CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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