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:
parent
55a2b19f9a
commit
d669f1057e
10 changed files with 293 additions and 201 deletions
|
@ -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) {
|
||||
|
|
|
@ -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,7 +34,9 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
|
|||
setExpanded(false)
|
||||
}
|
||||
}, [isMobile])
|
||||
const pages = useMemo(() => [
|
||||
|
||||
useEffect(() => {
|
||||
const pageList: Tab[] = [
|
||||
{
|
||||
name: "Home",
|
||||
href: "/",
|
||||
|
@ -53,9 +66,22 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
|
|||
// },
|
||||
{
|
||||
name: "Sign out",
|
||||
action: () => {
|
||||
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");
|
||||
}
|
||||
},
|
||||
|
@ -87,7 +113,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
|
|||
},
|
||||
{
|
||||
name: isMobile ? "Change theme" : "",
|
||||
action: function () {
|
||||
onClick: function () {
|
||||
if (typeof window !== 'undefined') {
|
||||
changeTheme();
|
||||
setSelectedTab(undefined);
|
||||
|
@ -97,15 +123,31 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
|
|||
condition: true,
|
||||
value: "theme",
|
||||
}
|
||||
], [changeTheme, isMobile, isSignedIn, router, theme])
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTab(pages.find((page) => {
|
||||
if (page.href && page.href === router.asPath) {
|
||||
return true
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
})?.href)
|
||||
}, [pages, router, router.pathname])
|
||||
|
||||
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
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
@ -150,17 +183,9 @@ 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}`)
|
||||
}
|
||||
}}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
icon={tab.icon}
|
||||
>
|
||||
{tab.name}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
<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>
|
||||
|
||||
|
|
|
@ -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
41
client/lib/time-ago.ts
Normal 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
|
|
@ -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
78
server/src/routes/auth.ts
Normal 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);
|
||||
}
|
||||
})
|
3
server/src/routes/index.ts
Normal file
3
server/src/routes/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { auth } from './auth';
|
||||
export { posts } from './posts';
|
||||
export { users } from './users';
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue