diff --git a/src/.claude/settings.json b/src/.claude/settings.json index eeec559c30d..7d7999811a8 100644 --- a/src/.claude/settings.json +++ b/src/.claude/settings.json @@ -27,7 +27,10 @@ "WebFetch(domain:www.anthropic.com)", "WebFetch(domain:mistral.ai)", "Bash(pnpm i18n:*)", - "WebFetch(domain:simplelocalize.io)" + "WebFetch(domain:simplelocalize.io)", + "Bash(pnpm list:*)", + "Bash(pnpm why:*)", + "mcp__github__get_issue" ], "deny": [] } diff --git a/src/packages/database/postgres/news.ts b/src/packages/database/postgres/news.ts index 03c7b2c4195..cb94ebbeb26 100644 --- a/src/packages/database/postgres/news.ts +++ b/src/packages/database/postgres/news.ts @@ -23,11 +23,13 @@ export function clearCache(): void { const Q_FEED = ` SELECT id, channel, title, text, url, - extract(epoch from date::timestamp)::integer as date + extract(epoch from date::timestamp)::integer as date, + extract(epoch from until::timestamp)::integer as until FROM news WHERE news.date <= NOW() AND hide IS NOT TRUE AND channel != '${EVENT_CHANNEL}' + AND (until IS NULL OR until > NOW()) ORDER BY date DESC LIMIT 100`; @@ -40,7 +42,8 @@ export async function getFeedData(): Promise { const Q_BY_ID = ` SELECT id, channel, title, text, url, hide, tags, - extract(epoch from date::timestamptz)::INTEGER as date + extract(epoch from date::timestamptz)::INTEGER as date, + extract(epoch from until::timestamptz)::INTEGER as until FROM news WHERE id = $1`; @@ -56,7 +59,9 @@ const Q_BY_ID_USER = ` SELECT id, channel, title, text, url, hide, tags, history, date >= NOW() as future, - extract(epoch from date::timestamptz)::INTEGER as date + until IS NOT NULL AND until <= NOW() as expired, + extract(epoch from date::timestamptz)::INTEGER as date, + extract(epoch from until::timestamptz)::INTEGER as until FROM news WHERE id = $1`; @@ -68,6 +73,7 @@ WHERE date >= (SELECT date FROM news WHERE id = $1) AND hide IS NOT TRUE AND date < NOW() AND channel != '${EVENT_CHANNEL}' + AND (until IS NULL OR until > NOW()) ORDER BY date ASC, id ASC LIMIT 1`; @@ -79,6 +85,7 @@ WHERE date <= (SELECT date FROM news WHERE id = $1) AND hide IS NOT TRUE AND date < NOW() AND channel != '${EVENT_CHANNEL}' + AND (until IS NULL OR until > NOW()) ORDER BY date DESC, id DESC LIMIT 1`; @@ -104,7 +111,9 @@ const Q_INDEX = ` SELECT id, channel, title, text, url, hide, tags, date >= NOW() as future, - extract(epoch from date::timestamptz)::INTEGER as date + until IS NOT NULL AND until <= NOW() as expired, + extract(epoch from date::timestamptz)::INTEGER as date, + extract(epoch from until::timestamptz)::INTEGER as until FROM news WHERE channel <> '${EVENT_CHANNEL}' ORDER BY date DESC @@ -122,11 +131,13 @@ export async function getIndex( const Q_MOST_RECENT = ` SELECT id, channel, title, tags, - extract(epoch from date::timestamptz)::INTEGER as date + extract(epoch from date::timestamptz)::INTEGER as date, + extract(epoch from until::timestamptz)::INTEGER as until FROM news WHERE date <= NOW() AND hide IS NOT TRUE AND channel != '${EVENT_CHANNEL}' + AND (until IS NULL OR until > NOW()) ORDER BY date DESC LIMIT 1`; @@ -137,11 +148,13 @@ export async function getMostRecentNews(): Promise { const Q_RECENT = ` SELECT id, channel, title, tags, - extract(epoch from date::timestamptz)::INTEGER as date + extract(epoch from date::timestamptz)::INTEGER as date, + extract(epoch from until::timestamptz)::INTEGER as until FROM news WHERE date <= NOW() AND channel != '${EVENT_CHANNEL}' AND hide IS NOT TRUE + AND (until IS NULL OR until > NOW()) ORDER BY date DESC LIMIT $1`; @@ -158,11 +171,13 @@ export async function getRecentHeadlines( const Q_UPCOMING_NEWS_CHANNEL_ITEMS = ` SELECT id, channel, title, text, url, tags, - extract(epoch from date::timestamp)::integer as date + extract(epoch from date::timestamp)::integer as date, + extract(epoch from until::timestamp)::integer as until FROM news WHERE date >= NOW() AND channel = $1 AND hide IS NOT TRUE + AND (until IS NULL OR until > NOW()) ORDER BY date LIMIT 100`; @@ -176,11 +191,13 @@ export async function getUpcomingNewsChannelItems( const Q_PAST_NEWS_CHANNEL_ITEMS = ` SELECT id, channel, title, text, url, tags, - extract(epoch from date::timestamp)::integer as date + extract(epoch from date::timestamp)::integer as date, + extract(epoch from until::timestamp)::integer as until FROM news WHERE date <= NOW() AND channel = $1 AND hide IS NOT TRUE + AND (until IS NULL OR until > NOW()) ORDER BY date DESC LIMIT 100`; diff --git a/src/packages/next/components/news/news.tsx b/src/packages/next/components/news/news.tsx index 2dd420c3ae8..ae528707041 100644 --- a/src/packages/next/components/news/news.tsx +++ b/src/packages/next/components/news/news.tsx @@ -6,9 +6,11 @@ import { Alert, Button, Card, Flex, Space, Tag, Tooltip } from "antd"; import { useRouter } from "next/router"; import { Fragment } from "react"; +import TimeAgo from "timeago-react"; import { Icon, IconName } from "@cocalc/frontend/components/icon"; import Markdown from "@cocalc/frontend/editors/slate/static-markdown"; +import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults"; import { capitalize, getRandomColor, @@ -24,11 +26,9 @@ import { } from "@cocalc/util/types/news"; import { CSS, Paragraph, Text, Title } from "components/misc"; import A from "components/misc/A"; -import TimeAgo from "timeago-react"; -import { useDateStr } from "./useDateStr"; import { useCustomize } from "lib/customize"; -import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults"; import { SocialMediaShareLinks } from "../landing/social-media-share-links"; +import { useDateStr } from "./useDateStr"; const STYLE: CSS = { borderColor: COLORS.GRAY_M, @@ -36,8 +36,8 @@ const STYLE: CSS = { } as const; interface Props { - // NewsWithFuture with optional future property - news: NewsItem & { future?: boolean }; + // NewsWithStatus with optional future and expired properties + news: NewsItem & { future?: boolean; expired?: boolean }; dns?: string; showEdit?: boolean; small?: boolean; // limit height, essentially @@ -55,7 +55,19 @@ export function News(props: Props) { historyMode = false, onTagClick, } = props; - const { id, url, tags, title, date, channel, text, future, hide } = news; + const { + id, + url, + tags, + title, + date, + channel, + text, + future, + hide, + expired, + until, + } = news; const dateStr = useDateStr(news, historyMode); const permalink = slugURL(news); const { kucalc, siteURL } = useCustomize(); @@ -183,6 +195,28 @@ export function News(props: Props) { } } + function renderExpired() { + if (expired) { + return ( + + Expired news item, not shown to users. + {typeof until === "number" && ( + <> + {" "} + Expired . + + )} + + } + /> + ); + } + } + function renderTags() { return ; } @@ -269,6 +303,7 @@ export function News(props: Props) { {renderFuture()} {renderHidden()} + {renderExpired()} @@ -303,6 +338,7 @@ export function News(props: Props) { > {renderFuture()} {renderHidden()} + {renderExpired()} diff --git a/src/packages/next/components/news/types.ts b/src/packages/next/components/news/types.ts index e189de961ef..d4ea0a97da4 100644 --- a/src/packages/next/components/news/types.ts +++ b/src/packages/next/components/news/types.ts @@ -5,6 +5,7 @@ import { NewsItem } from "@cocalc/util/types/news"; -export interface NewsWithFuture extends NewsItem { +export interface NewsWithStatus extends NewsItem { future: boolean; + expired?: boolean; } diff --git a/src/packages/next/pages/about/events.tsx b/src/packages/next/pages/about/events.tsx index 638aff3fa53..924afea0bff 100644 --- a/src/packages/next/pages/about/events.tsx +++ b/src/packages/next/pages/about/events.tsx @@ -18,7 +18,7 @@ import IndexList, { DataSource } from "components/landing/index-list"; import { CSS } from "components/misc"; import A from "components/misc/A"; import { TagList } from "components/news/news"; -import type { NewsWithFuture } from "components/news/types"; +import type { NewsWithStatus } from "components/news/types"; import { useDateStr } from "components/news/useDateStr"; import { MAX_WIDTH } from "lib/config"; @@ -30,8 +30,9 @@ const BODY_STYLE: CSS = { maxHeight: "max(300px, 75vh)", overflowY: "auto", } as const; + interface TitleComponentProps { - newsItem: NewsWithFuture; + newsItem: NewsWithStatus; showHelpTicket?: boolean; } @@ -81,8 +82,8 @@ const TitleComponent = ({ newsItem, showHelpTicket }: TitleComponentProps) => ( interface EventsProps { customize: CustomizeType; - upcomingEvents: NewsWithFuture[]; - pastEvents: NewsWithFuture[]; + upcomingEvents: NewsWithStatus[]; + pastEvents: NewsWithStatus[]; } export default function Events({ diff --git a/src/packages/next/pages/api/v2/news/edit.ts b/src/packages/next/pages/api/v2/news/edit.ts index b7b57bcdf25..50d3a2d1acd 100644 --- a/src/packages/next/pages/api/v2/news/edit.ts +++ b/src/packages/next/pages/api/v2/news/edit.ts @@ -25,8 +25,8 @@ export default async function handle(req: Request, res: Response) { } async function doIt(req: Request) { - // date is unix timestamp in seconds - const { id, title, text, date, channel, url, tags, hide } = getParams(req); + // date and until are unix timestamps in seconds + const { id, title, text, date, channel, url, tags, hide, until } = getParams(req); const account_id = await getAccountId(req); @@ -55,5 +55,6 @@ async function doIt(req: Request) { date: date ? new Date(1000 * date) : new Date(), channel, hide: !!hide, + until: until ? new Date(1000 * until) : undefined, }); } diff --git a/src/packages/next/pages/news/[id].tsx b/src/packages/next/pages/news/[id].tsx index 1e57785977a..172649a02a4 100644 --- a/src/packages/next/pages/news/[id].tsx +++ b/src/packages/next/pages/news/[id].tsx @@ -21,7 +21,7 @@ import Head from "components/landing/head"; import Header from "components/landing/header"; import A from "components/misc/A"; import { News } from "components/news/news"; -import { NewsWithFuture } from "components/news/types"; +import { NewsWithStatus } from "components/news/types"; import { useDateStr } from "components/news/useDateStr"; import Loading from "components/share/loading"; import { MAX_WIDTH, NOT_FOUND } from "lib/config"; @@ -32,7 +32,7 @@ import withCustomize from "lib/with-customize"; interface Props { customize: CustomizeType; - news: NewsWithFuture; + news: NewsWithStatus; prev?: NewsPrevNext; next?: NewsPrevNext; metadata: { @@ -42,12 +42,14 @@ interface Props { image: string; published: string; modified: string; - } + }; } -const formatNewsTime = (newsDate: NewsWithFuture['date']) => ( - typeof newsDate === "number" ? dayjs.unix(newsDate) : dayjs(newsDate) -).toISOString(); +const formatNewsTime = (newsDate: NewsWithStatus["date"]) => + (typeof newsDate === "number" + ? dayjs.unix(newsDate) + : dayjs(newsDate) + ).toISOString(); export default function NewsPage(props: Props) { const { customize, news, prev, next, metadata } = props; @@ -146,17 +148,17 @@ export default function NewsPage(props: Props) { - + - - - + + + - - + + -
+
& { date: dayjs.Dayjs }; +type NewsTypeForm = Omit & { date: dayjs.Dayjs; until?: dayjs.Dayjs }; export default function EditNews(props: Props) { const { customize, news } = props; @@ -68,10 +68,12 @@ export default function EditNews(props: Props) { const date: dayjs.Dayjs = typeof news?.date === "number" ? dayjs.unix(news.date) : dayjs(); + const until: dayjs.Dayjs | undefined = + typeof news?.until === "number" ? dayjs.unix(news.until) : undefined; const init: NewsTypeForm = news != null - ? { ...news, tags: news.tags ?? [], date } + ? { ...news, tags: news.tags ?? [], date, until } : { hide: false, title: "", @@ -80,6 +82,7 @@ export default function EditNews(props: Props) { tags: [], channel: "feature", date: dayjs(), + until: undefined, }; const [data, setData] = useState(init); @@ -111,8 +114,13 @@ export default function EditNews(props: Props) { async function save() { setSaving(true); try { - // send data, but convert date field to epoch seconds - const next = { ...data, id, date: data.date.unix() }; + // send data, but convert date and until fields to epoch seconds + const next = { + ...data, + id, + date: data.date.unix(), + until: data.until?.unix() + }; const { channel } = data; const ret = await apiPost("/news/edit", next); if (ret == null || ret.id == null) { @@ -225,6 +233,14 @@ export default function EditNews(props: Props) { > + + + - + diff --git a/src/packages/next/pages/news/index.tsx b/src/packages/next/pages/news/index.tsx index 140e02c92d2..712a6029552 100644 --- a/src/packages/next/pages/news/index.tsx +++ b/src/packages/next/pages/news/index.tsx @@ -31,7 +31,7 @@ import Header from "components/landing/header"; import { Paragraph, Title } from "components/misc"; import A from "components/misc/A"; import { News } from "components/news/news"; -import type { NewsWithFuture } from "components/news/types"; +import type { NewsWithStatus } from "components/news/types"; import { MAX_WIDTH } from "lib/config"; import { Customize, CustomizeType } from "lib/customize"; import useProfile from "lib/hooks/profile"; @@ -52,7 +52,7 @@ function isChannelAll(s?: string): s is ChannelAll { } interface Props { customize: CustomizeType; - news: NewsWithFuture[]; + news: NewsWithStatus[]; offset: number; tag?: string; // used for searching for a tag, used on /news/[id] standalone pages channel?: string; // a channel to filter by @@ -130,17 +130,13 @@ export default function AllNews(props: Props) { onChange={(e) => setChannel(e.target.value)} > Show All - { - CHANNELS - .filter((c) => c !== "event") - .map((c) => ( - - - {capitalize(c)} - - - )) - } + {CHANNELS.filter((c) => c !== "event").map((c) => ( + + + {capitalize(c)} + + + ))} @@ -159,8 +155,8 @@ export default function AllNews(props: Props) { function renderNews() { const rendered = news - // only admins see future and hidden news - .filter((n) => isAdmin || (!n.future && !n.hide)) + // only admins see future, hidden, and expired news + .filter((n) => isAdmin || (!n.future && !n.hide && !n.expired)) .filter((n) => channel === "all" || n.channel == channel) .filter((n) => { if (search === "") return true; @@ -341,7 +337,7 @@ export default function AllNews(props: Props) { return ( - +
NOW())", ], pg_changefeed: "news", options: [{ order_by: "-date" }, { limit: 100 }], diff --git a/src/packages/util/types/news.ts b/src/packages/util/types/news.ts index d97b5c7decc..01ad10da032 100644 --- a/src/packages/util/types/news.ts +++ b/src/packages/util/types/news.ts @@ -12,6 +12,7 @@ interface NewsProto { text: string; // Markdown text title: string; // title of the news item, should be short url?: string; // URL link to an external page (not the news item itself) + until?: number | Date; // optional expiration date - news item will not be shown after this date } export interface NewsItem extends NewsProto {