Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/pages/news/index.tsx
6120 views
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 { NewsWithStatus } 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: NewsWithStatus[];
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
{CHANNELS.filter((c) => c !== "event").map((c) => (
134
<Tooltip key={c} title={CHANNELS_DESCRIPTIONS[c]}>
135
<Radio.Button key={c} value={c}>
136
<Icon name={CHANNELS_ICONS[c] as IconName} /> {capitalize(c)}
137
</Radio.Button>
138
</Tooltip>
139
))}
140
</Radio.Group>
141
</Col>
142
<Col>
143
<Input.Search
144
value={search}
145
onChange={(e) => setSearch(e.target.value)}
146
status={search ? "warning" : undefined}
147
addonBefore="Filter"
148
placeholder="Search text…"
149
allowClear
150
/>
151
</Col>
152
</Row>
153
);
154
}
155
156
function renderNews() {
157
const rendered = news
158
// only admins see future, hidden, and expired news
159
.filter((n) => isAdmin || (!n.future && !n.hide && !n.expired))
160
.filter((n) => channel === "all" || n.channel == channel)
161
.filter((n) => {
162
if (search === "") return true;
163
const txt = search.toLowerCase();
164
return (
165
// substring match for title and text
166
n.title.toLowerCase().includes(txt) ||
167
n.text.toLowerCase().includes(txt) ||
168
// exact match for tags
169
n.tags?.map((t) => `#${t.toLowerCase()}`).some((t) => t == txt)
170
);
171
})
172
.map((n) => (
173
<Col key={n.id} xs={24} sm={24} md={12}>
174
<News
175
news={n}
176
showEdit={isAdmin}
177
small
178
onTagClick={(tag) => {
179
const ht = `#${tag}`;
180
// that's a toggle: if user clicks again on the same tag, remove the search filter
181
search === ht ? setTag("") : setTag(ht);
182
}}
183
/>
184
</Col>
185
));
186
if (rendered.length === 0) {
187
return <Alert banner type="info" message="No news found" />;
188
} else {
189
return <Row gutter={[30, 30]}>{rendered}</Row>;
190
}
191
}
192
193
function adminInfo() {
194
if (!isAdmin) return;
195
return (
196
<Alert
197
banner={true}
198
type="warning"
199
message={
200
<>
201
Admin only: <A href="/news/edit/new">Create News Item</A>
202
</>
203
}
204
/>
205
);
206
}
207
208
function titleFeedIcons() {
209
return (
210
<Space direction="horizontal" size="middle" style={{ float: "right" }}>
211
<A href="/news/rss.xml" external>
212
<Image src={rssIcon} width={32} height={32} alt="RSS Feed" />
213
</A>
214
<A href="/news/feed.json" external>
215
<Image
216
src={jsonfeedIcon}
217
width={32}
218
height={32}
219
alt="JSON Feed"
220
style={{ borderRadius: "5px" }}
221
/>
222
</A>
223
</Space>
224
);
225
}
226
227
function content() {
228
return (
229
<>
230
<Title level={1}>
231
<Icon name="file-alt" /> {siteName} News
232
{titleFeedIcons()}
233
</Title>
234
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
235
<Paragraph>
236
<div style={{ float: "right" }}>{renderSlicer("small")}</div>
237
Recent news about {siteName}. You can also subscribe via{" "}
238
{/* This is intentionally a regular link, to "break out" of next.js */}
239
<A href="/news/rss.xml" external>
240
<Image src={rssIcon} width={16} height={16} alt="RSS Feed" /> RSS
241
Feed
242
</A>{" "}
243
or{" "}
244
<A href="/news/feed.json" external>
245
<Image
246
src={jsonfeedIcon}
247
width={16}
248
height={16}
249
alt="JSON Feed"
250
/>{" "}
251
JSON Feed
252
</A>
253
.
254
</Paragraph>
255
{renderFilter()}
256
{adminInfo()}
257
{renderNews()}
258
</Space>
259
</>
260
);
261
}
262
263
function slice(dir: "future" | "past") {
264
const next = offset + (dir === "future" ? -1 : 1) * SLICE_SIZE;
265
const newOffset = Math.max(0, next);
266
const query = { ...router.query };
267
if (newOffset === 0) {
268
delete query.offset;
269
} else {
270
query.offset = `${newOffset}`;
271
}
272
router.push({ query });
273
}
274
275
function renderSlicer(size?: "small") {
276
//if (news.length < SLICE_SIZE && offset === 0) return;
277
const extraProps = size === "small" ? { size } : {};
278
return (
279
<>
280
<Radio.Group optionType="button" {...extraProps}>
281
<Radio.Button
282
disabled={news.length < SLICE_SIZE}
283
onClick={() => slice("past")}
284
>
285
<Icon name="arrow-left" /> Older
286
</Radio.Button>
287
<Radio.Button disabled={offset === 0} onClick={() => slice("future")}>
288
Newer <Icon name="arrow-right" />
289
</Radio.Button>
290
</Radio.Group>
291
</>
292
);
293
}
294
295
function renderFeeds() {
296
const iconSize = 20;
297
return (
298
<>
299
<Divider
300
orientation="center"
301
style={{
302
marginTop: "60px",
303
textAlign: "center",
304
}}
305
/>
306
<div
307
style={{
308
marginTop: "60px",
309
marginBottom: "60px",
310
textAlign: "center",
311
}}
312
>
313
<Space direction="horizontal" size="large">
314
<A href="/news/rss.xml" external>
315
<Image
316
src={rssIcon}
317
width={iconSize}
318
height={iconSize}
319
alt="RSS Feed"
320
/>{" "}
321
RSS
322
</A>
323
<A href="/news/feed.json" external>
324
<Image
325
src={jsonfeedIcon}
326
width={iconSize}
327
height={iconSize}
328
alt="Json Feed"
329
/>{" "}
330
JSON
331
</A>
332
</Space>
333
</div>
334
</>
335
);
336
}
337
338
return (
339
<Customize value={customize}>
340
<Head title={`${siteName} News`} />
341
<Layout>
342
<Header page="news" />
343
<Layout.Content
344
style={{
345
backgroundColor: "white",
346
}}
347
>
348
<div
349
style={{
350
minHeight: "75vh",
351
maxWidth: MAX_WIDTH,
352
padding: "30px 15px",
353
margin: "0 auto",
354
}}
355
>
356
{content()}
357
<div style={{ marginTop: "60px", textAlign: "center" }}>
358
{renderSlicer()}
359
</div>
360
{renderFeeds()}
361
</div>
362
<Footer />
363
</Layout.Content>
364
</Layout>
365
</Customize>
366
);
367
}
368
369
export async function getServerSideProps(context: GetServerSidePropsContext) {
370
const { query } = context;
371
const tag = typeof query.tag === "string" ? query.tag : null;
372
const channel = typeof query.channel === "string" ? query.channel : null;
373
const search = typeof query.search === "string" ? query.search : null;
374
const offsetVal = Number(query.offset ?? 0);
375
const offset = Math.max(0, Number.isNaN(offsetVal) ? 0 : offsetVal);
376
const news = await getIndex(SLICE_SIZE, offset);
377
return await withCustomize({
378
context,
379
props: { news, offset, tag, channel, search },
380
});
381
}
382
383