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