Path: blob/master/src/packages/next/components/news/news.tsx
6055 views
/*1* This file is part of CoCalc: Copyright © 2023 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Alert, Button, Card, Flex, Space, Tag, Tooltip } from "antd";6import { useRouter } from "next/router";7import { Fragment } from "react";8import TimeAgo from "timeago-react";910import { Icon, IconName } from "@cocalc/frontend/components/icon";11import Markdown from "@cocalc/frontend/editors/slate/static-markdown";12import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults";13import {14capitalize,15getRandomColor,16plural,17unreachable,18} from "@cocalc/util/misc";19import { slugURL } from "@cocalc/util/news";20import { COLORS } from "@cocalc/util/theme";21import {22CHANNELS_DESCRIPTIONS,23CHANNELS_ICONS,24NewsItem,25} from "@cocalc/util/types/news";26import { CSS, Paragraph, Text, Title } from "components/misc";27import A from "components/misc/A";28import { useCustomize } from "lib/customize";29import { SocialMediaShareLinks } from "../landing/social-media-share-links";30import { useDateStr } from "./useDateStr";3132const STYLE: CSS = {33borderColor: COLORS.GRAY_M,34boxShadow: "0 0 0 1px rgba(0,0,0,.1), 0 3px 3px rgba(0,0,0,.3)",35} as const;3637interface Props {38// NewsWithStatus with optional future and expired properties39news: NewsItem & { future?: boolean; expired?: boolean };40dns?: string;41showEdit?: boolean;42small?: boolean; // limit height, essentially43standalone?: boolean; // default false44historyMode?: boolean; // default false45onTagClick?: (tag: string) => void;46}4748export function News(props: Props) {49const {50news,51showEdit = false,52small = false,53standalone = false,54historyMode = false,55onTagClick,56} = props;57const {58id,59url,60tags,61title,62date,63channel,64text,65future,66hide,67expired,68until,69} = news;70const dateStr = useDateStr(news, historyMode);71const permalink = slugURL(news);72const { kucalc, siteURL } = useCustomize();73const isCoCalcCom = kucalc === KUCALC_COCALC_COM;74const showShareLinks = typeof siteURL === "string" && isCoCalcCom;7576const bottomLinkStyle: CSS = {77color: COLORS.ANTD_LINK_BLUE,78...(standalone ? { fontSize: "125%", fontWeight: "bold" } : {}),79};8081function editLink() {82return (83<A84key="edit"85href={`/news/edit/${id}`}86style={{87...bottomLinkStyle,88color: COLORS.ANTD_RED_WARN,89}}90>91<Icon name="edit" /> Edit92</A>93);94}9596function readMoreLink(iconOnly = false, button = false) {97if (button) {98return (99<Button100type="primary"101style={{ color: "white", marginBottom: "30px" }}102href={url}103target="_blank"104key="url"105size={small ? undefined : "large"}106>107<Icon name="external-link" />108{iconOnly ? "" : " Read more"}109</Button>110);111} else {112return (113<A114key="url"115href={url}116style={{117...bottomLinkStyle,118...(small ? { color: COLORS.GRAY } : { fontWeight: "bold" }),119}}120>121<Icon name="external-link" />122{iconOnly ? "" : " Read more"}123</A>124);125}126}127128function renderOpenLink() {129return (130<A131key="permalink"132href={permalink}133style={{134...bottomLinkStyle,135...(small ? { fontWeight: "bold" } : {}),136}}137>138<Icon name="external-link" /> Open139</A>140);141}142143function shareLinks(text = false) {144return (145<SocialMediaShareLinks146title={title}147url={encodeURIComponent(`${siteURL}${permalink}`)}148showText={text}149standalone={standalone}150/>151);152}153154function actions() {155const actions = [renderOpenLink()];156if (url) actions.push(readMoreLink());157if (showEdit) actions.push(editLink());158if (showShareLinks) actions.push(shareLinks());159return actions;160}161162const style = small ? { height: "200px", overflow: "auto" } : undefined;163164function renderFuture() {165if (future) {166return (167<Alert168banner169message={170<>171Future event, not shown to users.172{typeof date === "number" && (173<>174{" "}175Will be live in <TimeAgo datetime={new Date(1000 * date)} />.176</>177)}178</>179}180/>181);182}183}184185function renderHidden() {186if (hide) {187return (188<Alert189banner190type="error"191message="Hidden, will not be shown to users."192/>193);194}195}196197function renderExpired() {198if (expired) {199return (200<Alert201banner202type="warning"203message={204<>205Expired news item, not shown to users.206{typeof until === "number" && (207<>208{" "}209Expired <TimeAgo datetime={new Date(1000 * until)} />.210</>211)}212</>213}214/>215);216}217}218219function renderTags() {220return <TagList mode="news" tags={tags} onTagClick={onTagClick} />;221}222223function extra() {224return (225<>226{renderTags()}227<Text type="secondary" style={{ float: "right" }}>228{dateStr}229</Text>230</>231);232}233234function renderTitle() {235return (236<>237<Tooltip238title={239<>240{capitalize(channel)}: {CHANNELS_DESCRIPTIONS[channel]}241</>242}243>244<Icon name={CHANNELS_ICONS[channel] as IconName} />245</Tooltip>{" "}246<A href={permalink}>{title}</A>247</>248);249}250251function renderHistory() {252const { history } = news;253if (!history) return;254// Object.keys always returns strings, so we need to parse them255const timestamps = Object.keys(history)256.map(Number)257.filter((ts) => !Number.isNaN(ts))258.sort()259.reverse();260if (timestamps.length > 0) {261return (262<Paragraph style={{ textAlign: "center" }}>263{historyMode && (264<>265<A href={permalink}>Current version</A> ·{" "}266</>267)}268Previous {plural(timestamps.length, "version")}:{" "}269{timestamps270.map((ts) => [271<A key={ts} href={`/news/${id}/${ts}`}>272<TimeAgo datetime={new Date(1000 * ts)} />273</A>,274<Fragment key={`m-${ts}`}> · </Fragment>,275])276.flat()277.slice(0, -1)}278</Paragraph>279);280}281}282283if (standalone) {284const renderedTags = renderTags();285return (286<>287{historyMode && (288<Paragraph>289<Text type="danger" strong>290Archived version291</Text>292<Text type="secondary" style={{ float: "right" }}>293Published: {dateStr}294</Text>295</Paragraph>296)}297<Title level={2}>298<Icon name={CHANNELS_ICONS[channel] as IconName} /> {title}299{renderedTags && (300<span style={{ float: "right" }}>{renderedTags}</span>301)}302</Title>303{renderFuture()}304{renderHidden()}305{renderExpired()}306<Markdown value={text} style={{ ...style, minHeight: "20vh" }} />307308<Flex align="baseline" justify="space-between" wrap="wrap">309{url && (310<Paragraph style={{ textAlign: "center" }}>311{readMoreLink(false, true)}312</Paragraph>313)}314<Paragraph315style={{316fontWeight: "bold",317textAlign: "center",318}}319>320<Space size="middle" direction="horizontal">321{showEdit ? editLink() : undefined}322{showShareLinks ? shareLinks(true) : undefined}323</Space>324</Paragraph>325{renderHistory()}326</Flex>327</>328);329} else {330return (331<>332<Card333title={renderTitle()}334style={STYLE}335extra={extra()}336actions={actions()}337>338{renderFuture()}339{renderHidden()}340{renderExpired()}341<Markdown value={text} style={style} />342</Card>343</>344);345}346}347348interface TagListProps {349tags?: string[];350onTagClick?: (tag: string) => void;351style?: CSS;352styleTag?: CSS;353mode: "news" | "event";354}355356export function TagList({357tags,358onTagClick,359style,360styleTag,361mode,362}: TagListProps) {363if (tags == null || !Array.isArray(tags) || tags.length === 0) return null;364365const router = useRouter();366367function onTagClickStandalone(tag: string) {368router.push(`/news?tag=${tag}`);369}370371function onClick(tag) {372switch (mode) {373case "news":374(onTagClick ?? onTagClickStandalone)(tag);375case "event":376return;377default:378unreachable(mode);379}380}381382function getStyle(): CSS {383return {384...(mode === "news" ? { cursor: "pointer" } : {}),385...styleTag,386};387}388389return (390<Space size={[0, 4]} wrap={false} style={style}>391{tags.sort().map((tag) => (392<Tag393color={getRandomColor(tag)}394key={tag}395style={getStyle()}396onClick={() => onClick(tag)}397>398{tag}399</Tag>400))}401</Space>402);403}404405406