From 2a2d2b75da94688e1da512ca71e88dfeea6cf1a8 Mon Sep 17 00:00:00 2001 From: Roger Yu Date: Wed, 20 Jan 2021 23:22:40 -0500 Subject: [PATCH] Port Post & Posts --- .../src/components/Post/PostComponent.tsx | 192 ++++++++++++++++++ .../src/components/Posts/LoadAutoScroll.tsx | 58 ++++++ .../next/src/components/Posts/Posts.tsx | 107 ++++++++++ .../next/src/components/Posts/Timeline.tsx | 57 ++++++ src/frontend/next/src/interfaces/index.ts | 10 + 5 files changed, 424 insertions(+) create mode 100644 src/frontend/next/src/components/Post/PostComponent.tsx create mode 100644 src/frontend/next/src/components/Posts/LoadAutoScroll.tsx create mode 100644 src/frontend/next/src/components/Posts/Posts.tsx create mode 100644 src/frontend/next/src/components/Posts/Timeline.tsx diff --git a/src/frontend/next/src/components/Post/PostComponent.tsx b/src/frontend/next/src/components/Post/PostComponent.tsx new file mode 100644 index 0000000000..9b5cc07432 --- /dev/null +++ b/src/frontend/next/src/components/Post/PostComponent.tsx @@ -0,0 +1,192 @@ +import { useRef, useState } from 'react'; + +import useSWR from 'swr'; + +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { Box, Grid, Typography, ListSubheader, createStyles } from '@material-ui/core'; +import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; +import { Post } from '../../interfaces'; +import { AdminButtons } from '../AdminButtons'; +import Spinner from '../Spinner'; + +type Props = { + postUrl: string; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: 0, + fontSize: '1.5rem', + marginBottom: '4em', + }, + header: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.text.secondary, + padding: '2em 3em 2em 3em', + lineHeight: '1.3', + zIndex: 1100, + top: '-1.1em', + [theme.breakpoints.down(1440)]: { + paddingTop: '1.6em', + paddingBottom: '1.5em', + }, + [theme.breakpoints.down(1065)]: { + position: 'static', + }, + }, + expandHeader: { + whiteSpace: 'normal', + cursor: 'pointer', + }, + collapseHeader: { + whiteSpace: 'nowrap', + cursor: 'pointer', + }, + title: { + fontSize: '3.5em', + fontWeight: 'bold', + overflow: 'hidden', + textOverflow: 'ellipsis', + [theme.breakpoints.between('xs', 'sm')]: { + fontSize: '2.5em', + }, + }, + author: { + fontSize: '1.5em', + fontWeight: 'bold', + color: theme.palette.primary.contrastText, + [theme.breakpoints.between('xs', 'sm')]: { + fontSize: '1.2em', + }, + }, + published: { + fontSize: '1.2em', + textDecoration: 'none', + color: theme.palette.primary.contrastText, + [theme.breakpoints.between('xs', 'sm')]: { + fontSize: '1em', + }, + }, + content: { + overflow: 'auto', + padding: '2em', + color: theme.palette.text.primary, + }, + link: { + textDecoration: 'none', + color: theme.palette.primary.contrastText, + '&:hover': { + textDecorationLine: 'underline', + }, + }, + time: { + '&:hover': { + textDecorationLine: 'underline', + }, + }, + spinner: { + padding: '20px', + }, + error: { + lineHeight: '1.00', + fontSize: '1em', + }, + }) +); + +const formatPublishedDate = (dateString: string) => { + const date = new Date(dateString); + const options = { month: 'long', day: 'numeric', year: 'numeric' }; + const formatted = new Intl.DateTimeFormat('en-CA', options).format(date); + return `Last Updated ${formatted}`; +}; + +const PostComponent = ({ postUrl }: Props) => { + const classes = useStyles(); + // We need a ref to our post content, which we inject into a
below. + const sectionEl = useRef(null); + // Grab the post data from our backend so we can render it + const { data: post, error } = useSWR(postUrl); + const [expandHeader, setExpandHeader] = useState(false); + + if (error) { + console.error(`Error loading post at ${postUrl}`, error); + return ( + + + + + + + + {' '} + - Post Failed to Load + + + + + ); + } + + if (!post) { + return ( + + + + + Loading Blog... + + + + + + + + + + ); + } + + return ( + + + + + setExpandHeader(!expandHeader)} + onKeyDown={() => setExpandHeader(!expandHeader)} + className={expandHeader ? classes.expandHeader : classes.collapseHeader} + > + {post.title} + + + +  By  + + {post.feed.author} + + + + + + + + + +
+ + + + ); +}; + +export default PostComponent; diff --git a/src/frontend/next/src/components/Posts/LoadAutoScroll.tsx b/src/frontend/next/src/components/Posts/LoadAutoScroll.tsx new file mode 100644 index 0000000000..1a3e5b7d2a --- /dev/null +++ b/src/frontend/next/src/components/Posts/LoadAutoScroll.tsx @@ -0,0 +1,58 @@ +import { useEffect, createRef } from 'react'; + +import { Container, Button, Grid, createStyles, Theme } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +type Scroll = { + onScroll: Function; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + content: { + '& > *': { + padding: theme.spacing(2), + bottom: theme.spacing(4), + }, + }, + }) +); + +function LoadAutoScroll({ onScroll }: Scroll) { + const classes = useStyles(); + const $buttonRef = createRef(); + // This will make the automatic infinite scrolling feature + // Once the "button" is on the viewport(shown on the window), + // The new posts are updated(call onClick() -- setSize(size + 1) in Posts.jsx --) + useEffect(() => { + const options = { + root: null, + threshold: 1.0, + }; + + const observer = new IntersectionObserver( + (entries) => + entries.forEach((entry) => { + if (entry.isIntersecting) { + onScroll(); + } + }), + options + ); + observer.observe($buttonRef.current!); + + return () => { + observer.unobserve($buttonRef.current!); + }; + }, []); + + return ( + + + + + + ); +} + +export default LoadAutoScroll; diff --git a/src/frontend/next/src/components/Posts/Posts.tsx b/src/frontend/next/src/components/Posts/Posts.tsx new file mode 100644 index 0000000000..0c2fb97cd0 --- /dev/null +++ b/src/frontend/next/src/components/Posts/Posts.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect } from 'react'; + +import { useSWRInfinite } from 'swr'; +import { Container, createStyles, Grid } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { usePrevious } from 'react-use'; +import SentimentDissatisfiedRoundedIcon from '@material-ui/icons/SentimentDissatisfiedRounded'; +import Timeline from './Timeline'; +import useSiteMetaData from '../../hooks/use-site-metadata'; +import useFaviconBadge from '../../hooks/use-favicon-badge'; + +interface PostData { + [key: string]: any; +} + +const useStyles = makeStyles(() => + createStyles({ + root: { + padding: 0, + maxWidth: '785px', + }, + error: { + color: '#B5B5B5', + fontFamily: 'Roboto', + fontSize: '5rem', + paddingBottom: '30px', + }, + errorIcon: { + color: '#B5B5B5', + fontSize: '10rem', + paddingBottom: 0, + }, + }) +); + +// Refresh post data every 5 mins +const REFRESH_INTERVAL = 5 * 60 * 1000; + +const Posts = () => { + const classes = useStyles(); + const { telescopeUrl } = useSiteMetaData(); + const [currentPostId, setCurrentPostId] = useState(); + + const { data, size, setSize, error } = useSWRInfinite( + (index: number) => `${telescopeUrl}/posts?page=${index + 1}`, + async (url: string) => { + const res = await fetch(url); + return res.json(); + }, + { + refreshInterval: REFRESH_INTERVAL, + refreshWhenHidden: true, + onSuccess(newData: PostData[]) { + const safelyExtractId = () => { + try { + return newData[0][0].id; + } catch (err) { + return null; + } + }; + + // Get the id of the top post in the current and prev data sets + const id = safelyExtractId(); + setCurrentPostId(id); + }, + } + ); + + const prevPostId = usePrevious(currentPostId); + + // Manage the favicon badge, depending on whether we have new data or not + const setBadgeHint = useFaviconBadge(); + useEffect(() => { + if (currentPostId && currentPostId !== prevPostId) { + setBadgeHint(); + } + }, [currentPostId, prevPostId, setBadgeHint]); + + // TODO: need proper error handling + if (error) { + console.error('Error loading post data', error); + return ( + + + + + + Blog Timeline Failed to Load! + + + ); + } + + return ( + + setSize(size + 1)} /> + + ); +}; + +export default Posts; diff --git a/src/frontend/next/src/components/Posts/Timeline.tsx b/src/frontend/next/src/components/Posts/Timeline.tsx new file mode 100644 index 0000000000..09565d0898 --- /dev/null +++ b/src/frontend/next/src/components/Posts/Timeline.tsx @@ -0,0 +1,57 @@ +import { Container, createStyles, Grid } from '@material-ui/core'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import PostComponent from '../Post/PostComponent'; +import { Post } from '../../interfaces/index'; +import Spinner from '../Spinner'; +import LoadAutoScroll from './LoadAutoScroll'; +import useSiteMetaData from '../../hooks/use-site-metadata'; + +type Props = { + pages: Array | undefined; + nextPage: Function; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: 0, + maxWidth: '785px', + }, + activeCircle: { + borderRadius: '4rem', + transition: 'all linear 250ms', + color: theme.palette.primary, + }, + }) +); + +const Timeline = ({ pages, nextPage }: Props) => { + const classes = useStyles(); + const { telescopeUrl } = useSiteMetaData(); + + if (!(pages && pages.length)) { + return ( + + + + ); + } + + // Iterate over all the pages (an array of arrays) and then convert all post + // elements to + const postsTimeline = pages.map((page: Post): any => + page.map(({ id, url }: Post) => ) + ); + + // Add a "Load More" button at the end of the timeline. Give it a unique + // key each time, based on page (i.e., size), so we remove the previous one + if (nextPage) { + postsTimeline.push( + nextPage()} key={`load-more-button-${pages.length}`} /> + ); + } + + return {postsTimeline}; +}; + +export default Timeline; diff --git a/src/frontend/next/src/interfaces/index.ts b/src/frontend/next/src/interfaces/index.ts index deda9d0a4b..692cbcb774 100644 --- a/src/frontend/next/src/interfaces/index.ts +++ b/src/frontend/next/src/interfaces/index.ts @@ -18,3 +18,13 @@ export type FeedHash = { url: string; }; }; + +export type Post = { + feed: Feed; + id: string; + post: string; + title: string; + updated: string; + url: string; + html: string; +};