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

View file

@ -6,13 +6,24 @@ import styles from './header.module.css';
import { useRouter } from "next/router";
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 router = useRouter();
const [selectedTab, setSelectedTab] = useState<string>();
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
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(() => {
setBodyHidden(expanded)
@ -23,89 +34,120 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
setExpanded(false)
}
}, [isMobile])
const pages = useMemo(() => [
{
name: "Home",
href: "/",
icon: <HomeIcon />,
condition: true,
value: "home"
},
{
name: "New",
href: "/new",
icon: <NewIcon />,
condition: isSignedIn,
value: "new"
},
{
name: "Yours",
href: "/mine",
icon: <YourIcon />,
condition: isSignedIn,
value: "mine"
},
// {
// name: "Settings",
// href: "/settings",
// icon: <SettingsIcon />,
// condition: isSignedIn
// },
{
name: "Sign out",
action: () => {
if (typeof window !== 'undefined') {
localStorage.clear();
router.push("/signin");
}
},
href: "#signout",
icon: <SignoutIcon />,
condition: isSignedIn,
value: "signout"
},
{
name: "Sign in",
href: "/signin",
icon: <SignInIcon />,
condition: !isSignedIn,
value: "signin"
},
{
name: "Sign up",
href: "/signup",
icon: <SignUpIcon />,
condition: !isSignedIn,
value: "signup"
},
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
condition: true,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
action: function () {
if (typeof window !== 'undefined') {
changeTheme();
setSelectedTab(undefined);
}
},
icon: theme === 'light' ? <Moon /> : <Sun />,
condition: true,
value: "theme",
}
], [changeTheme, isMobile, isSignedIn, router, theme])
useEffect(() => {
setSelectedTab(pages.find((page) => {
if (page.href && page.href === router.asPath) {
return true
const pageList: Tab[] = [
{
name: "Home",
href: "/",
icon: <HomeIcon />,
condition: true,
value: "home"
},
{
name: "New",
href: "/new",
icon: <NewIcon />,
condition: isSignedIn,
value: "new"
},
{
name: "Yours",
href: "/mine",
icon: <YourIcon />,
condition: isSignedIn,
value: "mine"
},
// {
// name: "Settings",
// href: "/settings",
// icon: <SettingsIcon />,
// condition: isSignedIn
// },
{
name: "Sign out",
onClick: () => {
if (typeof window !== 'undefined') {
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");
}
},
href: "#signout",
icon: <SignoutIcon />,
condition: isSignedIn,
value: "signout"
},
{
name: "Sign in",
href: "/signin",
icon: <SignInIcon />,
condition: !isSignedIn,
value: "signin"
},
{
name: "Sign up",
href: "/signup",
icon: <SignUpIcon />,
condition: !isSignedIn,
value: "signup"
},
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
condition: true,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== 'undefined') {
changeTheme();
setSelectedTab(undefined);
}
},
icon: theme === 'light' ? <Moon /> : <Sun />,
condition: true,
value: "theme",
}
})?.href)
}, [pages, router, router.pathname])
]
if (isLoading) {
return setPages([])
}
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}`)
}
}
return (
<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"
hideDivider
hideBorder
onChange={(tab) => {
const match = pages.find(page => page.value === tab)
if (match?.action) {
match.action()
} else if (match?.href) {
router.push(`${match.href}`)
}
}}>
onChange={onTabChange}>
{!isLoading && pages.map((tab) => {
if (tab.condition)
return <Tabs.Item
font="14px"
label={<>{tab.icon} {tab.name}</>}
value={tab.value}
key={`${tab.value}`}
/>
else return null
return <Tabs.Item
font="14px"
label={<>{tab.icon} {tab.name}</>}
value={tab.value}
key={`${tab.value}`}
/>
})}
</Tabs>
</div>
@ -150,21 +183,13 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
{isMobile && expanded && (<div className={styles.mobile}>
<ButtonGroup vertical>
{pages.map((tab, index) => {
if (tab.condition)
return <Button
key={`${tab.name}-${index}`}
onClick={() => {
const nameMatch = pages.find(page => page.name === tab.name)
if (nameMatch?.action) {
nameMatch.action()
} else {
router.push(`${tab.href}`)
}
}}
icon={tab.icon}
>
{tab.name}
</Button>
return <Button
key={`${tab.name}-${index}`}
onClick={() => onTabChange(tab.value)}
icon={tab.icon}
>
{tab.name}
</Button>
})}
</ButtonGroup>
</div>)}

View file

@ -1,4 +1,5 @@
import { Text } from "@geist-ui/core"
import NextLink from "next/link"
import Link from '../Link'
import styles from './post-list.module.css'
@ -22,7 +23,7 @@ const PostList = ({ posts, error }: Props) => {
<ListItemSkeleton />
</li>
</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>
<ul>

View file

@ -1,5 +1,7 @@
import { Card, Spacer, Grid, Divider, Link, Text, Input } from "@geist-ui/core"
import post from "../post"
import { Card, Spacer, Grid, Divider, Link, Text, Input, Tooltip } from "@geist-ui/core"
import NextLink from "next/link"
import { useEffect, useMemo, useState } from "react"
import timeAgo from "../../lib/time-ago"
import ShiftBy from "../shift-by"
import VisibilityBadge from "../visibility-badge"
@ -14,17 +16,30 @@ const FilenameInput = ({ title }: { title: string }) => <Input
/>
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}>
<Card style={{ overflowY: 'scroll' }}>
<Spacer height={1 / 2} />
<Grid.Container justify={'space-between'}>
<Grid xs={8}>
<Text h3 paddingLeft={1 / 2}>
<Link color href={`/post/${post.id}`}>{post.title}
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
</Link>
<NextLink passHref={true} href={`/post/${post.id}`}>
<Link color>{post.title}
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
</Link>
</NextLink>
</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.Container>

View file

@ -1,10 +1,12 @@
import { useRouter } from "next/router";
import { useEffect } from "react"
import { useCallback, useEffect } from "react"
import useSharedState from "./use-shared-state";
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
const [isLoading, setLoading] = useSharedState('isLoading', true)
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
const router = useRouter();
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
router.push('/signin')
@ -14,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
async function checkToken() {
const token = localStorage.getItem('drift-token')
if (token) {
const response = await fetch('/api/users/verify-token', {
const response = await fetch('/api/auth/verify-token', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
@ -31,12 +33,12 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
const interval = setInterval(() => {
checkToken()
}, 60 * 1000);
}, 10000);
return () => clearInterval(interval);
}, [setLoading, setSignedIn])
return { isSignedIn, isLoading }
return { isSignedIn, isLoading, signout }
}
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 errorhandler from 'strong-error-handler';
import * as cors from 'cors';
import { users } from './routes/users'
import { posts } from './routes/posts'
import { posts, users, auth } from './routes';
export const app = express();
@ -16,8 +14,9 @@ const corsOptions = {
};
app.use(cors(corsOptions));
app.use("/users", users)
app.use("/auth", auth)
app.use("/posts", posts)
app.use("/users", users)
app.use(errorhandler({
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 { Movie } from '../models/Post'
import { genSalt, hash, compare } from "bcrypt"
import { User } from '../../lib/models/User'
import { File } from '../../lib/models/File'
import { sign } from 'jsonwebtoken'
import config from '../../lib/config'
import jwt, { UserJwtRequest } from '../../lib/middleware/jwt'
import { Post } from '../../lib/models/Post'
@ -47,72 +44,3 @@ users.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
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);
}
})