Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/next/pages/news/index.tsx
Views: 687
/*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 { NewsWithFuture } 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: NewsWithFuture[];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{133CHANNELS134.filter((c) => c !== "event")135.map((c) => (136<Tooltip key={c} title={CHANNELS_DESCRIPTIONS[c]}>137<Radio.Button key={c} value={c}>138<Icon name={CHANNELS_ICONS[c] as IconName}/> {capitalize(c)}139</Radio.Button>140</Tooltip>141))142}143</Radio.Group>144</Col>145<Col>146<Input.Search147value={search}148onChange={(e) => setSearch(e.target.value)}149status={search ? "warning" : undefined}150addonBefore="Filter"151placeholder="Search text…"152allowClear153/>154</Col>155</Row>156);157}158159function renderNews() {160const rendered = news161// only admins see future and hidden news162.filter((n) => isAdmin || (!n.future && !n.hide))163.filter((n) => channel === "all" || n.channel == channel)164.filter((n) => {165if (search === "") return true;166const txt = search.toLowerCase();167return (168// substring match for title and text169n.title.toLowerCase().includes(txt) ||170n.text.toLowerCase().includes(txt) ||171// exact match for tags172n.tags?.map((t) => `#${t.toLowerCase()}`).some((t) => t == txt)173);174})175.map((n) => (176<Col key={n.id} xs={24} sm={24} md={12}>177<News178news={n}179showEdit={isAdmin}180small181onTagClick={(tag) => {182const ht = `#${tag}`;183// that's a toggle: if user clicks again on the same tag, remove the search filter184search === ht ? setTag("") : setTag(ht);185}}186/>187</Col>188));189if (rendered.length === 0) {190return <Alert banner type="info" message="No news found" />;191} else {192return <Row gutter={[30, 30]}>{rendered}</Row>;193}194}195196function adminInfo() {197if (!isAdmin) return;198return (199<Alert200banner={true}201type="warning"202message={203<>204Admin only: <A href="/news/edit/new">Create News Item</A>205</>206}207/>208);209}210211function titleFeedIcons() {212return (213<Space direction="horizontal" size="middle" style={{ float: "right" }}>214<A href="/news/rss.xml" external>215<Image src={rssIcon} width={32} height={32} alt="RSS Feed" />216</A>217<A href="/news/feed.json" external>218<Image219src={jsonfeedIcon}220width={32}221height={32}222alt="JSON Feed"223style={{ borderRadius: "5px" }}224/>225</A>226</Space>227);228}229230function content() {231return (232<>233<Title level={1}>234<Icon name="file-alt" /> {siteName} News235{titleFeedIcons()}236</Title>237<Space direction="vertical" size="middle" style={{ width: "100%" }}>238<Paragraph>239<div style={{ float: "right" }}>{renderSlicer("small")}</div>240Recent news about {siteName}. You can also subscribe via{" "}241{/* This is intentionally a regular link, to "break out" of next.js */}242<A href="/news/rss.xml" external>243<Image src={rssIcon} width={16} height={16} alt="RSS Feed" /> RSS244Feed245</A>{" "}246or{" "}247<A href="/news/feed.json" external>248<Image249src={jsonfeedIcon}250width={16}251height={16}252alt="JSON Feed"253/>{" "}254JSON Feed255</A>256.257</Paragraph>258{renderFilter()}259{adminInfo()}260{renderNews()}261</Space>262</>263);264}265266function slice(dir: "future" | "past") {267const next = offset + (dir === "future" ? -1 : 1) * SLICE_SIZE;268const newOffset = Math.max(0, next);269const query = { ...router.query };270if (newOffset === 0) {271delete query.offset;272} else {273query.offset = `${newOffset}`;274}275router.push({ query });276}277278function renderSlicer(size?: "small") {279//if (news.length < SLICE_SIZE && offset === 0) return;280const extraProps = size === "small" ? { size } : {};281return (282<>283<Radio.Group optionType="button" {...extraProps}>284<Radio.Button285disabled={news.length < SLICE_SIZE}286onClick={() => slice("past")}287>288<Icon name="arrow-left" /> Older289</Radio.Button>290<Radio.Button disabled={offset === 0} onClick={() => slice("future")}>291Newer <Icon name="arrow-right" />292</Radio.Button>293</Radio.Group>294</>295);296}297298function renderFeeds() {299const iconSize = 20;300return (301<>302<Divider303orientation="center"304style={{305marginTop: "60px",306textAlign: "center",307}}308/>309<div310style={{311marginTop: "60px",312marginBottom: "60px",313textAlign: "center",314}}315>316<Space direction="horizontal" size="large">317<A href="/news/rss.xml" external>318<Image319src={rssIcon}320width={iconSize}321height={iconSize}322alt="RSS Feed"323/>{" "}324RSS325</A>326<A href="/news/feed.json" external>327<Image328src={jsonfeedIcon}329width={iconSize}330height={iconSize}331alt="Json Feed"332/>{" "}333JSON334</A>335</Space>336</div>337</>338);339}340341return (342<Customize value={customize}>343<Head title={`${siteName} News`}/>344<Layout>345<Header page="news" />346<Layout.Content347style={{348backgroundColor: "white",349}}350>351<div352style={{353minHeight: "75vh",354maxWidth: MAX_WIDTH,355padding: "30px 15px",356margin: "0 auto",357}}358>359{content()}360<div style={{ marginTop: "60px", textAlign: "center" }}>361{renderSlicer()}362</div>363{renderFeeds()}364</div>365<Footer />366</Layout.Content>367</Layout>368</Customize>369);370}371372export async function getServerSideProps(context: GetServerSidePropsContext) {373const { query } = context;374const tag = typeof query.tag === "string" ? query.tag : null;375const channel = typeof query.channel === "string" ? query.channel : null;376const search = typeof query.search === "string" ? query.search : null;377const offsetVal = Number(query.offset ?? 0);378const offset = Math.max(0, Number.isNaN(offsetVal) ? 0 : offsetVal);379const news = await getIndex(SLICE_SIZE, offset);380return await withCustomize({381context,382props: { news, offset, tag, channel, search },383});384}385386387