diff --git a/client/components/auth/index.tsx b/client/components/auth/index.tsx index e35b702..9040c56 100644 --- a/client/components/auth/index.tsx +++ b/client/components/auth/index.tsx @@ -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) { diff --git a/client/components/header/index.tsx b/client/components/header/index.tsx index 55100b0..e777025 100644 --- a/client/components/header/index.tsx +++ b/client/components/header/index.tsx @@ -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(); const [expanded, setExpanded] = useState(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([]) useEffect(() => { setBodyHidden(expanded) @@ -23,89 +34,120 @@ const Header = ({ changeTheme, theme }: DriftProps) => { setExpanded(false) } }, [isMobile]) - const pages = useMemo(() => [ - { - name: "Home", - href: "/", - icon: , - condition: true, - value: "home" - }, - { - name: "New", - href: "/new", - icon: , - condition: isSignedIn, - value: "new" - }, - { - name: "Yours", - href: "/mine", - icon: , - condition: isSignedIn, - value: "mine" - }, - // { - // name: "Settings", - // href: "/settings", - // icon: , - // condition: isSignedIn - // }, - { - name: "Sign out", - action: () => { - if (typeof window !== 'undefined') { - localStorage.clear(); - router.push("/signin"); - } - }, - href: "#signout", - icon: , - condition: isSignedIn, - value: "signout" - }, - { - name: "Sign in", - href: "/signin", - icon: , - condition: !isSignedIn, - value: "signin" - }, - { - name: "Sign up", - href: "/signup", - icon: , - condition: !isSignedIn, - value: "signup" - }, - { - name: isMobile ? "GitHub" : "", - href: "https://github.com/maxleiter/drift", - icon: , - condition: true, - value: "github" - }, - { - name: isMobile ? "Change theme" : "", - action: function () { - if (typeof window !== 'undefined') { - changeTheme(); - setSelectedTab(undefined); - } - }, - icon: theme === 'light' ? : , - 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: , + condition: true, + value: "home" + }, + { + name: "New", + href: "/new", + icon: , + condition: isSignedIn, + value: "new" + }, + { + name: "Yours", + href: "/mine", + icon: , + condition: isSignedIn, + value: "mine" + }, + // { + // name: "Settings", + // href: "/settings", + // icon: , + // 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: , + condition: isSignedIn, + value: "signout" + }, + { + name: "Sign in", + href: "/signin", + icon: , + condition: !isSignedIn, + value: "signin" + }, + { + name: "Sign up", + href: "/signup", + icon: , + condition: !isSignedIn, + value: "signup" + }, + { + name: isMobile ? "GitHub" : "", + href: "https://github.com/maxleiter/drift", + icon: , + condition: true, + value: "github" + }, + { + name: isMobile ? "Change theme" : "", + onClick: function () { + if (typeof window !== 'undefined') { + changeTheme(); + setSelectedTab(undefined); + } + }, + icon: theme === 'light' ? : , + 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 ( @@ -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 {tab.icon} {tab.name}} - value={tab.value} - key={`${tab.value}`} - /> - else return null + return {tab.icon} {tab.name}} + value={tab.value} + key={`${tab.value}`} + /> })} @@ -150,21 +183,13 @@ const Header = ({ changeTheme, theme }: DriftProps) => { {isMobile && expanded && (
{pages.map((tab, index) => { - if (tab.condition) - return + return })}
)} diff --git a/client/components/post-list/index.tsx b/client/components/post-list/index.tsx index eee4e53..915d772 100644 --- a/client/components/post-list/index.tsx +++ b/client/components/post-list/index.tsx @@ -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) => { } - {posts?.length === 0 && You have no posts. Create one here.} + {posts?.length === 0 && You have no posts. Create one here.} { posts?.length > 0 &&
    diff --git a/client/components/post-list/list-item.tsx b/client/components/post-list/list-item.tsx index 65c5c61..121eda9 100644 --- a/client/components/post-list/list-item.tsx +++ b/client/components/post-list/list-item.tsx @@ -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 }) => 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 (
  • - {post.title} - - + + {post.title} + + + - {new Date(post.createdAt).toLocaleDateString()} + {time} {post.files.length === 1 ? "1 file" : `${post.files.length} files`} diff --git a/client/lib/hooks/use-signed-in.ts b/client/lib/hooks/use-signed-in.ts index de665c8..cfede71 100644 --- a/client/lib/hooks/use-signed-in.ts +++ b/client/lib/hooks/use-signed-in.ts @@ -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 diff --git a/client/lib/time-ago.ts b/client/lib/time-ago.ts new file mode 100644 index 0000000..75e6624 --- /dev/null +++ b/client/lib/time-ago.ts @@ -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 diff --git a/server/src/app.ts b/server/src/app.ts index 1d9a8d8..3934c7f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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', diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..6479436 --- /dev/null +++ b/server/src/routes/auth.ts @@ -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); + } +}) diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts new file mode 100644 index 0000000..506eedb --- /dev/null +++ b/server/src/routes/index.ts @@ -0,0 +1,3 @@ +export { auth } from './auth'; +export { posts } from './posts'; +export { users } from './users'; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 969b199..3ffefd0 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -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); - } -})