Path: blob/master/src/packages/next/pages/news/index.tsx
6120 views
/*1* This file is part of CoCalc: Copyright © 2021 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import {6Alert,7Col,8Divider,9Input,10Layout,11Radio,12Row,13Space,14Tooltip,15} from "antd";16import { useEffect, useState } from "react";1718import { getIndex } from "@cocalc/database/postgres/news";19import { Icon, IconName } from "@cocalc/frontend/components/icon";20import { capitalize } from "@cocalc/util/misc";21import {22CHANNELS,23CHANNELS_DESCRIPTIONS,24CHANNELS_ICONS,25Channel,26} from "@cocalc/util/types/news";27import Footer from "components/landing/footer";28import Head from "components/landing/head";29import Header from "components/landing/header";30import { Paragraph, Title } from "components/misc";31import A from "components/misc/A";32import { News } from "components/news/news";33import type { NewsWithStatus } from "components/news/types";34import { MAX_WIDTH } from "lib/config";35import { Customize, CustomizeType } from "lib/customize";36import useProfile from "lib/hooks/profile";37import withCustomize from "lib/with-customize";38import { GetServerSidePropsContext } from "next";39import Image from "components/landing/image";40import { useRouter } from "next/router";41import jsonfeedIcon from "public/jsonfeed.png";42import rssIcon from "public/rss.svg";4344// news shown per page45const SLICE_SIZE = 10;4647type ChannelAll = Channel | "all";4849function isChannelAll(s?: string): s is ChannelAll {50return s != null && (CHANNELS.includes(s as Channel) || s === "all");51}52interface Props {53customize: CustomizeType;54news: NewsWithStatus[];55offset: number;56tag?: string; // used for searching for a tag, used on /news/[id] standalone pages57channel?: string; // a channel to filter by58search?: string; // a search query59}6061export default function AllNews(props: Props) {62const {63customize,64news,65offset,66tag,67channel: initChannel,68search: initSearch,69} = props;70const { siteName } = customize;71const router = useRouter();72const profile = useProfile({ noCache: true });73const isAdmin = profile?.is_admin;7475const [channel, setChannel] = useState<ChannelAll>(76isChannelAll(initChannel) ? initChannel : "all",77);78const [search, setSearchState] = useState<string>(initSearch ?? "");7980// when loading the page, we want to set the search to the given tag81useEffect(() => {82if (tag) setSearchState(`#${tag}`);83}, []);8485function setQuery(param: "tag" | "search" | "channel", value: string) {86const query = { ...router.query };87switch (param) {88case "tag":89delete query.search;90break;91case "search":92delete query.tag;93break;94}9596if (param === "channel" && value === "all") {97delete query.channel;98} else if (value) {99query[param] = param === "tag" ? value.slice(1) : value;100} else {101delete query[param];102}103router.replace({ query }, undefined, { shallow: true });104}105106// when the filter changes, change the channel=[filter] query parameter of the url107useEffect(() => {108setQuery("channel", channel);109}, [channel]);110111function setTag(tag: string) {112setSearchState(tag);113setQuery("tag", tag);114}115116function setSearch(search: string) {117setSearchState(search);118setQuery("search", search);119}120121function renderFilter() {122return (123<Row justify="space-between" gutter={15}>124<Col>125<Radio.Group126defaultValue={"all"}127value={channel}128buttonStyle="solid"129onChange={(e) => setChannel(e.target.value)}130>131<Radio.Button value="all">Show All</Radio.Button>132{CHANNELS.filter((c) => c !== "event").map((c) => (133<Tooltip key={c} title={CHANNELS_DESCRIPTIONS[c]}>134<Radio.Button key={c} value={c}>135<Icon name={CHANNELS_ICONS[c] as IconName} /> {capitalize(c)}136</Radio.Button>137</Tooltip>138))}139</Radio.Group>140</Col>141<Col>142<Input.Search143value={search}144onChange={(e) => setSearch(e.target.value)}145status={search ? "warning" : undefined}146addonBefore="Filter"147placeholder="Search text…"148allowClear149/>150</Col>151</Row>152);153}154155function renderNews() {156const rendered = news157// only admins see future, hidden, and expired news158.filter((n) => isAdmin || (!n.future && !n.hide && !n.expired))159.filter((n) => channel === "all" || n.channel == channel)160.filter((n) => {161if (search === "") return true;162const txt = search.toLowerCase();163return (164// substring match for title and text165n.title.toLowerCase().includes(txt) ||166n.text.toLowerCase().includes(txt) ||167// exact match for tags168n.tags?.map((t) => `#${t.toLowerCase()}`).some((t) => t == txt)169);170})171.map((n) => (172<Col key={n.id} xs={24} sm={24} md={12}>173<News174news={n}175showEdit={isAdmin}176small177onTagClick={(tag) => {178const ht = `#${tag}`;179// that's a toggle: if user clicks again on the same tag, remove the search filter180search === ht ? setTag("") : setTag(ht);181}}182/>183</Col>184));185if (rendered.length === 0) {186return <Alert banner type="info" message="No news found" />;187} else {188return <Row gutter={[30, 30]}>{rendered}</Row>;189}190}191192function adminInfo() {193if (!isAdmin) return;194return (195<Alert196banner={true}197type="warning"198message={199<>200Admin only: <A href="/news/edit/new">Create News Item</A>201</>202}203/>204);205}206207function titleFeedIcons() {208return (209<Space direction="horizontal" size="middle" style={{ float: "right" }}>210<A href="/news/rss.xml" external>211<Image src={rssIcon} width={32} height={32} alt="RSS Feed" />212</A>213<A href="/news/feed.json" external>214<Image215src={jsonfeedIcon}216width={32}217height={32}218alt="JSON Feed"219style={{ borderRadius: "5px" }}220/>221</A>222</Space>223);224}225226function content() {227return (228<>229<Title level={1}>230<Icon name="file-alt" /> {siteName} News231{titleFeedIcons()}232</Title>233<Space direction="vertical" size="middle" style={{ width: "100%" }}>234<Paragraph>235<div style={{ float: "right" }}>{renderSlicer("small")}</div>236Recent news about {siteName}. You can also subscribe via{" "}237{/* This is intentionally a regular link, to "break out" of next.js */}238<A href="/news/rss.xml" external>239<Image src={rssIcon} width={16} height={16} alt="RSS Feed" /> RSS240Feed241</A>{" "}242or{" "}243<A href="/news/feed.json" external>244<Image245src={jsonfeedIcon}246width={16}247height={16}248alt="JSON Feed"249/>{" "}250JSON Feed251</A>252.253</Paragraph>254{renderFilter()}255{adminInfo()}256{renderNews()}257</Space>258</>259);260}261262function slice(dir: "future" | "past") {263const next = offset + (dir === "future" ? -1 : 1) * SLICE_SIZE;264const newOffset = Math.max(0, next);265const query = { ...router.query };266if (newOffset === 0) {267delete query.offset;268} else {269query.offset = `${newOffset}`;270}271router.push({ query });272}273274function renderSlicer(size?: "small") {275//if (news.length < SLICE_SIZE && offset === 0) return;276const extraProps = size === "small" ? { size } : {};277return (278<>279<Radio.Group optionType="button" {...extraProps}>280<Radio.Button281disabled={news.length < SLICE_SIZE}282onClick={() => slice("past")}283>284<Icon name="arrow-left" /> Older285</Radio.Button>286<Radio.Button disabled={offset === 0} onClick={() => slice("future")}>287Newer <Icon name="arrow-right" />288</Radio.Button>289</Radio.Group>290</>291);292}293294function renderFeeds() {295const iconSize = 20;296return (297<>298<Divider299orientation="center"300style={{301marginTop: "60px",302textAlign: "center",303}}304/>305<div306style={{307marginTop: "60px",308marginBottom: "60px",309textAlign: "center",310}}311>312<Space direction="horizontal" size="large">313<A href="/news/rss.xml" external>314<Image315src={rssIcon}316width={iconSize}317height={iconSize}318alt="RSS Feed"319/>{" "}320RSS321</A>322<A href="/news/feed.json" external>323<Image324src={jsonfeedIcon}325width={iconSize}326height={iconSize}327alt="Json Feed"328/>{" "}329JSON330</A>331</Space>332</div>333</>334);335}336337return (338<Customize value={customize}>339<Head title={`${siteName} News`} />340<Layout>341<Header page="news" />342<Layout.Content343style={{344backgroundColor: "white",345}}346>347<div348style={{349minHeight: "75vh",350maxWidth: MAX_WIDTH,351padding: "30px 15px",352margin: "0 auto",353}}354>355{content()}356<div style={{ marginTop: "60px", textAlign: "center" }}>357{renderSlicer()}358</div>359{renderFeeds()}360</div>361<Footer />362</Layout.Content>363</Layout>364</Customize>365);366}367368export async function getServerSideProps(context: GetServerSidePropsContext) {369const { query } = context;370const tag = typeof query.tag === "string" ? query.tag : null;371const channel = typeof query.channel === "string" ? query.channel : null;372const search = typeof query.search === "string" ? query.search : null;373const offsetVal = Number(query.offset ?? 0);374const offset = Math.max(0, Number.isNaN(offsetVal) ? 0 : offsetVal);375const news = await getIndex(SLICE_SIZE, offset);376return await withCustomize({377context,378props: { news, offset, tag, channel, search },379});380}381382383