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/pages/news/index.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2021 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
Alert,
8
Col,
9
Divider,
10
Input,
11
Layout,
12
Radio,
13
Row,
14
Space,
15
Tooltip,
16
} from "antd";
17
import { useEffect, useState } from "react";
18
19
import { getIndex } from "@cocalc/database/postgres/news";
20
import { Icon, IconName } from "@cocalc/frontend/components/icon";
21
import { capitalize } from "@cocalc/util/misc";
22
import {
23
CHANNELS,
24
CHANNELS_DESCRIPTIONS,
25
CHANNELS_ICONS,
26
Channel,
27
} from "@cocalc/util/types/news";
28
import Footer from "components/landing/footer";
29
import Head from "components/landing/head";
30
import Header from "components/landing/header";
31
import { Paragraph, Title } from "components/misc";
32
import A from "components/misc/A";
33
import { News } from "components/news/news";
34
import type { NewsWithFuture } from "components/news/types";
35
import { MAX_WIDTH } from "lib/config";
36
import { Customize, CustomizeType } from "lib/customize";
37
import useProfile from "lib/hooks/profile";
38
import withCustomize from "lib/with-customize";
39
import { GetServerSidePropsContext } from "next";
40
import Image from "components/landing/image";
41
import { useRouter } from "next/router";
42
import jsonfeedIcon from "public/jsonfeed.png";
43
import rssIcon from "public/rss.svg";
44
45
// news shown per page
46
const SLICE_SIZE = 10;
47
48
type ChannelAll = Channel | "all";
49
50
function isChannelAll(s?: string): s is ChannelAll {
51
return s != null && (CHANNELS.includes(s as Channel) || s === "all");
52
}
53
interface Props {
54
customize: CustomizeType;
55
news: NewsWithFuture[];
56
offset: number;
57
tag?: string; // used for searching for a tag, used on /news/[id] standalone pages
58
channel?: string; // a channel to filter by
59
search?: string; // a search query
60
}
61
62
export default function AllNews(props: Props) {
63
const {
64
customize,
65
news,
66
offset,
67
tag,
68
channel: initChannel,
69
search: initSearch,
70
} = props;
71
const { siteName } = customize;
72
const router = useRouter();
73
const profile = useProfile({ noCache: true });
74
const isAdmin = profile?.is_admin;
75
76
const [channel, setChannel] = useState<ChannelAll>(
77
isChannelAll(initChannel) ? initChannel : "all",
78
);
79
const [search, setSearchState] = useState<string>(initSearch ?? "");
80
81
// when loading the page, we want to set the search to the given tag
82
useEffect(() => {
83
if (tag) setSearchState(`#${tag}`);
84
}, []);
85
86
function setQuery(param: "tag" | "search" | "channel", value: string) {
87
const query = { ...router.query };
88
switch (param) {
89
case "tag":
90
delete query.search;
91
break;
92
case "search":
93
delete query.tag;
94
break;
95
}
96
97
if (param === "channel" && value === "all") {
98
delete query.channel;
99
} else if (value) {
100
query[param] = param === "tag" ? value.slice(1) : value;
101
} else {
102
delete query[param];
103
}
104
router.replace({ query }, undefined, { shallow: true });
105
}
106
107
// when the filter changes, change the channel=[filter] query parameter of the url
108
useEffect(() => {
109
setQuery("channel", channel);
110
}, [channel]);
111
112
function setTag(tag: string) {
113
setSearchState(tag);
114
setQuery("tag", tag);
115
}
116
117
function setSearch(search: string) {
118
setSearchState(search);
119
setQuery("search", search);
120
}
121
122
function renderFilter() {
123
return (
124
<Row justify="space-between" gutter={15}>
125
<Col>
126
<Radio.Group
127
defaultValue={"all"}
128
value={channel}
129
buttonStyle="solid"
130
onChange={(e) => setChannel(e.target.value)}
131
>
132
<Radio.Button value="all">Show All</Radio.Button>
133
{
134
CHANNELS
135
.filter((c) => c !== "event")
136
.map((c) => (
137
<Tooltip key={c} title={CHANNELS_DESCRIPTIONS[c]}>
138
<Radio.Button key={c} value={c}>
139
<Icon name={CHANNELS_ICONS[c] as IconName}/> {capitalize(c)}
140
</Radio.Button>
141
</Tooltip>
142
))
143
}
144
</Radio.Group>
145
</Col>
146
<Col>
147
<Input.Search
148
value={search}
149
onChange={(e) => setSearch(e.target.value)}
150
status={search ? "warning" : undefined}
151
addonBefore="Filter"
152
placeholder="Search text…"
153
allowClear
154
/>
155
</Col>
156
</Row>
157
);
158
}
159
160
function renderNews() {
161
const rendered = news
162
// only admins see future and hidden news
163
.filter((n) => isAdmin || (!n.future && !n.hide))
164
.filter((n) => channel === "all" || n.channel == channel)
165
.filter((n) => {
166
if (search === "") return true;
167
const txt = search.toLowerCase();
168
return (
169
// substring match for title and text
170
n.title.toLowerCase().includes(txt) ||
171
n.text.toLowerCase().includes(txt) ||
172
// exact match for tags
173
n.tags?.map((t) => `#${t.toLowerCase()}`).some((t) => t == txt)
174
);
175
})
176
.map((n) => (
177
<Col key={n.id} xs={24} sm={24} md={12}>
178
<News
179
news={n}
180
showEdit={isAdmin}
181
small
182
onTagClick={(tag) => {
183
const ht = `#${tag}`;
184
// that's a toggle: if user clicks again on the same tag, remove the search filter
185
search === ht ? setTag("") : setTag(ht);
186
}}
187
/>
188
</Col>
189
));
190
if (rendered.length === 0) {
191
return <Alert banner type="info" message="No news found" />;
192
} else {
193
return <Row gutter={[30, 30]}>{rendered}</Row>;
194
}
195
}
196
197
function adminInfo() {
198
if (!isAdmin) return;
199
return (
200
<Alert
201
banner={true}
202
type="warning"
203
message={
204
<>
205
Admin only: <A href="/news/edit/new">Create News Item</A>
206
</>
207
}
208
/>
209
);
210
}
211
212
function titleFeedIcons() {
213
return (
214
<Space direction="horizontal" size="middle" style={{ float: "right" }}>
215
<A href="/news/rss.xml" external>
216
<Image src={rssIcon} width={32} height={32} alt="RSS Feed" />
217
</A>
218
<A href="/news/feed.json" external>
219
<Image
220
src={jsonfeedIcon}
221
width={32}
222
height={32}
223
alt="JSON Feed"
224
style={{ borderRadius: "5px" }}
225
/>
226
</A>
227
</Space>
228
);
229
}
230
231
function content() {
232
return (
233
<>
234
<Title level={1}>
235
<Icon name="file-alt" /> {siteName} News
236
{titleFeedIcons()}
237
</Title>
238
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
239
<Paragraph>
240
<div style={{ float: "right" }}>{renderSlicer("small")}</div>
241
Recent news about {siteName}. You can also subscribe via{" "}
242
{/* This is intentionally a regular link, to "break out" of next.js */}
243
<A href="/news/rss.xml" external>
244
<Image src={rssIcon} width={16} height={16} alt="RSS Feed" /> RSS
245
Feed
246
</A>{" "}
247
or{" "}
248
<A href="/news/feed.json" external>
249
<Image
250
src={jsonfeedIcon}
251
width={16}
252
height={16}
253
alt="JSON Feed"
254
/>{" "}
255
JSON Feed
256
</A>
257
.
258
</Paragraph>
259
{renderFilter()}
260
{adminInfo()}
261
{renderNews()}
262
</Space>
263
</>
264
);
265
}
266
267
function slice(dir: "future" | "past") {
268
const next = offset + (dir === "future" ? -1 : 1) * SLICE_SIZE;
269
const newOffset = Math.max(0, next);
270
const query = { ...router.query };
271
if (newOffset === 0) {
272
delete query.offset;
273
} else {
274
query.offset = `${newOffset}`;
275
}
276
router.push({ query });
277
}
278
279
function renderSlicer(size?: "small") {
280
//if (news.length < SLICE_SIZE && offset === 0) return;
281
const extraProps = size === "small" ? { size } : {};
282
return (
283
<>
284
<Radio.Group optionType="button" {...extraProps}>
285
<Radio.Button
286
disabled={news.length < SLICE_SIZE}
287
onClick={() => slice("past")}
288
>
289
<Icon name="arrow-left" /> Older
290
</Radio.Button>
291
<Radio.Button disabled={offset === 0} onClick={() => slice("future")}>
292
Newer <Icon name="arrow-right" />
293
</Radio.Button>
294
</Radio.Group>
295
</>
296
);
297
}
298
299
function renderFeeds() {
300
const iconSize = 20;
301
return (
302
<>
303
<Divider
304
orientation="center"
305
style={{
306
marginTop: "60px",
307
textAlign: "center",
308
}}
309
/>
310
<div
311
style={{
312
marginTop: "60px",
313
marginBottom: "60px",
314
textAlign: "center",
315
}}
316
>
317
<Space direction="horizontal" size="large">
318
<A href="/news/rss.xml" external>
319
<Image
320
src={rssIcon}
321
width={iconSize}
322
height={iconSize}
323
alt="RSS Feed"
324
/>{" "}
325
RSS
326
</A>
327
<A href="/news/feed.json" external>
328
<Image
329
src={jsonfeedIcon}
330
width={iconSize}
331
height={iconSize}
332
alt="Json Feed"
333
/>{" "}
334
JSON
335
</A>
336
</Space>
337
</div>
338
</>
339
);
340
}
341
342
return (
343
<Customize value={customize}>
344
<Head title={`${siteName} News`}/>
345
<Layout>
346
<Header page="news" />
347
<Layout.Content
348
style={{
349
backgroundColor: "white",
350
}}
351
>
352
<div
353
style={{
354
minHeight: "75vh",
355
maxWidth: MAX_WIDTH,
356
padding: "30px 15px",
357
margin: "0 auto",
358
}}
359
>
360
{content()}
361
<div style={{ marginTop: "60px", textAlign: "center" }}>
362
{renderSlicer()}
363
</div>
364
{renderFeeds()}
365
</div>
366
<Footer />
367
</Layout.Content>
368
</Layout>
369
</Customize>
370
);
371
}
372
373
export async function getServerSideProps(context: GetServerSidePropsContext) {
374
const { query } = context;
375
const tag = typeof query.tag === "string" ? query.tag : null;
376
const channel = typeof query.channel === "string" ? query.channel : null;
377
const search = typeof query.search === "string" ? query.search : null;
378
const offsetVal = Number(query.offset ?? 0);
379
const offset = Math.max(0, Number.isNaN(offsetVal) ? 0 : offsetVal);
380
const news = await getIndex(SLICE_SIZE, offset);
381
return await withCustomize({
382
context,
383
props: { news, offset, tag, channel, search },
384
});
385
}
386
387