client: add time-ago utility and auth cleanup (#19)

* client: add time-ago utility and use it in post list
* client: improve header, timeago styling
* client: Use next/link with geist-ui Link
This commit is contained in:
Max Leiter 2022-03-09 23:46:59 -08:00 committed by GitHub
parent 55a2b19f9a
commit d669f1057e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 293 additions and 201 deletions

View file

@ -35,7 +35,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
e.preventDefault() e.preventDefault()
if (signingIn) { if (signingIn) {
try { try {
const resp = await fetch('/api/users/login', reqOpts) const resp = await fetch('/api/auth/signin', reqOpts)
const json = await resp.json() const json = await resp.json()
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {
@ -43,7 +43,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
} }
} else { } else {
try { try {
const resp = await fetch('/api/users/signup', reqOpts) const resp = await fetch('/api/auth/signup', reqOpts)
const json = await resp.json() const json = await resp.json()
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {

View file

@ -6,13 +6,24 @@ import styles from './header.module.css';
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSignedIn from "../../lib/hooks/use-signed-in"; import useSignedIn from "../../lib/hooks/use-signed-in";
type Tab = {
name: string
icon: JSX.Element
condition?: boolean
value: string
onClick?: () => void
href?: string
}
const Header = ({ changeTheme, theme }: DriftProps) => { const Header = ({ changeTheme, theme }: DriftProps) => {
const router = useRouter(); const router = useRouter();
const [selectedTab, setSelectedTab] = useState<string>(); const [selectedTab, setSelectedTab] = useState<string>();
const [expanded, setExpanded] = useState<boolean>(false) const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery('xs', { match: 'down' }) const isMobile = useMediaQuery('xs', { match: 'down' })
const { isLoading, isSignedIn } = useSignedIn({ redirectIfNotAuthed: false }) const { isLoading, isSignedIn, signout } = useSignedIn({ redirectIfNotAuthed: false })
const [pages, setPages] = useState<Tab[]>([])
useEffect(() => { useEffect(() => {
setBodyHidden(expanded) setBodyHidden(expanded)
@ -23,7 +34,9 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
setExpanded(false) setExpanded(false)
} }
}, [isMobile]) }, [isMobile])
const pages = useMemo(() => [
useEffect(() => {
const pageList: Tab[] = [
{ {
name: "Home", name: "Home",
href: "/", href: "/",
@ -53,9 +66,22 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
// }, // },
{ {
name: "Sign out", name: "Sign out",
action: () => { onClick: () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.clear(); localStorage.clear();
// // send token to API blacklist
// fetch('/api/auth/signout', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify({
// token: localStorage.getItem("drift-token")
// })
// })
signout();
router.push("/signin"); router.push("/signin");
} }
}, },
@ -87,7 +113,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
}, },
{ {
name: isMobile ? "Change theme" : "", name: isMobile ? "Change theme" : "",
action: function () { onClick: function () {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
changeTheme(); changeTheme();
setSelectedTab(undefined); setSelectedTab(undefined);
@ -97,15 +123,31 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
condition: true, condition: true,
value: "theme", value: "theme",
} }
], [changeTheme, isMobile, isSignedIn, router, theme]) ]
useEffect(() => { if (isLoading) {
setSelectedTab(pages.find((page) => { return setPages([])
if (page.href && page.href === router.asPath) { }
return true
setPages(pageList.filter(page => page.condition))
}, [changeTheme, isLoading, isMobile, isSignedIn, router, signout, theme])
// useEffect(() => {
// setSelectedTab(pages.find((page) => {
// if (page.href && page.href === router.asPath) {
// return true
// }
// })?.href)
// }, [pages, router, router.pathname])
const onTabChange = (tab: string) => {
const match = pages.find(page => page.value === tab)
if (match?.onClick) {
match.onClick()
} else if (match?.href) {
router.push(`${match.href}`)
}
} }
})?.href)
}, [pages, router, router.pathname])
return ( return (
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}> <Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
@ -117,23 +159,14 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
align="center" align="center"
hideDivider hideDivider
hideBorder hideBorder
onChange={(tab) => { onChange={onTabChange}>
const match = pages.find(page => page.value === tab)
if (match?.action) {
match.action()
} else if (match?.href) {
router.push(`${match.href}`)
}
}}>
{!isLoading && pages.map((tab) => { {!isLoading && pages.map((tab) => {
if (tab.condition)
return <Tabs.Item return <Tabs.Item
font="14px" font="14px"
label={<>{tab.icon} {tab.name}</>} label={<>{tab.icon} {tab.name}</>}
value={tab.value} value={tab.value}
key={`${tab.value}`} key={`${tab.value}`}
/> />
else return null
})} })}
</Tabs> </Tabs>
</div> </div>
@ -150,17 +183,9 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
{isMobile && expanded && (<div className={styles.mobile}> {isMobile && expanded && (<div className={styles.mobile}>
<ButtonGroup vertical> <ButtonGroup vertical>
{pages.map((tab, index) => { {pages.map((tab, index) => {
if (tab.condition)
return <Button return <Button
key={`${tab.name}-${index}`} key={`${tab.name}-${index}`}
onClick={() => { onClick={() => onTabChange(tab.value)}
const nameMatch = pages.find(page => page.name === tab.name)
if (nameMatch?.action) {
nameMatch.action()
} else {
router.push(`${tab.href}`)
}
}}
icon={tab.icon} icon={tab.icon}
> >
{tab.name} {tab.name}

View file

@ -1,4 +1,5 @@
import { Text } from "@geist-ui/core" import { Text } from "@geist-ui/core"
import NextLink from "next/link"
import Link from '../Link' import Link from '../Link'
import styles from './post-list.module.css' import styles from './post-list.module.css'
@ -22,7 +23,7 @@ const PostList = ({ posts, error }: Props) => {
<ListItemSkeleton /> <ListItemSkeleton />
</li> </li>
</ul>} </ul>}
{posts?.length === 0 && <Text>You have no posts. Create one <Link href="/new">here</Link>.</Text>} {posts?.length === 0 && <Text>You have no posts. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
{ {
posts?.length > 0 && <div> posts?.length > 0 && <div>
<ul> <ul>

View file

@ -1,5 +1,7 @@
import { Card, Spacer, Grid, Divider, Link, Text, Input } from "@geist-ui/core" import { Card, Spacer, Grid, Divider, Link, Text, Input, Tooltip } from "@geist-ui/core"
import post from "../post" import NextLink from "next/link"
import { useEffect, useMemo, useState } from "react"
import timeAgo from "../../lib/time-ago"
import ShiftBy from "../shift-by" import ShiftBy from "../shift-by"
import VisibilityBadge from "../visibility-badge" import VisibilityBadge from "../visibility-badge"
@ -14,17 +16,30 @@ const FilenameInput = ({ title }: { title: string }) => <Input
/> />
const ListItem = ({ post }: { post: any }) => { const ListItem = ({ post }: { post: any }) => {
const createdDate = useMemo(() => new Date(post.createdAt), [post.createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate))
}, 10000)
return () => clearInterval(interval)
}, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (<li key={post.id}> return (<li key={post.id}>
<Card style={{ overflowY: 'scroll' }}> <Card style={{ overflowY: 'scroll' }}>
<Spacer height={1 / 2} /> <Spacer height={1 / 2} />
<Grid.Container justify={'space-between'}> <Grid.Container justify={'space-between'}>
<Grid xs={8}> <Grid xs={8}>
<Text h3 paddingLeft={1 / 2}> <Text h3 paddingLeft={1 / 2}>
<Link color href={`/post/${post.id}`}>{post.title} <NextLink passHref={true} href={`/post/${post.id}`}>
<Link color>{post.title}
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy> <ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
</Link> </Link>
</NextLink>
</Text></Grid> </Text></Grid>
<Grid xs={7}><Text type="secondary" h5>{new Date(post.createdAt).toLocaleDateString()} </Text></Grid> <Grid xs={7}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></Text></Grid>
<Grid xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid> <Grid xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid>
</Grid.Container> </Grid.Container>

View file

@ -1,10 +1,12 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react" import { useCallback, useEffect } from "react"
import useSharedState from "./use-shared-state"; import useSharedState from "./use-shared-state";
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => { const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false) const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
const [isLoading, setLoading] = useSharedState('isLoading', true) const [isLoading, setLoading] = useSharedState('isLoading', true)
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
const router = useRouter(); const router = useRouter();
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) { if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
router.push('/signin') router.push('/signin')
@ -14,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
async function checkToken() { async function checkToken() {
const token = localStorage.getItem('drift-token') const token = localStorage.getItem('drift-token')
if (token) { if (token) {
const response = await fetch('/api/users/verify-token', { const response = await fetch('/api/auth/verify-token', {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
@ -31,12 +33,12 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
const interval = setInterval(() => { const interval = setInterval(() => {
checkToken() checkToken()
}, 60 * 1000); }, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [setLoading, setSignedIn]) }, [setLoading, setSignedIn])
return { isSignedIn, isLoading } return { isSignedIn, isLoading, signout }
} }
export default useSignedIn export default useSignedIn

41
client/lib/time-ago.ts Normal file
View file

@ -0,0 +1,41 @@
// Modified from https://gist.github.com/IbeVanmeenen/4e3e58820c9168806e57530563612886
// which is based on https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
const epochs = [
['year', 31536000],
['month', 2592000],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1]
] as const;
// Get duration
const getDuration = (timeAgoInSeconds: number) => {
for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds);
if (interval >= 1) {
return {
interval: interval,
epoch: name
};
}
}
return {
interval: 0,
epoch: 'second'
}
};
// Calculate
const timeAgo = (date: Date) => {
const timeAgoInSeconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000);
const { interval, epoch } = getDuration(timeAgoInSeconds);
const suffix = interval === 1 ? '' : 's';
return `${interval} ${epoch}${suffix} ago`;
};
export default timeAgo

View file

@ -2,9 +2,7 @@ import * as express from 'express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as errorhandler from 'strong-error-handler'; import * as errorhandler from 'strong-error-handler';
import * as cors from 'cors'; import * as cors from 'cors';
import { posts, users, auth } from './routes';
import { users } from './routes/users'
import { posts } from './routes/posts'
export const app = express(); export const app = express();
@ -16,8 +14,9 @@ const corsOptions = {
}; };
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use("/users", users) app.use("/auth", auth)
app.use("/posts", posts) app.use("/posts", posts)
app.use("/users", users)
app.use(errorhandler({ app.use(errorhandler({
debug: process.env.ENV !== 'production', debug: process.env.ENV !== 'production',

78
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,78 @@
import { Router } from 'express'
// import { Movie } from '../models/Post'
import { genSalt, hash, compare } from "bcrypt"
import { User } from '../../lib/models/User'
import { sign } from 'jsonwebtoken'
import config from '../../lib/config'
import jwt from '../../lib/middleware/jwt'
export const auth = Router()
auth.post('/signup', async (req, res, next) => {
try {
if (!req.body.username || !req.body.password) {
throw new Error("Please provide a username and password")
}
const username = req.body.username.toLowerCase();
const existingUser = await User.findOne({ where: { username: username } })
if (existingUser) {
throw new Error("Username already exists")
}
const salt = await genSalt(10)
const user = {
username: username as string,
password: await hash(req.body.password, salt)
}
const created_user = await User.create(user);
const token = generateAccessToken(created_user.id);
res.status(201).json({ token: token, userId: created_user.id })
} catch (e) {
next(e);
}
});
auth.post('/signin', async (req, res, next) => {
try {
if (!req.body.username || !req.body.password) {
throw new Error("Missing username or password")
}
const username = req.body.username.toLowerCase();
const user = await User.findOne({ where: { username: username } });
if (!user) {
throw new Error("User does not exist");
}
const password_valid = await compare(req.body.password, user.password);
if (password_valid) {
const token = generateAccessToken(user.id);
res.status(200).json({ token: token, userId: user.id });
} else {
throw new Error("Password Incorrect");
}
} catch (e) {
next(e);
}
});
function generateAccessToken(id: string) {
return sign({ id: id }, config.jwt_secret, { expiresIn: '2d' });
}
auth.get("/verify-token", jwt, async (req, res, next) => {
try {
res.status(200).json({
message: "You are authenticated"
})
}
catch (e) {
next(e);
}
})

View file

@ -0,0 +1,3 @@
export { auth } from './auth';
export { posts } from './posts';
export { users } from './users';

View file

@ -1,10 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
// import { Movie } from '../models/Post' // import { Movie } from '../models/Post'
import { genSalt, hash, compare } from "bcrypt"
import { User } from '../../lib/models/User' import { User } from '../../lib/models/User'
import { File } from '../../lib/models/File' import { File } from '../../lib/models/File'
import { sign } from 'jsonwebtoken'
import config from '../../lib/config'
import jwt, { UserJwtRequest } from '../../lib/middleware/jwt' import jwt, { UserJwtRequest } from '../../lib/middleware/jwt'
import { Post } from '../../lib/models/Post' import { Post } from '../../lib/models/Post'
@ -47,72 +44,3 @@ users.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
next(error) next(error)
} }
}) })
users.post('/signup', async (req, res, next) => {
try {
if (!req.body.username || !req.body.password) {
throw new Error("Please provide a username and password")
}
const username = req.body.username.toLowerCase();
const existingUser = await User.findOne({ where: { username: username } })
if (existingUser) {
throw new Error("Username already exists")
}
const salt = await genSalt(10)
const user = {
username: username as string,
password: await hash(req.body.password, salt)
}
const created_user = await User.create(user);
const token = generateAccessToken(created_user.id);
res.status(201).json({ token: token, userId: created_user.id })
} catch (e) {
next(e);
}
});
users.post('/login', async (req, res, next) => {
try {
if (!req.body.username || !req.body.password) {
throw new Error("Missing username or password")
}
const username = req.body.username.toLowerCase();
const user = await User.findOne({ where: { username: username } });
if (!user) {
throw new Error("User does not exist");
}
const password_valid = await compare(req.body.password, user.password);
if (password_valid) {
const token = generateAccessToken(user.id);
res.status(200).json({ token: token, userId: user.id });
} else {
throw new Error("Password Incorrect");
}
} catch (e) {
next(e);
}
});
function generateAccessToken(id: string) {
return sign({ id: id }, config.jwt_secret, { expiresIn: '7d' });
}
users.get("/verify-token", jwt, async (req, res, next) => {
try {
res.status(200).json({
message: "You are authenticated"
})
}
catch (e) {
next(e);
}
})