client: lint tsx files with prettier

This commit is contained in:
Max Leiter 2022-04-09 17:48:19 -07:00
parent c44ab907bb
commit 36e255ad2b
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG key ID: A3512F2F2F17EBDA
52 changed files with 3306 additions and 2705 deletions

View file

@ -1,12 +1,17 @@
import type { LinkProps } from "@geist-ui/core" import type { LinkProps } from "@geist-ui/core"
import { Link as GeistLink } from "@geist-ui/core" import { Link as GeistLink } from "@geist-ui/core"
import { useRouter } from "next/router"; import { useRouter } from "next/router"
const Link = (props: LinkProps) => { const Link = (props: LinkProps) => {
const { basePath } = useRouter(); const { basePath } = useRouter()
const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href; const propHrefWithoutLeadingSlash =
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href; props.href && props.href.startsWith("/")
return <GeistLink {...props} href={href} /> ? props.href.substring(1)
: props.href
const href = basePath
? `${basePath}/${propHrefWithoutLeadingSlash}`
: props.href
return <GeistLink {...props} href={href} />
} }
export default Link export default Link

View file

@ -1,100 +1,132 @@
import { Text, Fieldset, Spacer, Link } from '@geist-ui/core' import { Text, Fieldset, Spacer, Link } from "@geist-ui/core"
import { Post, User } from '@lib/types' import { Post, User } from "@lib/types"
import Cookies from 'js-cookie' import Cookies from "js-cookie"
import { useEffect, useState } from 'react' import { useEffect, useState } from "react"
import useSWR from 'swr' import useSWR from "swr"
import styles from './admin.module.css' import styles from "./admin.module.css"
import PostModal from './post-modal-link' import PostModal from "./post-modal-link"
export const adminFetcher = (url: string) => fetch(url, { export const adminFetcher = (url: string) =>
method: "GET", fetch(url, {
headers: { method: "GET",
"Content-Type": "application/json", headers: {
"Authorization": `Bearer ${Cookies.get('drift-token')}`, "Content-Type": "application/json",
} Authorization: `Bearer ${Cookies.get("drift-token")}`
}).then(res => res.json()) }
}).then((res) => res.json())
const Admin = () => { const Admin = () => {
const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher) const { data: posts, error: postsError } = useSWR<Post[]>(
const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher) "/server-api/admin/posts",
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({}) adminFetcher
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100 )
useEffect(() => { const { data: users, error: usersError } = useSWR<User[]>(
if (posts) { "/server-api/admin/users",
// sum the sizes of each file per post adminFetcher
const sizes = posts.reduce((acc, post) => { )
const size = post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0 const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
return { ...acc, [post.id]: byteToMB(size) } const byteToMB = (bytes: number) =>
}, {}) Math.round((bytes / 1024 / 1024) * 100) / 100
setPostSizes(sizes) useEffect(() => {
} if (posts) {
}, [posts]) // sum the sizes of each file per post
const sizes = posts.reduce((acc, post) => {
const size =
post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
return { ...acc, [post.id]: byteToMB(size) }
}, {})
setPostSizes(sizes)
}
}, [posts])
return ( return (
<div className={styles.adminWrapper}> <div className={styles.adminWrapper}>
<Text h2>Administration</Text> <Text h2>Administration</Text>
<Fieldset> <Fieldset>
<Fieldset.Title>Users</Fieldset.Title> <Fieldset.Title>Users</Fieldset.Title>
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>} {users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>} {!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>} {usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{users && <table> {users && (
<thead> <table>
<tr> <thead>
<th>Username</th> <tr>
<th>Posts</th> <th>Username</th>
<th>Created</th> <th>Posts</th>
<th>Role</th> <th>Created</th>
</tr> <th>Role</th>
</thead> </tr>
<tbody> </thead>
{users?.map(user => ( <tbody>
<tr key={user.id}> {users?.map((user) => (
<td>{user.username}</td> <tr key={user.id}>
<td>{user.posts?.length}</td> <td>{user.username}</td>
<td>{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleTimeString()}</td> <td>{user.posts?.length}</td>
<td>{user.role}</td> <td>
</tr> {new Date(user.createdAt).toLocaleDateString()}{" "}
))} {new Date(user.createdAt).toLocaleTimeString()}
</tbody> </td>
</table>} <td>{user.role}</td>
</tr>
</Fieldset> ))}
<Spacer height={1} /> </tbody>
<Fieldset> </table>
<Fieldset.Title>Posts</Fieldset.Title> )}
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>} </Fieldset>
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>} <Spacer height={1} />
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>} <Fieldset>
{posts && <table> <Fieldset.Title>Posts</Fieldset.Title>
<thead> {posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
<tr> {!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
<th>Title</th> {postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
<th>Visibility</th> {posts && (
<th>Created</th> <table>
<th>Author</th> <thead>
<th>Size</th> <tr>
</tr> <th>Title</th>
</thead> <th>Visibility</th>
<tbody> <th>Created</th>
{posts?.map((post) => ( <th>Author</th>
<tr key={post.id}> <th>Size</th>
<td><PostModal id={post.id} /></td> </tr>
<td>{post.visibility}</td> </thead>
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td> <tbody>
<td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td> {posts?.map((post) => (
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td> <tr key={post.id}>
</tr> <td>
))} <PostModal id={post.id} />
</tbody> </td>
</table>} <td>{post.visibility}</td>
{Object.keys(postSizes).length && <div style={{ float: 'right' }}> <td>
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text> {new Date(post.createdAt).toLocaleDateString()}{" "}
</div>} {new Date(post.createdAt).toLocaleTimeString()}
</Fieldset> </td>
<td>
</div > {post.users?.length ? (
) post.users[0].username
) : (
<i>Deleted</i>
)}
</td>
<td>
{postSizes[post.id] ? `${postSizes[post.id]} MB` : ""}
</td>
</tr>
))}
</tbody>
</table>
)}
{Object.keys(postSizes).length && (
<div style={{ float: "right" }}>
<Text>
Total size:{" "}
{Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB
</Text>
</div>
)}
</Fieldset>
</div>
)
} }
export default Admin export default Admin

View file

@ -1,51 +1,57 @@
import { Link, Modal, useModal } from "@geist-ui/core"; import { Link, Modal, useModal } from "@geist-ui/core"
import { Post } from "@lib/types"; import { Post } from "@lib/types"
import Cookies from "js-cookie"; import Cookies from "js-cookie"
import useSWR from "swr"; import useSWR from "swr"
import { adminFetcher } from "."; import { adminFetcher } from "."
import styles from './admin.module.css' import styles from "./admin.module.css"
const PostModal = ({ id }: { const PostModal = ({ id }: { id: string }) => {
id: string, const { visible, setVisible, bindings } = useModal()
}) => { const { data: post, error } = useSWR<Post>(
const { visible, setVisible, bindings } = useModal() `/server-api/admin/post/${id}`,
const { data: post, error } = useSWR<Post>(`/server-api/admin/post/${id}`, adminFetcher) adminFetcher
if (error) return <Modal>failed to load</Modal> )
if (!post) return <Modal>loading...</Modal> if (error) return <Modal>failed to load</Modal>
if (!post) return <Modal>loading...</Modal>
const deletePost = async () => { const deletePost = async () => {
await fetch(`/server-api/admin/post/${id}`, { await fetch(`/server-api/admin/post/${id}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`, Authorization: `Bearer ${Cookies.get("drift-token")}`
} }
}) })
setVisible(false) setVisible(false)
} }
return ( return (
<> <>
<Link href="#" color onClick={() => setVisible(true)}>{post.title}</Link> <Link href="#" color onClick={() => setVisible(true)}>
<Modal width={'var(--main-content)'} {...bindings}> {post.title}
<Modal.Title>{post.title}</Modal.Title> </Link>
<Modal.Subtitle>Click an item to expand</Modal.Subtitle> <Modal width={"var(--main-content)"} {...bindings}>
{post.files?.map((file) => ( <Modal.Title>{post.title}</Modal.Title>
<div key={file.id} className={styles.postModal}> <Modal.Subtitle>Click an item to expand</Modal.Subtitle>
<Modal.Content> {post.files?.map((file) => (
<details> <div key={file.id} className={styles.postModal}>
<summary>{file.title}</summary> <Modal.Content>
<div dangerouslySetInnerHTML={{ __html: file.html }}> <details>
</div> <summary>{file.title}</summary>
</details> <div dangerouslySetInnerHTML={{ __html: file.html }}></div>
</Modal.Content> </details>
</div> </Modal.Content>
) </div>
)} ))}
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action> <Modal.Action type="warning" onClick={deletePost}>
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action> Delete
</Modal> </Modal.Action>
</>) <Modal.Action passive onClick={() => setVisible(false)}>
Close
</Modal.Action>
</Modal>
</>
)
} }
export default PostModal export default PostModal

View file

@ -4,60 +4,63 @@ import type { NextComponentType, NextPageContext } from "next"
import { SkeletonTheme } from "react-loading-skeleton" import { SkeletonTheme } from "react-loading-skeleton"
const App = ({ const App = ({
Component, Component,
pageProps, pageProps
}: { }: {
Component: NextComponentType<NextPageContext, any, any> Component: NextComponentType<NextPageContext, any, any>
pageProps: any pageProps: any
}) => { }) => {
const skeletonBaseColor = 'var(--light-gray)' const skeletonBaseColor = "var(--light-gray)"
const skeletonHighlightColor = 'var(--lighter-gray)' const skeletonHighlightColor = "var(--lighter-gray)"
const customTheme = Themes.createFromLight( const customTheme = Themes.createFromLight({
{ type: "custom",
type: "custom", palette: {
palette: { background: "var(--bg)",
background: 'var(--bg)', foreground: "var(--fg)",
foreground: 'var(--fg)', accents_1: "var(--lightest-gray)",
accents_1: 'var(--lightest-gray)', accents_2: "var(--lighter-gray)",
accents_2: 'var(--lighter-gray)', accents_3: "var(--light-gray)",
accents_3: 'var(--light-gray)', accents_4: "var(--gray)",
accents_4: 'var(--gray)', accents_5: "var(--darker-gray)",
accents_5: 'var(--darker-gray)', accents_6: "var(--darker-gray)",
accents_6: 'var(--darker-gray)', accents_7: "var(--darkest-gray)",
accents_7: 'var(--darkest-gray)', accents_8: "var(--darkest-gray)",
accents_8: 'var(--darkest-gray)', border: "var(--light-gray)",
border: 'var(--light-gray)', warning: "var(--warning)"
warning: 'var(--warning)' },
}, expressiveness: {
expressiveness: { dropdownBoxShadow: "0 0 0 1px var(--light-gray)",
dropdownBoxShadow: '0 0 0 1px var(--light-gray)', shadowSmall: "0 0 0 1px var(--light-gray)",
shadowSmall: '0 0 0 1px var(--light-gray)', shadowLarge: "0 0 0 1px var(--light-gray)",
shadowLarge: '0 0 0 1px var(--light-gray)', shadowMedium: "0 0 0 1px var(--light-gray)"
shadowMedium: '0 0 0 1px var(--light-gray)', },
}, layout: {
layout: { gap: "var(--gap)",
gap: 'var(--gap)', gapHalf: "var(--gap-half)",
gapHalf: 'var(--gap-half)', gapQuarter: "var(--gap-quarter)",
gapQuarter: 'var(--gap-quarter)', gapNegative: "var(--gap-negative)",
gapNegative: 'var(--gap-negative)', gapHalfNegative: "var(--gap-half-negative)",
gapHalfNegative: 'var(--gap-half-negative)', gapQuarterNegative: "var(--gap-quarter-negative)",
gapQuarterNegative: 'var(--gap-quarter-negative)', radius: "var(--radius)"
radius: 'var(--radius)', },
}, font: {
font: { mono: "var(--font-mono)",
mono: 'var(--font-mono)', sans: "var(--font-sans)"
sans: 'var(--font-sans)', }
} })
} return (
) <GeistProvider themes={[customTheme]} themeType={"custom"}>
return (<GeistProvider themes={[customTheme]} themeType={"custom"}> <SkeletonTheme
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}> baseColor={skeletonBaseColor}
<CssBaseline /> highlightColor={skeletonHighlightColor}
<Header /> >
<Component {...pageProps} /> <CssBaseline />
</SkeletonTheme> <Header />
</GeistProvider >) <Component {...pageProps} />
</SkeletonTheme>
</GeistProvider>
)
} }
export default App export default App

View file

@ -1,131 +1,154 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from "react"
import { Button, Input, Text, Note } from '@geist-ui/core' import { Button, Input, Text, Note } from "@geist-ui/core"
import styles from './auth.module.css' import styles from "./auth.module.css"
import { useRouter } from 'next/router' import { useRouter } from "next/router"
import Link from '../Link' import Link from "../Link"
import Cookies from "js-cookie"; import Cookies from "js-cookie"
import useSignedIn from '@lib/hooks/use-signed-in' import useSignedIn from "@lib/hooks/use-signed-in"
const NO_EMPTY_SPACE_REGEX = /^\S*$/; const NO_EMPTY_SPACE_REGEX = /^\S*$/
const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters"; const ERROR_MESSAGE =
"Provide a non empty username and a password with at least 6 characters"
const Auth = ({ page }: { page: "signup" | "signin" }) => { const Auth = ({ page }: { page: "signup" | "signin" }) => {
const router = useRouter(); const router = useRouter()
const [username, setUsername] = useState(''); const [username, setUsername] = useState("")
const [password, setPassword] = useState(''); const [password, setPassword] = useState("")
const [serverPassword, setServerPassword] = useState(''); const [serverPassword, setServerPassword] = useState("")
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState("")
const [requiresServerPassword, setRequiresServerPassword] = useState(false); const [requiresServerPassword, setRequiresServerPassword] = useState(false)
const signingIn = page === 'signin' const signingIn = page === "signin"
const { signin } = useSignedIn(); const { signin } = useSignedIn()
useEffect(() => { useEffect(() => {
async function fetchRequiresPass() { async function fetchRequiresPass() {
if (!signingIn) { if (!signingIn) {
const resp = await fetch("/server-api/auth/requires-passcode", { const resp = await fetch("/server-api/auth/requires-passcode", {
method: "GET", method: "GET"
}) })
if (resp.ok) { if (resp.ok) {
const res = await resp.json() const res = await resp.json()
setRequiresServerPassword(res.requiresPasscode) setRequiresServerPassword(res.requiresPasscode)
} else { } else {
setErrorMsg("Something went wrong. Is the server running?") setErrorMsg("Something went wrong. Is the server running?")
} }
} }
} }
fetchRequiresPass() fetchRequiresPass()
}, [page, signingIn]) }, [page, signingIn])
const handleJson = (json: any) => {
signin(json.token)
Cookies.set("drift-userid", json.userId)
const handleJson = (json: any) => { router.push("/new")
signin(json.token) }
Cookies.set('drift-userid', json.userId);
router.push('/new') const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
} e.preventDefault()
if (
!signingIn &&
(!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)
)
return setErrorMsg(ERROR_MESSAGE)
if (
!signingIn &&
requiresServerPassword &&
!NO_EMPTY_SPACE_REGEX.test(serverPassword)
)
return setErrorMsg(ERROR_MESSAGE)
else setErrorMsg("")
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const reqOpts = {
e.preventDefault() method: "POST",
if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE) headers: {
if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE) "Content-Type": "application/json"
else setErrorMsg(''); },
body: JSON.stringify({ username, password, serverPassword })
}
const reqOpts = { try {
method: 'POST', const signUrl = signingIn
headers: { ? "/server-api/auth/signin"
'Content-Type': 'application/json' : "/server-api/auth/signup"
}, const resp = await fetch(signUrl, reqOpts)
body: JSON.stringify({ username, password, serverPassword }) const json = await resp.json()
} if (!resp.ok) throw new Error(json.error.message)
try { handleJson(json)
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup'; } catch (err: any) {
const resp = await fetch(signUrl, reqOpts); setErrorMsg(err.message ?? "Something went wrong")
const json = await resp.json(); }
if (!resp.ok) throw new Error(json.error.message); }
handleJson(json) return (
} catch (err: any) { <div className={styles.container}>
setErrorMsg(err.message ?? "Something went wrong") <div className={styles.form}>
} <div className={styles.formContentSpace}>
} <h1>{signingIn ? "Sign In" : "Sign Up"}</h1>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<Input
htmlType="text"
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
required
scale={4 / 3}
/>
<Input
htmlType="password"
id="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
required
scale={4 / 3}
/>
{requiresServerPassword && (
<Input
htmlType="password"
id="server-password"
value={serverPassword}
onChange={(event) => setServerPassword(event.target.value)}
placeholder="Server Password"
required
scale={4 / 3}
/>
)}
return ( <Button type="success" htmlType="submit">
<div className={styles.container}> {signingIn ? "Sign In" : "Sign Up"}
<div className={styles.form}> </Button>
<div className={styles.formContentSpace}> </div>
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1> <div className={styles.formContentSpace}>
</div> {signingIn ? (
<form onSubmit={handleSubmit}> <Text>
<div className={styles.formGroup}> Don&apos;t have an account?{" "}
<Input <Link color href="/signup">
htmlType="text" Sign up
id="username" </Link>
value={username} </Text>
onChange={(event) => setUsername(event.target.value)} ) : (
placeholder="Username" <Text>
required Already have an account?{" "}
scale={4 / 3} <Link color href="/signin">
/> Sign in
<Input </Link>
htmlType='password' </Text>
id="password" )}
value={password} </div>
onChange={(event) => setPassword(event.target.value)} {errorMsg && (
placeholder="Password" <Note scale={0.75} type="error">
required {errorMsg}
scale={4 / 3} </Note>
/> )}
{requiresServerPassword && <Input </form>
htmlType='password' </div>
id="server-password" </div>
value={serverPassword} )
onChange={(event) => setServerPassword(event.target.value)}
placeholder="Server Password"
required
scale={4 / 3}
/>}
<Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
</div>
<div className={styles.formContentSpace}>
{signingIn ? (
<Text>
Don&apos;t have an account?{" "}
<Link color href="/signup">Sign up</Link>
</Text>
) : (
<Text>
Already have an account?{" "}
<Link color href="/signin">Sign in</Link>
</Text>
)}
</div>
{errorMsg && <Note scale={0.75} type='error'>{errorMsg}</Note>}
</form>
</div>
</div >
)
} }
export default Auth export default Auth

View file

@ -1,22 +1,27 @@
import { Badge, Tooltip } from "@geist-ui/core"; import { Badge, Tooltip } from "@geist-ui/core"
import { timeAgo } from "@lib/time-ago"; import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react"
const CreatedAgoBadge = ({ createdAt }: { const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
createdAt: string | Date; const createdDate = useMemo(() => new Date(createdAt), [createdAt])
}) => { const [time, setTimeAgo] = useState(timeAgo(createdDate))
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate)) setTimeAgo(timeAgo(createdDate))
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [createdDate]) }, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}` const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (<Badge type="secondary"> <Tooltip hideArrow text={formattedTime}>Created {time}</Tooltip></Badge>) return (
<Badge type="secondary">
{" "}
<Tooltip hideArrow text={formattedTime}>
Created {time}
</Tooltip>
</Badge>
)
} }
export default CreatedAgoBadge export default CreatedAgoBadge

View file

@ -1,60 +1,66 @@
import { Badge, Tooltip } from "@geist-ui/core"; import { Badge, Tooltip } from "@geist-ui/core"
import { timeUntil } from "@lib/time-ago"; import { timeUntil } from "@lib/time-ago"
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"
const ExpirationBadge = ({ const ExpirationBadge = ({
postExpirationDate, postExpirationDate
// onExpires }: // onExpires
}: { {
postExpirationDate: Date | string | null postExpirationDate: Date | string | null
onExpires?: () => void onExpires?: () => void
}) => { }) => {
const expirationDate = useMemo(() => postExpirationDate ? new Date(postExpirationDate) : null, [postExpirationDate]) const expirationDate = useMemo(
const [timeUntilString, setTimeUntil] = useState<string | null>(expirationDate ? timeUntil(expirationDate) : null); () => (postExpirationDate ? new Date(postExpirationDate) : null),
[postExpirationDate]
)
const [timeUntilString, setTimeUntil] = useState<string | null>(
expirationDate ? timeUntil(expirationDate) : null
)
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timer | null = null; let interval: NodeJS.Timer | null = null
if (expirationDate) { if (expirationDate) {
interval = setInterval(() => { interval = setInterval(() => {
if (expirationDate) { if (expirationDate) {
setTimeUntil(timeUntil(expirationDate)) setTimeUntil(timeUntil(expirationDate))
} }
}, 1000) }, 1000)
} }
return () => { return () => {
if (interval) { if (interval) {
clearInterval(interval) clearInterval(interval)
} }
} }
}, [expirationDate]) }, [expirationDate])
const isExpired = useMemo(() => { const isExpired = useMemo(() => {
return timeUntilString && timeUntilString === "in 0 seconds" return timeUntilString && timeUntilString === "in 0 seconds"
}, [timeUntilString]) }, [timeUntilString])
// useEffect(() => { // useEffect(() => {
// // check if expired every // // check if expired every
// if (isExpired) { // if (isExpired) {
// if (onExpires) { // if (onExpires) {
// onExpires(); // onExpires();
// } // }
// } // }
// }, [isExpired, onExpires]) // }, [isExpired, onExpires])
if (!expirationDate) { if (!expirationDate) {
return null; return null
} }
return ( return (
<Badge type={isExpired ? "error" : "warning"}> <Badge type={isExpired ? "error" : "warning"}>
<Tooltip <Tooltip
hideArrow hideArrow
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}> text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
{isExpired ? "Expired" : `Expires ${timeUntilString}`} >
</Tooltip> {isExpired ? "Expired" : `Expires ${timeUntilString}`}
</Badge> </Tooltip>
) </Badge>
)
} }
export default ExpirationBadge export default ExpirationBadge

View file

@ -2,22 +2,22 @@ import { Badge } from "@geist-ui/core"
import type { PostVisibility } from "@lib/types" import type { PostVisibility } from "@lib/types"
type Props = { type Props = {
visibility: PostVisibility visibility: PostVisibility
} }
const VisibilityBadge = ({ visibility }: Props) => { const VisibilityBadge = ({ visibility }: Props) => {
const getBadgeType = () => { const getBadgeType = () => {
switch (visibility) { switch (visibility) {
case "public": case "public":
return "success" return "success"
case "private": case "private":
return "warning" return "warning"
case "unlisted": case "unlisted":
return "default" return "default"
} }
} }
return (<Badge type={getBadgeType()}>{visibility}</Badge>) return <Badge type={getBadgeType()}>{visibility}</Badge>
} }
export default VisibilityBadge export default VisibilityBadge

View file

@ -1,116 +1,116 @@
import Button from "@components/button" import Button from "@components/button"
import React, { useCallback, useEffect } from "react" import React, { useCallback, useEffect } from "react"
import { useState } from "react" import { useState } from "react"
import styles from './dropdown.module.css' import styles from "./dropdown.module.css"
import DownIcon from '@geist-ui/icons/arrowDown' import DownIcon from "@geist-ui/icons/arrowDown"
type Props = { type Props = {
type?: "primary" | "secondary" type?: "primary" | "secondary"
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean
className?: string className?: string
iconHeight?: number iconHeight?: number
} }
type Attrs = Omit<React.HTMLAttributes<any>, keyof Props> type Attrs = Omit<React.HTMLAttributes<any>, keyof Props>
type ButtonDropdownProps = Props & Attrs type ButtonDropdownProps = Props & Attrs
const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = ({ const ButtonDropdown: React.FC<
type, React.PropsWithChildren<ButtonDropdownProps>
className, > = ({ type, className, disabled, loading, iconHeight = 24, ...props }) => {
disabled, const [visible, setVisible] = useState(false)
loading, const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
iconHeight = 24,
...props
}) => {
const [visible, setVisible] = useState(false)
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => { const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation() e.nativeEvent.stopImmediatePropagation()
setVisible(!visible) setVisible(!visible)
} }
const onBlur = () => { const onBlur = () => {
setVisible(false) setVisible(false)
} }
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => { const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation() e.nativeEvent.stopImmediatePropagation()
} }
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => { const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation() e.nativeEvent.stopImmediatePropagation()
} }
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => { const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
e.nativeEvent.stopImmediatePropagation() e.nativeEvent.stopImmediatePropagation()
setVisible(false) setVisible(false)
} }
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setVisible(false) setVisible(false)
} }
} }
const onClickOutside = useCallback(() => (e: React.MouseEvent<HTMLDivElement>) => { const onClickOutside = useCallback(
if (dropdown && !dropdown.contains(e.target as Node)) { () => (e: React.MouseEvent<HTMLDivElement>) => {
setVisible(false) if (dropdown && !dropdown.contains(e.target as Node)) {
} setVisible(false)
}, [dropdown]) }
},
[dropdown]
)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
document.addEventListener("mousedown", onClickOutside) document.addEventListener("mousedown", onClickOutside)
} else { } else {
document.removeEventListener("mousedown", onClickOutside) document.removeEventListener("mousedown", onClickOutside)
} }
return () => {
document.removeEventListener("mousedown", onClickOutside)
}
}, [visible, onClickOutside])
if (!Array.isArray(props.children)) {
return null
}
return (
<div
className={`${styles.main} ${className}`}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onBlur={onBlur}
>
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'flex-end' }}>
{props.children[0]}
<Button style={{ height: iconHeight, width: iconHeight }} className={styles.icon} onClick={() => setVisible(!visible)}><DownIcon /></Button>
</div>
{
visible && (
<div
className={`${styles.dropdown}`}
>
<div
className={`${styles.dropdownContent}`}
>
{props.children.slice(1)}
</div>
</div>
)
}
</div >
)
return () => {
document.removeEventListener("mousedown", onClickOutside)
}
}, [visible, onClickOutside])
if (!Array.isArray(props.children)) {
return null
}
return (
<div
className={`${styles.main} ${className}`}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onBlur={onBlur}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-end"
}}
>
{props.children[0]}
<Button
style={{ height: iconHeight, width: iconHeight }}
className={styles.icon}
onClick={() => setVisible(!visible)}
>
<DownIcon />
</Button>
</div>
{visible && (
<div className={`${styles.dropdown}`}>
<div className={`${styles.dropdownContent}`}>
{props.children.slice(1)}
</div>
</div>
)}
</div>
)
} }
export default ButtonDropdown export default ButtonDropdown

View file

@ -1,28 +1,39 @@
import styles from './button.module.css' import styles from "./button.module.css"
import { forwardRef, Ref } from 'react' import { forwardRef, Ref } from "react"
type Props = React.HTMLProps<HTMLButtonElement> & { type Props = React.HTMLProps<HTMLButtonElement> & {
children: React.ReactNode children: React.ReactNode
buttonType?: 'primary' | 'secondary' buttonType?: "primary" | "secondary"
className?: string className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
} }
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>( const Button = forwardRef<HTMLButtonElement, Props>(
({ children, onClick, className, buttonType = 'primary', type = 'button', disabled = false, ...props }, ref) => { (
return ( {
<button children,
ref={ref} onClick,
className={`${styles.button} ${styles[type]} ${className}`} className,
disabled={disabled} buttonType = "primary",
onClick={onClick} type = "button",
{...props} disabled = false,
> ...props
{children} },
</button> ref
) ) => {
} return (
<button
ref={ref}
className={`${styles.button} ${styles[type]} ${className}`}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
)
}
) )
export default Button export default Button

View file

@ -2,34 +2,44 @@ import type { Document } from "@lib/types"
import DocumentComponent from "@components/edit-document" import DocumentComponent from "@components/edit-document"
import { ChangeEvent, memo, useCallback } from "react" import { ChangeEvent, memo, useCallback } from "react"
const DocumentList = ({ docs, removeDoc, updateDocContent, updateDocTitle, onPaste }: { const DocumentList = ({
docs: Document[], docs,
updateDocTitle: (i: number) => (title: string) => void removeDoc,
updateDocContent: (i: number) => (content: string) => void updateDocContent,
removeDoc: (i: number) => () => void updateDocTitle,
onPaste: (e: any) => void onPaste
}: {
docs: Document[]
updateDocTitle: (i: number) => (title: string) => void
updateDocContent: (i: number) => (content: string) => void
removeDoc: (i: number) => () => void
onPaste: (e: any) => void
}) => { }) => {
const handleOnChange = useCallback((i) => (e: ChangeEvent<HTMLTextAreaElement>) => { const handleOnChange = useCallback(
updateDocContent(i)(e.target.value) (i) => (e: ChangeEvent<HTMLTextAreaElement>) => {
}, [updateDocContent]) updateDocContent(i)(e.target.value)
},
[updateDocContent]
)
return (<>{ return (
docs.map(({ content, id, title }, i) => { <>
return ( {docs.map(({ content, id, title }, i) => {
<DocumentComponent return (
onPaste={onPaste} <DocumentComponent
key={id} onPaste={onPaste}
remove={removeDoc(i)} key={id}
setContent={updateDocContent(i)} remove={removeDoc(i)}
setTitle={updateDocTitle(i)} setContent={updateDocContent(i)}
handleOnContentChange={handleOnChange(i)} setTitle={updateDocTitle(i)}
content={content} handleOnContentChange={handleOnChange(i)}
title={title} content={content}
/> title={title}
) />
}) )
} })}
</>) </>
)
} }
export default memo(DocumentList) export default memo(DocumentList)

View file

@ -1,131 +1,148 @@
import Bold from '@geist-ui/icons/bold' import Bold from "@geist-ui/icons/bold"
import Italic from '@geist-ui/icons/italic' import Italic from "@geist-ui/icons/italic"
import Link from '@geist-ui/icons/link' import Link from "@geist-ui/icons/link"
import ImageIcon from '@geist-ui/icons/image' import ImageIcon from "@geist-ui/icons/image"
import { RefObject, useCallback, useMemo } from "react" import { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css' import styles from "../document.module.css"
import { Button, ButtonGroup } from "@geist-ui/core" import { Button, ButtonGroup } from "@geist-ui/core"
// TODO: clean up // TODO: clean up
const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTMLTextAreaElement>, setText?: (text: string) => void }) => { const FormattingIcons = ({
// const { textBefore, textAfter, selectedText } = useMemo(() => { textareaRef,
// if (textareaRef && textareaRef.current) { setText
// const textarea = textareaRef.current }: {
// const text = textareaRef.current.value textareaRef?: RefObject<HTMLTextAreaElement>
// const selectionStart = textarea.selectionStart setText?: (text: string) => void
// const selectionEnd = textarea.selectionEnd }) => {
// const textBefore = text.substring(0, selectionStart) // const { textBefore, textAfter, selectedText } = useMemo(() => {
// const textAfter = text.substring(selectionEnd) // if (textareaRef && textareaRef.current) {
// const selectedText = text.substring(selectionStart, selectionEnd) // const textarea = textareaRef.current
// return { textBefore, textAfter, selectedText } // const text = textareaRef.current.value
// } // const selectionStart = textarea.selectionStart
// return { textBefore: '', textAfter: '' } // const selectionEnd = textarea.selectionEnd
// }, [textareaRef,]) // const textBefore = text.substring(0, selectionStart)
// const textAfter = text.substring(selectionEnd)
// const selectedText = text.substring(selectionStart, selectionEnd)
// return { textBefore, textAfter, selectedText }
// }
// return { textBefore: '', textAfter: '' }
// }, [textareaRef,])
const handleBoldClick = useCallback(() => { const handleBoldClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value const text = textareaRef.current.value
const before = text.substring(0, selectionStart) const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd) const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd) const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}**${selectedText}**${after}` const newText = `${before}**${selectedText}**${after}`
setText(newText) setText(newText)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleItalicClick = useCallback(() => { const handleItalicClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value const text = textareaRef.current.value
const before = text.substring(0, selectionStart) const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd) const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd) const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}*${selectedText}*${after}` const newText = `${before}*${selectedText}*${after}`
setText(newText) setText(newText)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleLinkClick = useCallback(() => { const handleLinkClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value const text = textareaRef.current.value
const before = text.substring(0, selectionStart) const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd) const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd) const selectedText = text.substring(selectionStart, selectionEnd)
let formattedText = ''; let formattedText = ""
if (selectedText.includes('http')) { if (selectedText.includes("http")) {
formattedText = `[](${selectedText})` formattedText = `[](${selectedText})`
} else { } else {
formattedText = `[${selectedText}](https://)` formattedText = `[${selectedText}](https://)`
} }
const newText = `${before}${formattedText}${after}` const newText = `${before}${formattedText}${after}`
setText(newText) setText(newText)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleImageClick = useCallback(() => { const handleImageClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value const text = textareaRef.current.value
const before = text.substring(0, selectionStart) const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd) const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd) const selectedText = text.substring(selectionStart, selectionEnd)
let formattedText = ''; let formattedText = ""
if (selectedText.includes('http')) { if (selectedText.includes("http")) {
formattedText = `![](${selectedText})` formattedText = `![](${selectedText})`
} else { } else {
formattedText = `![${selectedText}](https://)` formattedText = `![${selectedText}](https://)`
} }
const newText = `${before}${formattedText}${after}` const newText = `${before}${formattedText}${after}`
setText(newText) setText(newText)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const formattingActions = useMemo(() => [ const formattingActions = useMemo(
{ () => [
icon: <Bold />, {
name: 'bold', icon: <Bold />,
action: handleBoldClick name: "bold",
}, action: handleBoldClick
{ },
icon: <Italic />, {
name: 'italic', icon: <Italic />,
action: handleItalicClick name: "italic",
}, action: handleItalicClick
// { },
// icon: <Underline />, // {
// name: 'underline', // icon: <Underline />,
// action: handleUnderlineClick // name: 'underline',
// }, // action: handleUnderlineClick
{ // },
icon: <Link />, {
name: 'hyperlink', icon: <Link />,
action: handleLinkClick name: "hyperlink",
}, action: handleLinkClick
{ },
icon: <ImageIcon />, {
name: 'image', icon: <ImageIcon />,
action: handleImageClick name: "image",
} action: handleImageClick
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]) }
],
return ( [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]
<div className={styles.actionWrapper}> )
<ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => (
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
))}
</ButtonGroup>
</div>
)
return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => (
<Button
auto
scale={2 / 3}
px={0.6}
aria-label={name}
key={name}
icon={icon}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
/>
))}
</ButtonGroup>
</div>
)
} }
export default FormattingIcons export default FormattingIcons

View file

@ -1,118 +1,172 @@
import {
ChangeEvent,
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react" memo,
import styles from './document.module.css' useCallback,
import Trash from '@geist-ui/icons/trash' useMemo,
useRef,
useState
} from "react"
import styles from "./document.module.css"
import Trash from "@geist-ui/icons/trash"
import FormattingIcons from "./formatting-icons" import FormattingIcons from "./formatting-icons"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core" import {
Button,
ButtonGroup,
Card,
Input,
Spacer,
Tabs,
Textarea,
Tooltip
} from "@geist-ui/core"
import Preview from "@components/preview" import Preview from "@components/preview"
// import Link from "next/link" // import Link from "next/link"
type Props = { type Props = {
title?: string title?: string
content?: string content?: string
setTitle?: (title: string) => void setTitle?: (title: string) => void
setContent?: (content: string) => void setContent?: (content: string) => void
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
initialTab?: "edit" | "preview" initialTab?: "edit" | "preview"
remove?: () => void remove?: () => void
onPaste?: (e: any) => void onPaste?: (e: any) => void
} }
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => { const Document = ({
const codeEditorRef = useRef<HTMLTextAreaElement>(null) onPaste,
const [tab, setTab] = useState(initialTab) remove,
// const height = editable ? "500px" : '100%' title,
const height = "100%"; content,
setTitle,
setContent,
initialTab = "edit",
handleOnContentChange
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%"
const handleTabChange = (newTab: string) => { const handleTabChange = (newTab: string) => {
if (newTab === 'edit') { if (newTab === "edit") {
codeEditorRef.current?.focus() codeEditorRef.current?.focus()
} }
setTab(newTab as 'edit' | 'preview') setTab(newTab as "edit" | "preview")
} }
const onTitleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null, [setTitle]) const onTitleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) =>
setTitle ? setTitle(event.target.value) : null,
[setTitle]
)
const removeFile = useCallback((remove?: () => void) => { const removeFile = useCallback(
if (remove) { (remove?: () => void) => {
if (content && content.trim().length > 0) { if (remove) {
const confirmed = window.confirm("Are you sure you want to remove this file?") if (content && content.trim().length > 0) {
if (confirmed) { const confirmed = window.confirm(
remove() "Are you sure you want to remove this file?"
} )
} else { if (confirmed) {
remove() remove()
} }
} } else {
}, [content]) remove()
}
}
},
[content]
)
// if (skeleton) { // if (skeleton) {
// return <> // return <>
// <Spacer height={1} /> // <Spacer height={1} />
// <div className={styles.card}> // <div className={styles.card}>
// <div className={styles.fileNameContainer}> // <div className={styles.fileNameContainer}>
// <Skeleton width={275} height={36} /> // <Skeleton width={275} height={36} />
// {remove && <Skeleton width={36} height={36} />} // {remove && <Skeleton width={36} height={36} />}
// </div> // </div>
// <div className={styles.descriptionContainer}> // <div className={styles.descriptionContainer}>
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div> // <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
// <Skeleton width={'100%'} height={350} /> // <Skeleton width={'100%'} height={350} />
// </div > // </div >
// </div> // </div>
// </> // </>
// } // }
return ( return (
<> <>
<Spacer height={1} /> <Spacer height={1} />
<div className={styles.card}> <div className={styles.card}>
<div className={styles.fileNameContainer}> <div className={styles.fileNameContainer}>
<Input <Input
placeholder="MyFile.md" placeholder="MyFile.md"
value={title} value={title}
onChange={onTitleChange} onChange={onTitleChange}
marginTop="var(--gap-double)" marginTop="var(--gap-double)"
size={1.2} size={1.2}
font={1.2} font={1.2}
label="Filename" label="Filename"
width={"100%"} width={"100%"}
id={title} id={title}
/> />
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />} {remove && (
</div> <Button
<div className={styles.descriptionContainer}> type="abort"
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />} ghost
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> icon={<Trash />}
<Tabs.Item label={"Edit"} value="edit"> auto
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} height={"36px"}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> width={"36px"}
<Textarea onClick={() => removeFile(remove)}
onPaste={onPaste ? onPaste : undefined} />
ref={codeEditorRef} )}
placeholder="" </div>
value={content} <div className={styles.descriptionContainer}>
onChange={handleOnContentChange} {tab === "edit" && (
width="100%" <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />
// TODO: Textarea should grow to fill parent if height == 100% )}
style={{ flex: 1, minHeight: 350 }} <Tabs
resize="vertical" onChange={handleTabChange}
className={styles.textarea} initialValue={initialTab}
/> hideDivider
</div> leftSpace={0}
</Tabs.Item> >
<Tabs.Item label="Preview" value="preview"> <Tabs.Item label={"Edit"} value="edit">
<div style={{ marginTop: 'var(--gap-half)', }}> {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<Preview height={height} title={title} content={content} /> <div
</div> style={{
</Tabs.Item> marginTop: "var(--gap-half)",
</Tabs> display: "flex",
</div > flexDirection: "column"
</div > }}
</> >
) <Textarea
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
placeholder=""
value={content}
onChange={handleOnContentChange}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: "var(--gap-half)" }}>
<Preview height={height} title={title} content={content} />
</div>
</Tabs.Item>
</Tabs>
</div>
</div>
</>
)
} }
export default memo(Document)
export default memo(Document)

View file

@ -1,19 +1,17 @@
import { Page } from '@geist-ui/core' import { Page } from "@geist-ui/core"
const Error = ({ status }: { const Error = ({ status }: { status: number }) => {
status: number return (
}) => { <Page title={status.toString() || "Error"}>
return ( {status === 404 ? (
<Page title={status.toString() || 'Error'}> <h1>This page cannot be found.</h1>
{status === 404 ? ( ) : (
<h1>This page cannot be found.</h1> <section>
) : ( <p>An error occurred: {status}</p>
<section> </section>
<p>An error occurred: {status}</p> )}
</section> </Page>
)} )
</Page>
)
} }
export default Error export default Error

View file

@ -1,30 +1,30 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/ // https://www.joshwcomeau.com/snippets/react-components/fade-in/
import styles from './fade.module.css'; import styles from "./fade.module.css"
const FadeIn = ({ const FadeIn = ({
duration = 300, duration = 300,
delay = 0, delay = 0,
children, children,
...delegated ...delegated
}: { }: {
duration?: number; duration?: number
delay?: number; delay?: number
children: React.ReactNode; children: React.ReactNode
[key: string]: any; [key: string]: any
}) => { }) => {
return ( return (
<div <div
{...delegated} {...delegated}
className={styles.fadeIn} className={styles.fadeIn}
style={{ style={{
...(delegated.style || {}), ...(delegated.style || {}),
animationDuration: duration + 'ms', animationDuration: duration + "ms",
animationDelay: delay + 'ms', animationDelay: delay + "ms"
}} }}
> >
{children} {children}
</div> </div>
); )
}; }
export default FadeIn export default FadeIn

View file

@ -1,83 +1,101 @@
import { Button, Link, Text, Popover } from '@geist-ui/core' import { Button, Link, Text, Popover } from "@geist-ui/core"
import FileIcon from '@geist-ui/icons/fileText' import FileIcon from "@geist-ui/icons/fileText"
import CodeIcon from '@geist-ui/icons/fileFunction' import CodeIcon from "@geist-ui/icons/fileFunction"
import styles from './dropdown.module.css' import styles from "./dropdown.module.css"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
import ChevronDown from '@geist-ui/icons/chevronDown' import ChevronDown from "@geist-ui/icons/chevronDown"
import ShiftBy from "@components/shift-by" import ShiftBy from "@components/shift-by"
import type { File } from '@lib/types' import type { File } from "@lib/types"
type Item = File & { type Item = File & {
icon: JSX.Element icon: JSX.Element
} }
const FileDropdown = ({ const FileDropdown = ({
files, files,
isMobile isMobile
}: { }: {
files: File[], files: File[]
isMobile: boolean isMobile: boolean
}) => { }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
const changeHandler = (next: boolean) => { const changeHandler = (next: boolean) => {
setExpanded(next) setExpanded(next)
} }
const onOpen = useCallback(() => { const onOpen = useCallback(() => {
setExpanded(true) setExpanded(true)
}, []) }, [])
const onClose = useCallback(() => { const onClose = useCallback(() => {
setExpanded(false) setExpanded(false)
// contentRef.current?.focus() // contentRef.current?.focus()
}, []) }, [])
useEffect(() => { useEffect(() => {
const newItems = files.map(file => { const newItems = files.map((file) => {
const extension = file.title.split('.').pop() const extension = file.title.split(".").pop()
if (codeFileExtensions.includes(extension || '')) { if (codeFileExtensions.includes(extension || "")) {
return { return {
...file, ...file,
icon: <CodeIcon /> icon: <CodeIcon />
} }
} else { } else {
return { return {
...file, ...file,
icon: <FileIcon /> icon: <FileIcon />
} }
} }
}) })
setItems(newItems) setItems(newItems)
}, [files]) }, [files])
const content = useCallback(() => (<ul className={styles.content}> const content = useCallback(
{items.map(item => ( () => (
<li key={item.id} onClick={onClose}> <ul className={styles.content}>
<a href={`#${item.title}`}> {items.map((item) => (
<ShiftBy y={5}><span className={styles.fileIcon}> <li key={item.id} onClick={onClose}>
{item.icon}</span></ShiftBy> <a href={`#${item.title}`}>
<span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span> <ShiftBy y={5}>
</a> <span className={styles.fileIcon}>{item.icon}</span>
</li> </ShiftBy>
))} <span className={styles.fileTitle}>
</ul> {item.title ? item.title : "Untitled"}
), [items, onClose]) </span>
</a>
</li>
))}
</ul>
),
[items, onClose]
)
// a list of files with an icon and a title // a list of files with an icon and a title
return ( return (
<> <>
<Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />} style={{ textTransform: 'none' }} > <Button
Jump to {files.length} {files.length === 1 ? 'file' : 'files'} auto
</Button> onClick={onOpen}
<Popover className={styles.button}
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }} iconRight={<ChevronDown />}
onVisibleChange={changeHandler} style={{ textTransform: "none" }}
content={content} visible={expanded} hideArrow={true} onClick={onClose} /> >
</> Jump to {files.length} {files.length === 1 ? "file" : "files"}
</Button>
) <Popover
style={{
transform: isMobile ? "translateX(110px)" : "translateX(-75px)"
}}
onVisibleChange={changeHandler}
content={content}
visible={expanded}
hideArrow={true}
onClick={onClose}
/>
</>
)
} }
export default FileDropdown export default FileDropdown

View file

@ -1,61 +1,58 @@
import { File } from "@lib/types" import { File } from "@lib/types"
import { Card, Link, Text } from '@geist-ui/core' import { Card, Link, Text } from "@geist-ui/core"
import FileIcon from '@geist-ui/icons/fileText' import FileIcon from "@geist-ui/icons/fileText"
import CodeIcon from '@geist-ui/icons/fileLambda' import CodeIcon from "@geist-ui/icons/fileLambda"
import styles from './file-tree.module.css' import styles from "./file-tree.module.css"
import ShiftBy from "@components/shift-by" import ShiftBy from "@components/shift-by"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
type Item = File & { type Item = File & {
icon: JSX.Element icon: JSX.Element
} }
const FileTree = ({ const FileTree = ({ files }: { files: File[] }) => {
files const [items, setItems] = useState<Item[]>([])
}: { useEffect(() => {
files: File[] const newItems = files.map((file) => {
}) => { const extension = file.title.split(".").pop()
const [items, setItems] = useState<Item[]>([]) if (codeFileExtensions.includes(extension || "")) {
useEffect(() => { return {
const newItems = files.map(file => { ...file,
const extension = file.title.split('.').pop() icon: <CodeIcon />
if (codeFileExtensions.includes(extension || '')) { }
return { } else {
...file, return {
icon: <CodeIcon /> ...file,
} icon: <FileIcon />
} else { }
return { }
...file, })
icon: <FileIcon /> setItems(newItems)
} }, [files])
}
})
setItems(newItems)
}, [files])
// a list of files with an icon and a title // a list of files with an icon and a title
return ( return (
<div className={styles.fileTreeWrapper}> <div className={styles.fileTreeWrapper}>
<Card height={'100%'} className={styles.card}> <Card height={"100%"} className={styles.card}>
<div className={styles.cardContent}> <div className={styles.cardContent}>
<Text h4>Files</Text> <Text h4>Files</Text>
<ul className={styles.fileTree}> <ul className={styles.fileTree}>
{items.map(({ id, title, icon }) => ( {items.map(({ id, title, icon }) => (
<li key={id}> <li key={id}>
<Link color={false} href={`#${title}`}> <Link color={false} href={`#${title}`}>
<ShiftBy y={5}><span className={styles.fileTreeIcon}> <ShiftBy y={5}>
{icon}</span></ShiftBy> <span className={styles.fileTreeIcon}>{icon}</span>
<span className={styles.fileTreeTitle}>{title}</span> </ShiftBy>
</Link> <span className={styles.fileTreeTitle}>{title}</span>
</li> </Link>
))} </li>
</ul> ))}
</div> </ul>
</Card> </div>
</div > </Card>
) </div>
)
} }
export default FileTree export default FileTree

View file

@ -1,27 +1,26 @@
import Head from "next/head"; import Head from "next/head"
import React from "react"; import React from "react"
type PageSeoProps = { type PageSeoProps = {
title?: string; title?: string
description?: string; description?: string
isLoading?: boolean; isLoading?: boolean
isPrivate?: boolean isPrivate?: boolean
}; }
const PageSeo = ({ const PageSeo = ({
title = 'Drift', title = "Drift",
description = "A self-hostable clone of GitHub Gist", description = "A self-hostable clone of GitHub Gist",
isPrivate = false isPrivate = false
}: PageSeoProps) => { }: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
)
}
return ( export default PageSeo
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
);
};
export default PageSeo;

View file

@ -1,46 +1,46 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react"
import MoonIcon from '@geist-ui/icons/moon' import MoonIcon from "@geist-ui/icons/moon"
import SunIcon from '@geist-ui/icons/sun' import SunIcon from "@geist-ui/icons/sun"
// import { useAllThemes, useTheme } from '@geist-ui/core' // import { useAllThemes, useTheme } from '@geist-ui/core'
import styles from './header.module.css' import styles from "./header.module.css"
import { Select } from '@geist-ui/core' import { Select } from "@geist-ui/core"
import { useTheme } from 'next-themes' import { useTheme } from "next-themes"
const Controls = () => { const Controls = () => {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const { resolvedTheme, setTheme } = useTheme() const { resolvedTheme, setTheme } = useTheme()
useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
if (!mounted) return null if (!mounted) return null
const switchThemes = () => { const switchThemes = () => {
if (resolvedTheme === 'dark') { if (resolvedTheme === "dark") {
setTheme('light') setTheme("light")
} else { } else {
setTheme('dark') setTheme("dark")
} }
} }
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Select <Select
scale={0.5} scale={0.5}
h="28px" h="28px"
pure pure
onChange={switchThemes} onChange={switchThemes}
value={resolvedTheme} value={resolvedTheme}
> >
<Select.Option value="light"> <Select.Option value="light">
<span className={styles.selectContent}> <span className={styles.selectContent}>
<SunIcon size={14} /> Light <SunIcon size={14} /> Light
</span> </span>
</Select.Option> </Select.Option>
<Select.Option value="dark"> <Select.Option value="dark">
<span className={styles.selectContent}> <span className={styles.selectContent}>
<MoonIcon size={14} /> Dark <MoonIcon size={14} /> Dark
</span> </span>
</Select.Option> </Select.Option>
</Select> </Select>
</div > </div>
) )
} }
export default React.memo(Controls); export default React.memo(Controls)

View file

@ -1,207 +1,225 @@
import {
ButtonGroup,
Button,
Page,
Spacer,
useBodyScroll,
useMediaQuery
} from "@geist-ui/core"
import { ButtonGroup, Button, Page, Spacer, useBodyScroll, useMediaQuery, } from "@geist-ui/core"; import { useCallback, useEffect, useMemo, useState } from "react"
import styles from "./header.module.css"
import useSignedIn from "../../lib/hooks/use-signed-in"
import { useCallback, useEffect, useMemo, useState } from "react"; import HomeIcon from "@geist-ui/icons/home"
import styles from './header.module.css'; import MenuIcon from "@geist-ui/icons/menu"
import useSignedIn from "../../lib/hooks/use-signed-in"; import GitHubIcon from "@geist-ui/icons/github"
import SignOutIcon from "@geist-ui/icons/userX"
import HomeIcon from '@geist-ui/icons/home'; import SignInIcon from "@geist-ui/icons/user"
import MenuIcon from '@geist-ui/icons/menu'; import SignUpIcon from "@geist-ui/icons/userPlus"
import GitHubIcon from '@geist-ui/icons/github'; import NewIcon from "@geist-ui/icons/plusCircle"
import SignOutIcon from '@geist-ui/icons/userX'; import YourIcon from "@geist-ui/icons/list"
import SignInIcon from '@geist-ui/icons/user'; import MoonIcon from "@geist-ui/icons/moon"
import SignUpIcon from '@geist-ui/icons/userPlus'; import SettingsIcon from "@geist-ui/icons/settings"
import NewIcon from '@geist-ui/icons/plusCircle'; import SunIcon from "@geist-ui/icons/sun"
import YourIcon from '@geist-ui/icons/list'
import MoonIcon from '@geist-ui/icons/moon';
import SettingsIcon from '@geist-ui/icons/settings';
import SunIcon from '@geist-ui/icons/sun';
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import useUserData from "@lib/hooks/use-user-data"; import useUserData from "@lib/hooks/use-user-data"
import Link from "next/link"; import Link from "next/link"
import { useRouter } from "next/router"; import { useRouter } from "next/router"
type Tab = { type Tab = {
name: string name: string
icon: JSX.Element icon: JSX.Element
value: string value: string
onClick?: () => void onClick?: () => void
href?: string href?: string
} }
const Header = () => { const Header = () => {
const router = useRouter() const router = useRouter()
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 { signedIn: isSignedIn } = useSignedIn() const { signedIn: isSignedIn } = useSignedIn()
const userData = useUserData(); const userData = useUserData()
const [pages, setPages] = useState<Tab[]>([]) const [pages, setPages] = useState<Tab[]>([])
const { setTheme, resolvedTheme } = useTheme() const { setTheme, resolvedTheme } = useTheme()
useEffect(() => { useEffect(() => {
setBodyHidden(expanded) setBodyHidden(expanded)
}, [expanded, setBodyHidden]) }, [expanded, setBodyHidden])
useEffect(() => { useEffect(() => {
if (!isMobile) { if (!isMobile) {
setExpanded(false) setExpanded(false)
} }
}, [isMobile]) }, [isMobile])
useEffect(() => { useEffect(() => {
const defaultPages: Tab[] = [ const defaultPages: Tab[] = [
{ {
name: isMobile ? "GitHub" : "", name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift", href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />, icon: <GitHubIcon />,
value: "github" value: "github"
}, },
{ {
name: isMobile ? "Change theme" : "", name: isMobile ? "Change theme" : "",
onClick: function () { onClick: function () {
if (typeof window !== 'undefined') if (typeof window !== "undefined")
setTheme(resolvedTheme === 'light' ? 'dark' : 'light'); setTheme(resolvedTheme === "light" ? "dark" : "light")
}, },
icon: resolvedTheme === 'light' ? <MoonIcon /> : <SunIcon />, icon: resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
value: "theme", value: "theme"
} }
] ]
if (isSignedIn) if (isSignedIn)
setPages([ setPages([
{ {
name: 'new', name: "new",
icon: <NewIcon />, icon: <NewIcon />,
value: 'new', value: "new",
href: '/new' href: "/new"
}, },
{ {
name: 'yours', name: "yours",
icon: <YourIcon />, icon: <YourIcon />,
value: 'yours', value: "yours",
href: '/mine' href: "/mine"
}, },
// { // {
// name: 'settings', // name: 'settings',
// icon: <SettingsIcon />, // icon: <SettingsIcon />,
// value: 'settings', // value: 'settings',
// href: '/settings' // href: '/settings'
// }, // },
{ {
name: 'sign out', name: "sign out",
icon: <SignOutIcon />, icon: <SignOutIcon />,
value: 'signout', value: "signout",
href: '/signout' href: "/signout"
}, },
...defaultPages ...defaultPages
]) ])
else else
setPages([ setPages([
{ {
name: 'home', name: "home",
icon: <HomeIcon />, icon: <HomeIcon />,
value: 'home', value: "home",
href: '/' href: "/"
}, },
{ {
name: 'Sign in', name: "Sign in",
icon: <SignInIcon />, icon: <SignInIcon />,
value: 'signin', value: "signin",
href: '/signin' href: "/signin"
}, },
{ {
name: 'Sign up', name: "Sign up",
icon: <SignUpIcon />, icon: <SignUpIcon />,
value: 'signup', value: "signup",
href: '/signup' href: "/signup"
}, },
...defaultPages ...defaultPages
]) ])
if (userData?.role === "admin") { if (userData?.role === "admin") {
setPages((pages) => [ setPages((pages) => [
...pages, ...pages,
{ {
name: 'admin', name: "admin",
icon: <SettingsIcon />, icon: <SettingsIcon />,
value: 'admin', value: "admin",
href: '/admin' href: "/admin"
} }
]) ])
} }
// TODO: investigate deps causing infinite loop // TODO: investigate deps causing infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isSignedIn, resolvedTheme, userData]) }, [isMobile, isSignedIn, resolvedTheme, userData])
const onTabChange = useCallback((tab: string) => { const onTabChange = useCallback(
if (typeof window === 'undefined') return (tab: string) => {
const match = pages.find(page => page.value === tab) if (typeof window === "undefined") return
if (match?.onClick) { const match = pages.find((page) => page.value === tab)
match.onClick() if (match?.onClick) {
} match.onClick()
}, [pages]) }
},
[pages]
)
const getButton = useCallback((tab: Tab) => { const getButton = useCallback(
const activeStyle = router.pathname === tab.href ? styles.active : "" (tab: Tab) => {
if (tab.onClick) { const activeStyle = router.pathname === tab.href ? styles.active : ""
return <Button if (tab.onClick) {
auto={isMobile ? false : true} return (
key={tab.value} <Button
icon={tab.icon} auto={isMobile ? false : true}
onClick={() => onTabChange(tab.value)} key={tab.value}
className={`${styles.tab} ${activeStyle}`} icon={tab.icon}
shadow={false} onClick={() => onTabChange(tab.value)}
> className={`${styles.tab} ${activeStyle}`}
{tab.name ? tab.name : undefined} shadow={false}
</Button> >
} else if (tab.href) { {tab.name ? tab.name : undefined}
return <Link key={tab.value} href={tab.href}> </Button>
<a className={styles.tab}> )
<Button } else if (tab.href) {
className={activeStyle} return (
auto={isMobile ? false : true} <Link key={tab.value} href={tab.href}>
icon={tab.icon} <a className={styles.tab}>
shadow={false} <Button
> className={activeStyle}
{tab.name ? tab.name : undefined} auto={isMobile ? false : true}
</Button> icon={tab.icon}
</a> shadow={false}
</Link> >
} {tab.name ? tab.name : undefined}
}, [isMobile, onTabChange, router.pathname]) </Button>
</a>
</Link>
)
}
},
[isMobile, onTabChange, router.pathname]
)
const buttons = useMemo(() => pages.map(getButton), [pages, getButton]) const buttons = useMemo(() => pages.map(getButton), [pages, getButton])
return ( return (
<Page.Header> <Page.Header>
<div className={styles.tabs}> <div className={styles.tabs}>
<div className={styles.buttons}> <div className={styles.buttons}>{buttons}</div>
{buttons} </div>
</div> <div className={styles.controls}>
</div> <Button
<div className={styles.controls}> effect={false}
<Button auto
effect={false} type="abort"
auto onClick={() => setExpanded(!expanded)}
type="abort" aria-label="Menu"
onClick={() => setExpanded(!expanded)} >
aria-label="Menu" <Spacer height={5 / 6} width={0} />
> <MenuIcon />
<Spacer height={5 / 6} width={0} /> </Button>
<MenuIcon /> </div>
</Button> {/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
</div> {isMobile && expanded && (
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */} <div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
{isMobile && expanded && (<div className={styles.mobile} onClick={() => setExpanded(!expanded)}> <ButtonGroup
<ButtonGroup vertical style={{ vertical
background: "var(--bg)", style={{
}}> background: "var(--bg)"
{buttons} }}
</ButtonGroup> >
</div>)} {buttons}
</Page.Header > </ButtonGroup>
) </div>
)}
</Page.Header>
)
} }
export default Header export default Header

View file

@ -1,43 +1,72 @@
import ShiftBy from "@components/shift-by" import ShiftBy from "@components/shift-by"
import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core" import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core"
import Image from 'next/image' import Image from "next/image"
import styles from './home.module.css' import styles from "./home.module.css"
import markdownStyles from '@components/preview/preview.module.css' import markdownStyles from "@components/preview/preview.module.css"
const Home = ({ introTitle, introContent, rendered }: { const Home = ({
introTitle: string introTitle,
introContent: string introContent,
rendered: string rendered
}: {
introTitle: string
introContent: string
rendered: string
}) => { }) => {
return (<><div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> return (
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy> <>
<Spacer /> <div
<Text style={{ display: 'inline' }} h1>{introTitle}</Text> style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
</div> >
<Card> <ShiftBy y={-2}>
<Tabs initialValue={'preview'} hideDivider leftSpace={0}> <Image
<Tabs.Item label={"Raw"} value="edit"> src={"/assets/logo-optimized.svg"}
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} width={"48px"}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> height={"48px"}
<Textarea alt=""
readOnly />
value={introContent} </ShiftBy>
width="100%" <Spacer />
// TODO: Textarea should grow to fill parent if height == 100% <Text style={{ display: "inline" }} h1>
style={{ flex: 1, minHeight: 350 }} {introTitle}
resize="vertical" </Text>
className={styles.textarea} </div>
/> <Card>
</div> <Tabs initialValue={"preview"} hideDivider leftSpace={0}>
</Tabs.Item> <Tabs.Item label={"Raw"} value="edit">
<Tabs.Item label="Preview" value="preview"> {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', }}> <div
<article className={markdownStyles.markdownPreview} dangerouslySetInnerHTML={{ __html: rendered }} style={{ style={{
height: "100%" marginTop: "var(--gap-half)",
}} /> display: "flex",
</div> flexDirection: "column"
</Tabs.Item> }}
</Tabs> >
</Card></>) <Textarea
readOnly
value={introContent}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: "var(--gap-half)" }}>
<article
className={markdownStyles.markdownPreview}
dangerouslySetInnerHTML={{ __html: rendered }}
style={{
height: "100%"
}}
/>
</div>
</Tabs.Item>
</Tabs>
</Card>
</>
)
} }
export default Home export default Home

View file

@ -1,24 +1,25 @@
import React from 'react' import React from "react"
import styles from './input.module.css' import styles from "./input.module.css"
type Props = React.HTMLProps<HTMLInputElement> & { type Props = React.HTMLProps<HTMLInputElement> & {
label?: string label?: string
fontSize?: number | string fontSize?: number | string
} }
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const Input = React.forwardRef<HTMLInputElement, Props>(({ label, className, ...props }, ref) => { const Input = React.forwardRef<HTMLInputElement, Props>(
return (<div className={styles.wrapper}> ({ label, className, ...props }, ref) => {
{label && <label className={styles.label}>{label}</label>} return (
<input <div className={styles.wrapper}>
{label && <label className={styles.label}>{label}</label>}
ref={ref} <input
className={className ? `${styles.input} ${className}` : styles.input} ref={ref}
{...props} className={className ? `${styles.input} ${className}` : styles.input}
/> {...props}
</div> />
</div>
) )
}) }
)
export default Input export default Input

View file

@ -1,13 +1,16 @@
import type { Post } from "@lib/types" import type { Post } from "@lib/types"
import PostList from "../post-list" import PostList from "../post-list"
const MyPosts = ({ posts, error, morePosts }: const MyPosts = ({
{ posts,
posts: Post[], error,
error: boolean, morePosts
morePosts: boolean }: {
}) => { posts: Post[]
return <PostList morePosts={morePosts} initialPosts={posts} error={error} /> error: boolean
morePosts: boolean
}) => {
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
} }
export default MyPosts export default MyPosts

View file

@ -1,89 +1,115 @@
import { Text, useTheme, useToasts } from '@geist-ui/core' import { Text, useTheme, useToasts } from "@geist-ui/core"
import { memo } from 'react' import { memo } from "react"
import { useDropzone } from 'react-dropzone' import { useDropzone } from "react-dropzone"
import styles from './drag-and-drop.module.css' import styles from "./drag-and-drop.module.css"
import type { Document } from '@lib/types' import type { Document } from "@lib/types"
import generateUUID from '@lib/generate-uuid' import generateUUID from "@lib/generate-uuid"
import { allowedFileTypes, allowedFileNames, allowedFileExtensions } from '@lib/constants' import {
allowedFileTypes,
allowedFileNames,
allowedFileExtensions
} from "@lib/constants"
function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
const { palette } = useTheme()
const { setToast } = useToasts()
const onDrop = async (acceptedFiles: File[]) => {
const newDocs = await Promise.all(
acceptedFiles.map((file) => {
return new Promise<Document>((resolve) => {
const reader = new FileReader()
function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) { reader.onabort = () =>
const { palette } = useTheme() setToast({ text: "File reading was aborted", type: "error" })
const { setToast } = useToasts() reader.onerror = () =>
const onDrop = async (acceptedFiles: File[]) => { setToast({ text: "File reading failed", type: "error" })
const newDocs = await Promise.all(acceptedFiles.map((file) => { reader.onload = () => {
return new Promise<Document>((resolve) => { const content = reader.result as string
const reader = new FileReader() resolve({
title: file.name,
content,
id: generateUUID()
})
}
reader.readAsText(file)
})
})
)
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' }) setDocs(newDocs)
reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' }) }
reader.onload = () => {
const content = reader.result as string
resolve({
title: file.name,
content,
id: generateUUID()
})
}
reader.readAsText(file)
})
}))
setDocs(newDocs) const validator = (file: File) => {
} const byteToMB = (bytes: number) =>
Math.round((bytes / 1024 / 1024) * 100) / 100
const validator = (file: File) => { // TODO: make this configurable
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100 const maxFileSize = 50000000
if (file.size > maxFileSize) {
return {
code: "file-too-big",
message:
"File is too big. Maximum file size is " +
byteToMB(maxFileSize) +
" MB."
}
}
// TODO: make this configurable // We initially try to use the browser provided mime type, and then fall back to file names and finally extensions
const maxFileSize = 50000000; if (
if (file.size > maxFileSize) { allowedFileTypes.includes(file.type) ||
return { allowedFileNames.includes(file.name) ||
code: 'file-too-big', allowedFileExtensions.includes(file.name?.split(".").pop() || "")
message: 'File is too big. Maximum file size is ' + byteToMB(maxFileSize) + ' MB.', ) {
} return null
} } else {
return {
code: "not-plain-text",
message: `Only plain text files are allowed.`
}
}
}
// We initially try to use the browser provided mime type, and then fall back to file names and finally extensions const { getRootProps, getInputProps, isDragActive, fileRejections } =
if (allowedFileTypes.includes(file.type) || allowedFileNames.includes(file.name) || allowedFileExtensions.includes(file.name?.split('.').pop() || '')) { useDropzone({ onDrop, validator })
return null
} else {
return {
code: "not-plain-text",
message: `Only plain text files are allowed.`
};
}
}
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, validator }) const fileRejectionItems = fileRejections.map(({ file, errors }) => (
<li key={file.name}>
{file.name}:
<ul>
{errors.map((e) => (
<li key={e.code}>
<Text>{e.message}</Text>
</li>
))}
</ul>
</li>
))
const fileRejectionItems = fileRejections.map(({ file, errors }) => ( return (
<li key={file.name}> <div className={styles.container}>
{file.name}: <div
<ul> {...getRootProps()}
{errors.map(e => ( className={styles.dropzone}
<li key={e.code}><Text>{e.message}</Text></li> style={{
))} borderColor: palette.accents_3
</ul> }}
</li> >
)); <input {...getInputProps()} />
{!isDragActive && (
return ( <Text p>Drag some files here, or click to select files</Text>
<div className={styles.container}> )}
<div {...getRootProps()} className={styles.dropzone} style={{ {isDragActive && <Text p>Release to drop the files here</Text>}
borderColor: palette.accents_3, </div>
}}> {fileRejections.length > 0 && (
<input {...getInputProps()} /> <ul className={styles.error}>
{!isDragActive && <Text p>Drag some files here, or click to select files</Text>} {/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
{isDragActive && <Text p>Release to drop the files here</Text>} <Text h5>There was a problem with one or more of your files.</Text>
</div> {fileRejectionItems}
{fileRejections.length > 0 && <ul className={styles.error}> </ul>
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */} )}
<Text h5>There was a problem with one or more of your files.</Text> </div>
{fileRejectionItems} )
</ul>}
</div>
)
} }
export default memo(FileDropzone) export default memo(FileDropzone)

View file

@ -1,278 +1,362 @@
import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core' import {
import { useRouter } from 'next/router'; Button,
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' useToasts,
import generateUUID from '@lib/generate-uuid'; ButtonDropdown,
import FileDropzone from './drag-and-drop'; Toggle,
import styles from './post.module.css' Input,
import Title from './title'; useClickAway
import Cookies from 'js-cookie' } from "@geist-ui/core"
import type { Post as PostType, PostVisibility, Document as DocumentType } from '@lib/types'; import { useRouter } from "next/router"
import PasswordModal from './password-modal'; import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import getPostPath from '@lib/get-post-path'; import generateUUID from "@lib/generate-uuid"
import EditDocumentList from '@components/edit-document-list'; import FileDropzone from "./drag-and-drop"
import { ChangeEvent } from 'react'; import styles from "./post.module.css"
import DatePicker from 'react-datepicker'; import Title from "./title"
import Cookies from "js-cookie"
import type {
Post as PostType,
PostVisibility,
Document as DocumentType
} from "@lib/types"
import PasswordModal from "./password-modal"
import getPostPath from "@lib/get-post-path"
import EditDocumentList from "@components/edit-document-list"
import { ChangeEvent } from "react"
import DatePicker from "react-datepicker"
const Post = ({ const Post = ({
initialPost, initialPost,
newPostParent newPostParent
}: { }: {
initialPost?: PostType, initialPost?: PostType
newPostParent?: string newPostParent?: string
}) => { }) => {
const { setToast } = useToasts() const { setToast } = useToasts()
const router = useRouter(); const router = useRouter()
const [title, setTitle] = useState<string>() const [title, setTitle] = useState<string>()
const [expiresAt, setExpiresAt] = useState<Date | null>(null) const [expiresAt, setExpiresAt] = useState<Date | null>(null)
const emptyDoc = useMemo(() => [{ const emptyDoc = useMemo(
title: '', () => [
content: '', {
id: generateUUID() title: "",
}], []) content: "",
id: generateUUID()
}
],
[]
)
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc) const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
// the /new/from/{id} route fetches an initial post // the /new/from/{id} route fetches an initial post
useEffect(() => { useEffect(() => {
if (initialPost) { if (initialPost) {
setTitle(`Copy of ${initialPost.title}`) setTitle(`Copy of ${initialPost.title}`)
setDocs(initialPost.files?.map(doc => ({ setDocs(
title: doc.title, initialPost.files?.map((doc) => ({
content: doc.content, title: doc.title,
id: doc.id content: doc.content,
})) || emptyDoc) id: doc.id
} })) || emptyDoc
}, [emptyDoc, initialPost]) )
}
}, [emptyDoc, initialPost])
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(async (url: string, data: const sendRequest = useCallback(
{ async (
expiresAt: Date | null, url: string,
visibility?: PostVisibility, data: {
title?: string, expiresAt: Date | null
files?: DocumentType[], visibility?: PostVisibility
password?: string, title?: string
userId: string, files?: DocumentType[]
parentId?: string password?: string
}) => { userId: string
parentId?: string
}
) => {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
body: JSON.stringify({
title,
files: docs,
...data
})
})
const res = await fetch(url, { if (res.ok) {
method: "POST", const json = await res.json()
headers: { router.push(getPostPath(json.visibility, json.id))
"Content-Type": "application/json", } else {
"Authorization": `Bearer ${Cookies.get('drift-token')}` const json = await res.json()
}, setToast({
body: JSON.stringify({ text: json.error.message || "Please fill out all fields",
title, type: "error"
files: docs, })
...data, setPasswordModalVisible(false)
}) setSubmitting(false)
}) }
},
[docs, router, setToast, title]
)
if (res.ok) { const [isSubmitting, setSubmitting] = useState(false)
const json = await res.json()
router.push(getPostPath(json.visibility, json.id))
} else {
const json = await res.json()
setToast({
text: json.error.message || 'Please fill out all fields',
type: 'error'
})
setPasswordModalVisible(false)
setSubmitting(false)
}
}, [docs, router, setToast, title]) const onSubmit = useCallback(
async (visibility: PostVisibility, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true)
return
}
const [isSubmitting, setSubmitting] = useState(false) setPasswordModalVisible(false)
const onSubmit = useCallback(async (visibility: PostVisibility, password?: string) => { setSubmitting(true)
if (visibility === 'protected' && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false) let hasErrored = false
setSubmitting(true) if (!title) {
setToast({
text: "Please fill out the post title",
type: "error"
})
hasErrored = true
}
let hasErrored = false if (!docs.length) {
setToast({
text: "Please add at least one document",
type: "error"
})
hasErrored = true
}
if (!title) { for (const doc of docs) {
setToast({ if (!doc.title) {
text: 'Please fill out the post title', setToast({
type: 'error' text: "Please fill out all the document titles",
}) type: "error"
hasErrored = true })
} hasErrored = true
}
}
if (!docs.length) { if (hasErrored) {
setToast({ setSubmitting(false)
text: 'Please add at least one document', return
type: 'error' }
})
hasErrored = true
}
for (const doc of docs) { await sendRequest("/server-api/posts/create", {
if (!doc.title) { title,
setToast({ files: docs,
text: 'Please fill out all the document titles', visibility,
type: 'error' password,
}) userId: Cookies.get("drift-userid") || "",
hasErrored = true expiresAt,
} parentId: newPostParent
} })
},
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
)
if (hasErrored) { const onClosePasswordModal = () => {
setSubmitting(false) setPasswordModalVisible(false)
return setSubmitting(false)
} }
await sendRequest('/server-api/posts/create', { const submitPassword = useCallback(
title, (password) => onSubmit("protected", password),
files: docs, [onSubmit]
visibility, )
password,
userId: Cookies.get('drift-userid') || '',
expiresAt,
parentId: newPostParent
})
}, [docs, expiresAt, newPostParent, sendRequest, setToast, title])
const onClosePasswordModal = () => { const onChangeExpiration = useCallback((date) => setExpiresAt(date), [])
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = useCallback((password) => onSubmit('protected', password), [onSubmit]) const onChangeTitle = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
},
[setTitle]
)
const onChangeExpiration = useCallback((date) => setExpiresAt(date), []) const updateDocTitle = useCallback(
(i: number) => (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
},
[setDocs]
)
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => { const updateDocContent = useCallback(
setTitle(e.target.value) (i: number) => (content: string) => {
}, [setTitle]) setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
},
[setDocs]
)
const removeDoc = useCallback(
(i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
},
[setDocs]
)
const updateDocTitle = useCallback((i: number) => (title: string) => { const uploadDocs = useCallback(
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, title } : doc)) (files: DocumentType[]) => {
}, [setDocs]) // if no title is set and the only document is empty,
const isFirstDocEmpty =
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle("Uploaded files")
}
}
const updateDocContent = useCallback((i: number) => (content: string) => { if (isFirstDocEmpty) setDocs(files)
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, content } : doc)) else setDocs((docs) => [...docs, ...files])
}, [setDocs]) },
[docs, title]
)
const removeDoc = useCallback((i: number) => () => { // pasted files
setDocs((docs) => docs.filter((_, index) => i !== index)) // const files = e.clipboardData.files as File[]
}, [setDocs]) // if (files.length) {
// const docs = Array.from(files).map((file) => ({
// title: file.name,
// content: '',
// id: generateUUID()
// }))
// }
const uploadDocs = useCallback((files: DocumentType[]) => { const onPaste = useCallback(
// if no title is set and the only document is empty, (e: any) => {
const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true) const pastedText = e.clipboardData.getData("text")
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle('Uploaded files')
}
}
if (isFirstDocEmpty) setDocs(files) if (pastedText) {
else setDocs((docs) => [...docs, ...files]) if (!title) {
}, [docs, title]) setTitle("Pasted text")
}
}
},
[title]
)
// pasted files const CustomTimeInput = ({
// const files = e.clipboardData.files as File[] date,
// if (files.length) { value,
// const docs = Array.from(files).map((file) => ({ onChange
// title: file.name, }: {
// content: '', date: Date
// id: generateUUID() value: string
// })) onChange: (date: string) => void
// } }) => (
<input
type="time"
value={value}
onChange={(e) => {
if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16))
}
}}
style={{
backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)"
}}
required
/>
)
const onPaste = useCallback((e: any) => { return (
const pastedText = (e.clipboardData).getData('text') <div style={{ paddingBottom: 150 }}>
<Title title={title} onChange={onChangeTitle} />
if (pastedText) { <FileDropzone setDocs={uploadDocs} />
if (!title) { <EditDocumentList
setTitle("Pasted text") onPaste={onPaste}
} docs={docs}
} updateDocTitle={updateDocTitle}
}, [title]) updateDocContent={updateDocContent}
removeDoc={removeDoc}
const CustomTimeInput = ({ date, value, onChange }: { />
date: Date, <div className={styles.buttons}>
value: string, <Button
onChange: (date: string) => void className={styles.button}
}) => ( onClick={() => {
<input setDocs([
type="time" ...docs,
value={value} {
onChange={(e) => { title: "",
if (!isNaN(date.getTime())) { content: "",
onChange(e.target.value || date.toISOString().slice(11, 16)) id: generateUUID()
} }
}} ])
style={{ }}
backgroundColor: 'var(--bg)', type="default"
border: '1px solid var(--light-gray)', >
borderRadius: 'var(--radius)' Add a File
}} </Button>
required <div className={styles.rightButtons}>
/> {
); <DatePicker
onChange={onChangeExpiration}
return ( customInput={
<div style={{ paddingBottom: 150 }}> <Input
<Title title={title} onChange={onChangeTitle} /> label="Expires at"
<FileDropzone setDocs={uploadDocs} /> clearable
<EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} /> width="100%"
<div className={styles.buttons}> height="40px"
<Button />
className={styles.button} }
onClick={() => { placeholderText="Won't expire"
setDocs([...docs, { selected={expiresAt}
title: '', showTimeInput={true}
content: '', // @ts-ignore
id: generateUUID() customTimeInput={<CustomTimeInput />}
}]) timeInputLabel="Time:"
}} dateFormat="MM/dd/yyyy h:mm aa"
type="default" className={styles.datePicker}
> clearButtonTitle={"Clear"}
Add a File // TODO: investigate why this causes margin shift if true
</Button> enableTabLoop={false}
<div className={styles.rightButtons}> minDate={new Date()}
{<DatePicker />
onChange={onChangeExpiration} }
customInput={<Input label="Expires at" clearable width="100%" height="40px" />} <ButtonDropdown loading={isSubmitting} type="success">
placeholderText="Won't expire" <ButtonDropdown.Item main onClick={() => onSubmit("private")}>
selected={expiresAt} Create Private
showTimeInput={true} </ButtonDropdown.Item>
// @ts-ignore <ButtonDropdown.Item onClick={() => onSubmit("public")}>
customTimeInput={<CustomTimeInput />} Create Public
timeInputLabel="Time:" </ButtonDropdown.Item>
dateFormat="MM/dd/yyyy h:mm aa" <ButtonDropdown.Item onClick={() => onSubmit("unlisted")}>
className={styles.datePicker} Create Unlisted
clearButtonTitle={"Clear"} </ButtonDropdown.Item>
// TODO: investigate why this causes margin shift if true <ButtonDropdown.Item onClick={() => onSubmit("protected")}>
enableTabLoop={false} Create with Password
minDate={new Date()} </ButtonDropdown.Item>
/>} </ButtonDropdown>
<ButtonDropdown loading={isSubmitting} type="success"> </div>
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item> </div>
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item> <PasswordModal
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item> creating={true}
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item> isOpen={passwordModalVisible}
</ButtonDropdown> onClose={onClosePasswordModal}
</div> onSubmit={submitPassword}
</div> />
<PasswordModal creating={true} isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} /> </div>
</div> )
)
} }
export default Post export default Post

View file

@ -1,54 +1,83 @@
import { Modal, Note, Spacer, Input } from "@geist-ui/core" import { Modal, Note, Spacer, Input } from "@geist-ui/core"
import { useState } from "react" import { useState } from "react"
type Props = { type Props = {
creating: boolean creating: boolean
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSubmit: (password: string) => void onSubmit: (password: string) => void
} }
const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creating }: Props) => { const PasswordModal = ({
const [password, setPassword] = useState<string>() isOpen,
const [confirmPassword, setConfirmPassword] = useState<string>() onClose,
const [error, setError] = useState<string>() onSubmit: onSubmitAfterVerify,
creating
}: Props) => {
const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>()
const [error, setError] = useState<string>()
const onSubmit = () => { const onSubmit = () => {
if (!password || (creating && !confirmPassword)) { if (!password || (creating && !confirmPassword)) {
setError('Please enter a password') setError("Please enter a password")
return return
} }
if (password !== confirmPassword && creating) { if (password !== confirmPassword && creating) {
setError("Passwords do not match") setError("Passwords do not match")
return return
} }
onSubmitAfterVerify(password) onSubmitAfterVerify(password)
} }
return (<> return (
{/* TODO: investigate disableBackdropClick not updating state? */} <>
{/* TODO: investigate disableBackdropClick not updating state? */}
{<Modal visible={isOpen} disableBackdropClick={true} > {
<Modal.Title>Enter a password</Modal.Title> <Modal visible={isOpen} disableBackdropClick={true}>
<Modal.Content> <Modal.Title>Enter a password</Modal.Title>
{!error && creating && <Note type="warning" label='Warning'> <Modal.Content>
This doesn&apos;t protect your post from the server administrator. {!error && creating && (
</Note>} <Note type="warning" label="Warning">
{error && <Note type="error" label='Error'> This doesn&apos;t protect your post from the server
{error} administrator.
</Note>} </Note>
<Spacer /> )}
<Input width={"100%"} label="Password" marginBottom={1} htmlType="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} /> {error && (
{creating && <Input width={"100%"} label="Confirm" htmlType="password" placeholder="Confirm Password" onChange={(e) => setConfirmPassword(e.target.value)} />} <Note type="error" label="Error">
</Modal.Content> {error}
<Modal.Action passive onClick={onClose}>Cancel</Modal.Action> </Note>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action> )}
</Modal>} <Spacer />
</>) <Input
width={"100%"}
label="Password"
marginBottom={1}
htmlType="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
{creating && (
<Input
width={"100%"}
label="Confirm"
htmlType="password"
placeholder="Confirm Password"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
)}
</Modal.Content>
<Modal.Action passive onClick={onClose}>
Cancel
</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>
}
</>
)
} }
export default PasswordModal
export default PasswordModal

View file

@ -1,45 +1,51 @@
import { ChangeEvent, memo, useEffect, useState } from 'react' import { ChangeEvent, memo, useEffect, useState } from "react"
import { Text } from '@geist-ui/core' import { Text } from "@geist-ui/core"
import ShiftBy from '@components/shift-by' import ShiftBy from "@components/shift-by"
import styles from '../post.module.css' import styles from "../post.module.css"
import { Input } from '@geist-ui/core' import { Input } from "@geist-ui/core"
const titlePlaceholders = [ const titlePlaceholders = [
"How to...", "How to...",
"Status update for ...", "Status update for ...",
"My new project", "My new project",
"My new idea", "My new idea",
"Let's talk about...", "Let's talk about...",
"What's up with ...", "What's up with ...",
"I'm thinking about ...", "I'm thinking about ..."
] ]
type props = { type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string title?: string
} }
const Title = ({ onChange, title }: props) => { const Title = ({ onChange, title }: props) => {
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0]) const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
useEffect(() => { useEffect(() => {
// set random placeholder on load // set random placeholder on load
setPlaceholder(titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]) setPlaceholder(
}, []) titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]
return (<div className={styles.title}> )
<Text h1 width={"150px"} className={styles.drift}>Drift</Text> }, [])
<ShiftBy y={-3}> return (
<Input <div className={styles.title}>
placeholder={placeholder} <Text h1 width={"150px"} className={styles.drift}>
value={title || ""} Drift
onChange={onChange} </Text>
height={"55px"} <ShiftBy y={-3}>
font={1.5} <Input
label="Post title" placeholder={placeholder}
style={{ width: "100%" }} value={title || ""}
/> onChange={onChange}
</ShiftBy> height={"55px"}
</div>) font={1.5}
label="Post title"
style={{ width: "100%" }}
/>
</ShiftBy>
</div>
)
} }
export default memo(Title) export default memo(Title)

View file

@ -1,27 +1,26 @@
import Head from "next/head"; import Head from "next/head"
import React from "react"; import React from "react"
type PageSeoProps = { type PageSeoProps = {
title?: string; title?: string
description?: string; description?: string
isLoading?: boolean; isLoading?: boolean
isPrivate?: boolean isPrivate?: boolean
}; }
const PageSeo = ({ const PageSeo = ({
title = 'Drift', title = "Drift",
description = "A self-hostable clone of GitHub Gist", description = "A self-hostable clone of GitHub Gist",
isPrivate = false isPrivate = false
}: PageSeoProps) => { }: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
)
}
return ( export default PageSeo
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
);
};
export default PageSeo;

View file

@ -1,8 +1,8 @@
import { Button, Code, Dot, Input, Note, Text } from "@geist-ui/core" import { Button, Code, Dot, Input, Note, Text } from "@geist-ui/core"
import NextLink from "next/link" 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"
import ListItemSkeleton from "./list-item-skeleton" import ListItemSkeleton from "./list-item-skeleton"
import ListItem from "./list-item" import ListItem from "./list-item"
import { Post } from "@lib/types" import { Post } from "@lib/types"
@ -11,128 +11,156 @@ import debounce from "lodash.debounce"
import Cookies from "js-cookie" import Cookies from "js-cookie"
type Props = { type Props = {
initialPosts: Post[] initialPosts: Post[]
error: boolean error: boolean
morePosts: boolean morePosts: boolean
} }
const PostList = ({ morePosts, initialPosts, error }: Props) => { const PostList = ({ morePosts, initialPosts, error }: Props) => {
const [search, setSearchValue] = useState('') const [search, setSearchValue] = useState("")
const [posts, setPosts] = useState<Post[]>(initialPosts) const [posts, setPosts] = useState<Post[]>(initialPosts)
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts) const [hasMorePosts, setHasMorePosts] = useState(morePosts)
const loadMoreClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { const loadMoreClick = useCallback(
e.preventDefault() (e: React.MouseEvent<HTMLButtonElement>) => {
if (hasMorePosts) { e.preventDefault()
async function fetchPosts() { if (hasMorePosts) {
const res = await fetch(`/server-api/posts/mine`, async function fetchPosts() {
{ const res = await fetch(`/server-api/posts/mine`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`, Authorization: `Bearer ${Cookies.get("drift-token")}`,
"x-page": `${posts.length / 10 + 1}`, "x-page": `${posts.length / 10 + 1}`
} }
} })
) const json = await res.json()
const json = await res.json() setPosts([...posts, ...json.posts])
setPosts([...posts, ...json.posts]) setHasMorePosts(json.morePosts)
setHasMorePosts(json.morePosts) }
} fetchPosts()
fetchPosts() }
} },
}, [posts, hasMorePosts]) [posts, hasMorePosts]
)
// update posts on search // update posts on search
useEffect(() => { useEffect(() => {
if (search) { if (search) {
// fetch results from /server-api/posts/search // fetch results from /server-api/posts/search
const fetchResults = async () => { const fetchResults = async () => {
setSearching(true) setSearching(true)
//encode search //encode search
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, { const res = await fetch(
method: "GET", `/server-api/posts/search?q=${encodeURIComponent(search)}`,
headers: { {
"Content-Type": "application/json", method: "GET",
"Authorization": `Bearer ${Cookies.get("drift-token")}`, headers: {
// "tok": process.env.SECRET_KEY || '' "Content-Type": "application/json",
} Authorization: `Bearer ${Cookies.get("drift-token")}`
}) // "tok": process.env.SECRET_KEY || ''
const data = await res.json() }
setPosts(data) }
setSearching(false) )
} const data = await res.json()
fetchResults() setPosts(data)
} else { setSearching(false)
setPosts(initialPosts) }
} fetchResults()
}, [initialPosts, search]) } else {
setPosts(initialPosts)
}
}, [initialPosts, search])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => { const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value) setSearchValue(e.target.value)
} }
const debouncedSearchHandler = useMemo( const debouncedSearchHandler = useMemo(
() => debounce(handleSearchChange, 300) () => debounce(handleSearchChange, 300),
, []); []
)
useEffect(() => { useEffect(() => {
return () => { return () => {
debouncedSearchHandler.cancel(); debouncedSearchHandler.cancel()
} }
}, [debouncedSearchHandler]); }, [debouncedSearchHandler])
const deletePost = useCallback((postId: string) => async () => { const deletePost = useCallback(
const res = await fetch(`/server-api/posts/${postId}`, { (postId: string) => async () => {
method: "DELETE", const res = await fetch(`/server-api/posts/${postId}`, {
headers: { method: "DELETE",
"Content-Type": "application/json", headers: {
"Authorization": `Bearer ${Cookies.get("drift-token")}` "Content-Type": "application/json",
}, Authorization: `Bearer ${Cookies.get("drift-token")}`
}) }
})
if (!res.ok) { if (!res.ok) {
console.error(res) console.error(res)
return return
} else { } else {
setPosts((posts) => posts.filter(post => post.id !== postId)) setPosts((posts) => posts.filter((post) => post.id !== postId))
} }
}, []) },
[]
)
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.searchContainer}> <div className={styles.searchContainer}>
<Input scale={3 / 2} <Input
clearable scale={3 / 2}
placeholder="Search..." clearable
onChange={debouncedSearchHandler} /> placeholder="Search..."
</div> onChange={debouncedSearchHandler}
{error && <Text type='error'>Failed to load.</Text>} />
{!posts.length && searching && <ul> </div>
<li> {error && <Text type="error">Failed to load.</Text>}
<ListItemSkeleton /> {!posts.length && searching && (
</li> <ul>
<li> <li>
<ListItemSkeleton /> <ListItemSkeleton />
</li> </li>
</ul>} <li>
{posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>} <ListItemSkeleton />
{ </li>
posts?.length > 0 && <div> </ul>
<ul> )}
{posts.map((post) => { {posts?.length === 0 && !error && (
return <ListItem deletePost={deletePost(post.id)} post={post} key={post.id} /> <Text type="secondary">
})} No posts found. Create one{" "}
</ul> <NextLink passHref={true} href="/new">
</div> <Link color>here</Link>
} </NextLink>
{hasMorePosts && !setSearchValue && <div className={styles.moreContainer}> .
<Button width={"100%"} onClick={loadMoreClick}> </Text>
Load more )}
</Button> {posts?.length > 0 && (
</div>} <div>
</div> <ul>
) {posts.map((post) => {
return (
<ListItem
deletePost={deletePost(post.id)}
post={post}
key={post.id}
/>
)
})}
</ul>
</div>
)}
{hasMorePosts && !setSearchValue && (
<div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>
)}
</div>
)
} }
export default PostList export default PostList

View file

@ -1,21 +1,27 @@
import Skeleton from "react-loading-skeleton"
import { Card, Divider, Grid, Spacer } from "@geist-ui/core"
const ListItemSkeleton = () => (
<Card>
<Spacer height={1 / 2} />
<Grid.Container justify={"space-between"} marginBottom={1 / 2}>
<Grid xs={8} paddingLeft={1 / 2}>
<Skeleton width={150} />
</Grid>
<Grid xs={7}>
<Skeleton width={100} />
</Grid>
<Grid xs={4}>
<Skeleton width={70} />
</Grid>
</Grid.Container>
import Skeleton from "react-loading-skeleton"; <Divider h="1px" my={0} />
import { Card, Divider, Grid, Spacer } from "@geist-ui/core";
const ListItemSkeleton = () => (<Card> <Card.Content>
<Spacer height={1 / 2} /> <Skeleton width={200} />
<Grid.Container justify={'space-between'} marginBottom={1 / 2}> </Card.Content>
<Grid xs={8} paddingLeft={1 / 2}><Skeleton width={150} /></Grid> </Card>
<Grid xs={7}><Skeleton width={100} /></Grid> )
<Grid xs={4}><Skeleton width={70} /></Grid>
</Grid.Container>
<Divider h="1px" my={0} /> export default ListItemSkeleton
<Card.Content >
<Skeleton width={200} />
</Card.Content>
</Card>)
export default ListItemSkeleton

View file

@ -1,8 +1,15 @@
import NextLink from "next/link" import NextLink from "next/link"
import VisibilityBadge from "../badges/visibility-badge" import VisibilityBadge from "../badges/visibility-badge"
import getPostPath from "@lib/get-post-path" import getPostPath from "@lib/get-post-path"
import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core" import {
Link,
Text,
Card,
Tooltip,
Divider,
Badge,
Button
} from "@geist-ui/core"
import { File, Post } from "@lib/types" import { File, Post } from "@lib/types"
import FadeIn from "@components/fade-in" import FadeIn from "@components/fade-in"
import Trash from "@geist-ui/icons/trash" import Trash from "@geist-ui/icons/trash"
@ -10,65 +17,99 @@ import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Edit from "@geist-ui/icons/edit" import Edit from "@geist-ui/icons/edit"
import { useRouter } from "next/router" import { useRouter } from "next/router"
import Parent from '@geist-ui/icons/arrowUpCircle' import Parent from "@geist-ui/icons/arrowUpCircle"
import styles from "./list-item.module.css" import styles from "./list-item.module.css"
// TODO: isOwner should default to false so this can be used generically // TODO: isOwner should default to false so this can be used generically
const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: boolean, deletePost: () => void }) => { const ListItem = ({
const router = useRouter() post,
isOwner = true,
deletePost
}: {
post: Post
isOwner?: boolean
deletePost: () => void
}) => {
const router = useRouter()
const editACopy = () => { const editACopy = () => {
router.push(`/new/from/${post.id}`) router.push(`/new/from/${post.id}`)
} }
return (<FadeIn><li key={post.id}> return (
<Card style={{ overflowY: 'scroll' }}> <FadeIn>
<Card.Body> <li key={post.id}>
<Text h3 className={styles.title}> <Card style={{ overflowY: "scroll" }}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}> <Card.Body>
<Link color marginRight={'var(--gap)'}> <Text h3 className={styles.title}>
{post.title} <NextLink
</Link> passHref={true}
</NextLink> href={getPostPath(post.visibility, post.id)}
{isOwner && <span className={styles.buttons}> >
{post.parent && <Tooltip text={"View parent"} hideArrow> <Link color marginRight={"var(--gap)"}>
<Button {post.title}
auto </Link>
icon={<Parent />} </NextLink>
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))} {isOwner && (
/> <span className={styles.buttons}>
</Tooltip>} {post.parent && (
<Tooltip text={"Make a copy"} hideArrow> <Tooltip text={"View parent"} hideArrow>
<Button <Button
auto auto
iconRight={<Edit />} icon={<Parent />}
onClick={editACopy} /> onClick={() =>
</Tooltip> router.push(
<Tooltip text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip> getPostPath(
</span>} post.parent!.visibility,
</Text> post.parent!.id
)
)
}
/>
</Tooltip>
)}
<Tooltip text={"Make a copy"} hideArrow>
<Button auto iconRight={<Edit />} onClick={editACopy} />
</Tooltip>
<Tooltip text={"Delete"} hideArrow>
<Button iconRight={<Trash />} onClick={deletePost} auto />
</Tooltip>
</span>
)}
</Text>
<div className={styles.badges}> <div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} /> <CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge> <Badge type="secondary">
<ExpirationBadge postExpirationDate={post.expiresAt} /> {post.files?.length === 1
</div> ? "1 file"
: `${post.files?.length || 0} files`}
</Card.Body> </Badge>
<Divider h="1px" my={0} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
<Card.Content> </div>
{post.files?.map((file: File) => { </Card.Body>
return <div key={file.id}> <Divider h="1px" my={0} />
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}> <Card.Content>
{file.title || 'Untitled file'} {post.files?.map((file: File) => {
</Link></div> return (
})} <div key={file.id}>
</Card.Content> <Link
color
</Card> href={`${getPostPath(post.visibility, post.id)}#${
file.title
</li> </FadeIn>) }`}
>
{file.title || "Untitled file"}
</Link>
</div>
)
})}
</Card.Content>
</Card>
</li>{" "}
</FadeIn>
)
} }
export default ListItem export default ListItem

View file

@ -1,15 +1,15 @@
import PageSeo from "@components/page-seo" import PageSeo from "@components/page-seo"
import VisibilityBadge from "@components/badges/visibility-badge" import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from '@components/view-document' import DocumentComponent from "@components/view-document"
import styles from './post-page.module.css' import styles from "./post-page.module.css"
import homeStyles from '@styles/Home.module.css' import homeStyles from "@styles/Home.module.css"
import type { File, Post } from "@lib/types" import type { File, Post } from "@lib/types"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core" import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Archive from '@geist-ui/icons/archive' import Archive from "@geist-ui/icons/archive"
import Edit from '@geist-ui/icons/edit' import Edit from "@geist-ui/icons/edit"
import Parent from '@geist-ui/icons/arrowUpCircle' import Parent from "@geist-ui/icons/arrowUpCircle"
import FileDropdown from "@components/file-dropdown" import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top" import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/router" import { useRouter } from "next/router"
@ -19,124 +19,144 @@ import Cookies from "js-cookie"
import getPostPath from "@lib/get-post-path" import getPostPath from "@lib/get-post-path"
type Props = { type Props = {
post: Post post: Post
} }
const PostPage = ({ post }: Props) => { const PostPage = ({ post }: Props) => {
const router = useRouter() const router = useRouter()
const isMobile = useMediaQuery("mobile") const isMobile = useMediaQuery("mobile")
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null) const [isExpired, setIsExpired] = useState(
const [isLoading, setIsLoading] = useState(true) post.expiresAt ? new Date(post.expiresAt) < new Date() : null
useEffect(() => { )
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false const [isLoading, setIsLoading] = useState(true)
if (!isOwner && isExpired) { useEffect(() => {
router.push("/expired") const isOwner = post.users
} ? post.users[0].id === Cookies.get("drift-userid")
: false
if (!isOwner && isExpired) {
router.push("/expired")
}
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "") const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (!isOwner && expirationDate < new Date()) { if (!isOwner && expirationDate < new Date()) {
router.push("/expired") router.push("/expired")
} else { } else {
setIsLoading(false) setIsLoading(false)
} }
let interval: NodeJS.Timer | null = null; let interval: NodeJS.Timer | null = null
if (post.expiresAt) { if (post.expiresAt) {
interval = setInterval(() => { interval = setInterval(() => {
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "") const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
setIsExpired(expirationDate < new Date()) setIsExpired(expirationDate < new Date())
}, 4000) }, 4000)
} }
return () => { return () => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval)
} }
}, [isExpired, post.expiresAt, post.users, router]) }, [isExpired, post.expiresAt, post.users, router])
const download = async () => {
if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(
post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
const download = async () => { const editACopy = () => {
if (!post.files) return router.push(`/new/from/${post.id}`)
const downloadZip = (await import("client-zip")).downloadZip }
const blob = await downloadZip(post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
const editACopy = () => { if (isLoading) {
router.push(`/new/from/${post.id}`) return <></>
} }
if (isLoading) { return (
return <></> <Page width={"100%"}>
} <PageSeo
title={`${post.title} - Drift`}
description={post.description}
isPrivate={false}
/>
return ( <Page.Content className={homeStyles.main}>
<Page width={"100%"}> <div className={styles.header}>
<PageSeo <span className={styles.buttons}>
title={`${post.title} - Drift`} <ButtonGroup
description={post.description} vertical={isMobile}
isPrivate={false} marginLeft={0}
/> marginRight={0}
marginTop={1}
<Page.Content className={homeStyles.main}> marginBottom={1}
<div className={styles.header}> >
<span className={styles.buttons}> <Button
<ButtonGroup vertical={isMobile} marginLeft={0} marginRight={0} marginTop={1} marginBottom={1}> auto
<Button icon={<Edit />}
auto onClick={editACopy}
icon={<Edit />} style={{ textTransform: "none" }}
onClick={editACopy} >
style={{ textTransform: 'none' }}> Edit a Copy
Edit a Copy </Button>
</Button> {post.parent && (
{post.parent && <Button <Button
auto auto
icon={<Parent />} icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))} onClick={() =>
> router.push(
View Parent getPostPath(post.parent!.visibility, post.parent!.id)
</Button>} )
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}> }
Download as ZIP Archive >
</Button> View Parent
<FileDropdown isMobile={isMobile} files={post.files || []} /> </Button>
</ButtonGroup> )}
</span> <Button
<span className={styles.title}> auto
<Text h3>{post.title}</Text> onClick={download}
<span className={styles.badges}> icon={<Archive />}
<VisibilityBadge visibility={post.visibility} /> style={{ textTransform: "none" }}
<CreatedAgoBadge createdAt={post.createdAt} /> >
<ExpirationBadge postExpirationDate={post.expiresAt} /> Download as ZIP Archive
</span> </Button>
</span> <FileDropdown isMobile={isMobile} files={post.files || []} />
</ButtonGroup>
</span>
</div> <span className={styles.title}>
{/* {post.files.length > 1 && <FileTree files={post.files} />} */} <Text h3>{post.title}</Text>
{post.files?.map(({ id, content, title }: File) => ( <span className={styles.badges}>
<DocumentComponent <VisibilityBadge visibility={post.visibility} />
key={id} <CreatedAgoBadge createdAt={post.createdAt} />
title={title} <ExpirationBadge postExpirationDate={post.expiresAt} />
initialTab={'preview'} </span>
id={id} </span>
content={content} </div>
/> {/* {post.files.length > 1 && <FileTree files={post.files} />} */}
))} {post.files?.map(({ id, content, title }: File) => (
<ScrollToTop /> <DocumentComponent
key={id}
</Page.Content> title={title}
</Page > initialTab={"preview"}
) id={id}
content={content}
/>
))}
<ScrollToTop />
</Page.Content>
</Page>
)
} }
export default PostPage export default PostPage

View file

@ -1,58 +1,67 @@
import Cookies from "js-cookie" import Cookies from "js-cookie"
import { memo, useEffect, useState } from "react" import { memo, useEffect, useState } from "react"
import styles from './preview.module.css' import styles from "./preview.module.css"
type Props = { type Props = {
height?: number | string height?: number | string
fileId?: string fileId?: string
content?: string content?: string
title?: string title?: string
// file extensions we can highlight // file extensions we can highlight
} }
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => { const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [preview, setPreview] = useState<string>(content || "") const [preview, setPreview] = useState<string>(content || "")
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => { useEffect(() => {
async function fetchPost() { async function fetchPost() {
if (fileId) { if (fileId) {
const resp = await fetch(`/api/html/${fileId}`, { const resp = await fetch(`/api/html/${fileId}`, {
method: "GET", method: "GET"
}) })
if (resp.ok) { if (resp.ok) {
const res = await resp.text() const res = await resp.text()
setPreview(res) setPreview(res)
setIsLoading(false) setIsLoading(false)
} }
} else if (content) { } else if (content) {
const resp = await fetch("/server-api/files/html", { const resp = await fetch("/server-api/files/html", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token") || ""}`, Authorization: `Bearer ${Cookies.get("drift-token") || ""}`
}, },
body: JSON.stringify({ body: JSON.stringify({
title, title,
content, content
}), })
}) })
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
}
setIsLoading(false)
}
fetchPost()
}, [content, fileId, title])
return (<>
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
height
}} />}
</>)
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
}
setIsLoading(false)
}
fetchPost()
}, [content, fileId, title])
return (
<>
{isLoading ? (
<div>Loading...</div>
) : (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}}
/>
)}
</>
)
} }
export default memo(MarkdownPreview) export default memo(MarkdownPreview)

View file

@ -1,37 +1,58 @@
import { Tooltip, Button, Spacer } from '@geist-ui/core' import { Tooltip, Button, Spacer } from "@geist-ui/core"
import ChevronUp from '@geist-ui/icons/chevronUpCircleFill' import ChevronUp from "@geist-ui/icons/chevronUpCircleFill"
import { useEffect, useState } from 'react' import { useEffect, useState } from "react"
import styles from './scroll.module.css' import styles from "./scroll.module.css"
const ScrollToTop = () => { const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false) const [shouldShow, setShouldShow] = useState(false)
useEffect(() => { useEffect(() => {
// if user is scrolled, set visible // if user is scrolled, set visible
const handleScroll = () => { const handleScroll = () => {
setShouldShow(window.scrollY > 100) setShouldShow(window.scrollY > 100)
} }
window.addEventListener('scroll', handleScroll) window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener("scroll", handleScroll)
}, []) }, [])
const isReducedMotion = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false const isReducedMotion =
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => { typeof window !== "undefined"
e.currentTarget.blur() ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? 'auto' : 'smooth' }) window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
}
} return (
<div
return ( style={{
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}> display: "flex",
<Tooltip hideArrow text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}> flexDirection: "row",
<Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto > width: "100%",
<Spacer height={2 / 3} inline width={0} /> height: 24,
<ChevronUp /> justifyContent: "flex-end"
</Button> }}
</Tooltip> >
</div> <Tooltip
) hideArrow
text="Scroll to Top"
className={`${styles["scroll-up"]} ${
shouldShow ? styles["scroll-up-shown"] : ""
}`}
>
<Button
aria-label="Scroll to Top"
onClick={onClick}
style={{ background: "var(--light-gray)" }}
auto
>
<Spacer height={2 / 3} inline width={0} />
<ChevronUp />
</Button>
</Tooltip>
</div>
)
} }
export default ScrollToTop export default ScrollToTop

View file

@ -1,20 +1,20 @@
// https://www.joshwcomeau.com/snippets/react-components/shift-by/ // https://www.joshwcomeau.com/snippets/react-components/shift-by/
type Props = { type Props = {
x?: number x?: number
y?: number y?: number
children: React.ReactNode children: React.ReactNode
} }
function ShiftBy({ x = 0, y = 0, children }: Props) { function ShiftBy({ x = 0, y = 0, children }: Props) {
return ( return (
<div <div
style={{ style={{
transform: `translate(${x}px, ${y}px)`, transform: `translate(${x}px, ${y}px)`,
display: 'inline-block' display: "inline-block"
}} }}
> >
{children} {children}
</div> </div>
) )
} }
export default ShiftBy export default ShiftBy

View file

@ -1,125 +1,169 @@
import { memo, useRef, useState } from "react" import { memo, useRef, useState } from "react"
import styles from './document.module.css' import styles from "./document.module.css"
import Download from '@geist-ui/icons/download' import Download from "@geist-ui/icons/download"
import ExternalLink from '@geist-ui/icons/externalLink' import ExternalLink from "@geist-ui/icons/externalLink"
import Skeleton from "react-loading-skeleton" import Skeleton from "react-loading-skeleton"
import { Button, Text, ButtonGroup, Spacer, Tabs, Textarea, Tooltip, Link, Tag } from "@geist-ui/core" import {
Button,
Text,
ButtonGroup,
Spacer,
Tabs,
Textarea,
Tooltip,
Link,
Tag
} from "@geist-ui/core"
import HtmlPreview from "@components/preview" import HtmlPreview from "@components/preview"
import FadeIn from "@components/fade-in" import FadeIn from "@components/fade-in"
// import Link from "next/link" // import Link from "next/link"
type Props = { type Props = {
title: string title: string
initialTab?: "edit" | "preview" initialTab?: "edit" | "preview"
skeleton?: boolean skeleton?: boolean
id: string id: string
content: string content: string
} }
const DownloadButton = ({ rawLink }: { rawLink?: string }) => { const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}> return (
<ButtonGroup className={styles.actions}> <div className={styles.actionWrapper}>
<Tooltip hideArrow text="Download"> <ButtonGroup className={styles.actions}>
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer"> <Tooltip hideArrow text="Download">
<Button <a
scale={2 / 3} px={0.6} href={`${rawLink}?download=true`}
icon={<Download />} target="_blank"
auto rel="noopener noreferrer"
aria-label="Download" >
/> <Button
</a> scale={2 / 3}
</Tooltip> px={0.6}
<Tooltip hideArrow text="Open raw in new tab"> icon={<Download />}
<a href={rawLink} target="_blank" rel="noopener noreferrer"> auto
<Button aria-label="Download"
scale={2 / 3} px={0.6} />
icon={<ExternalLink />} </a>
auto </Tooltip>
aria-label="Open raw file in new tab" <Tooltip hideArrow text="Open raw in new tab">
/> <a href={rawLink} target="_blank" rel="noopener noreferrer">
</a> <Button
</Tooltip> scale={2 / 3}
</ButtonGroup> px={0.6}
</div>) icon={<ExternalLink />}
auto
aria-label="Open raw file in new tab"
/>
</a>
</Tooltip>
</ButtonGroup>
</div>
)
} }
const Document = ({
content,
title,
initialTab = "edit",
skeleton,
id
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%"
const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props) => { const handleTabChange = (newTab: string) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null) if (newTab === "edit") {
const [tab, setTab] = useState(initialTab) codeEditorRef.current?.focus()
// const height = editable ? "500px" : '100%' }
const height = "100%"; setTab(newTab as "edit" | "preview")
}
const handleTabChange = (newTab: string) => { const rawLink = () => {
if (newTab === 'edit') { if (id) {
codeEditorRef.current?.focus() return `/file/raw/${id}`
} }
setTab(newTab as 'edit' | 'preview') }
}
const rawLink = () => { if (skeleton) {
if (id) { return (
return `/file/raw/${id}` <>
} <Spacer height={1} />
} <div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
</div>
<div className={styles.descriptionContainer}>
<div style={{ flexDirection: "row", display: "flex" }}>
<Skeleton width={125} height={36} />
</div>
<Skeleton width={"100%"} height={350} />
</div>
</div>
</>
)
}
if (skeleton) { return (
return <> <FadeIn>
<Spacer height={1} /> <Spacer height={1} />
<div className={styles.card}> <div className={styles.card}>
<div className={styles.fileNameContainer}> <Link href={`#${title}`} className={styles.fileNameContainer}>
<Skeleton width={275} height={36} /> <Tag
</div> height={"100%"}
<div className={styles.descriptionContainer}> id={`${title}`}
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div> width={"100%"}
<Skeleton width={'100%'} height={350} /> style={{ borderRadius: 0 }}
</div > >
</div> {title || "Untitled"}
</> </Tag>
} </Link>
<div className={styles.descriptionContainer}>
<DownloadButton rawLink={rawLink()} />
return ( <Tabs
<FadeIn> onChange={handleTabChange}
<Spacer height={1} /> initialValue={initialTab}
<div className={styles.card}> hideDivider
<Link href={`#${title}`} className={styles.fileNameContainer}> leftSpace={0}
<Tag height={"100%"} id={`${title}`} width={"100%"} style={{ borderRadius: 0 }}> >
{title || 'Untitled'} <Tabs.Item label={"Raw"} value="edit">
</Tag> {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
</Link> <div
<div className={styles.descriptionContainer}> style={{
<DownloadButton rawLink={rawLink()} /> marginTop: "var(--gap-half)",
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> display: "flex",
<Tabs.Item label={"Raw"} value="edit"> flexDirection: "column"
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} }}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> >
<Textarea <Textarea
readOnly readOnly
ref={codeEditorRef} ref={codeEditorRef}
value={content} value={content}
width="100%" width="100%"
// TODO: Textarea should grow to fill parent if height == 100% // TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }} style={{ flex: 1, minHeight: 350 }}
resize="vertical" resize="vertical"
className={styles.textarea} className={styles.textarea}
/> />
</div> </div>
</Tabs.Item> </Tabs.Item>
<Tabs.Item label="Preview" value="preview"> <Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}> <div style={{ marginTop: "var(--gap-half)" }}>
<HtmlPreview height={height} fileId={id} content={content} title={title} /> <HtmlPreview
</div> height={height}
</Tabs.Item> fileId={id}
</Tabs> content={content}
</div> title={title}
</div> />
</FadeIn> </div>
) </Tabs.Item>
</Tabs>
</div>
</div>
</FadeIn>
)
} }
export default memo(Document)
export default memo(Document)

View file

@ -1,74 +1,74 @@
{ {
"name": "drift", "name": "drift",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3001", "dev": "next dev --port 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint && prettier --config .prettierrc '{components,lib,pages}/**/*.ts' --write", "lint": "next lint && prettier --list-different --config .prettierrc '{components,lib,pages}/**/*.{ts,tsx}' --write",
"analyze": "cross-env ANALYZE=true next build", "analyze": "cross-env ANALYZE=true next build",
"find:unused": "next-unused" "find:unused": "next-unused"
}, },
"dependencies": { "dependencies": {
"@geist-ui/core": "^2.3.5", "@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1", "@geist-ui/icons": "^1.0.1",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/js-cookie": "^3.0.1", "@types/js-cookie": "^3.0.1",
"client-zip": "^2.0.0", "client-zip": "^2.0.0",
"cookie": "^0.4.2", "cookie": "^0.4.2",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"marked": "^4.0.12", "marked": "^4.0.12",
"next": "^12.1.1-canary.15", "next": "^12.1.1-canary.15",
"next-themes": "^0.1.1", "next-themes": "^0.1.1",
"postcss": "^8.4.12", "postcss": "^8.4.12",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2", "postcss-hover-media-feature": "^1.0.2",
"postcss-preset-env": "^7.4.3", "postcss-preset-env": "^7.4.3",
"preact": "^10.6.6", "preact": "^10.6.6",
"prism-react-renderer": "^1.3.1", "prism-react-renderer": "^1.3.1",
"react": "17.0.2", "react": "17.0.2",
"react-datepicker": "^4.7.0", "react-datepicker": "^4.7.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-dropzone": "^12.0.4", "react-dropzone": "^12.0.4",
"react-loading-skeleton": "^3.0.3", "react-loading-skeleton": "^3.0.3",
"react-markdown": "^8.0.0", "react-markdown": "^8.0.0",
"react-syntax-highlighter": "^15.4.5", "react-syntax-highlighter": "^15.4.5",
"rehype-autolink-headings": "^6.1.1", "rehype-autolink-headings": "^6.1.1",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"swr": "^1.2.2" "swr": "^1.2.2"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "^12.1.0", "@next/bundle-analyzer": "^12.1.0",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/marked": "^4.0.3", "@types/marked": "^4.0.3",
"@types/node": "17.0.21", "@types/node": "17.0.21",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/react": "17.0.39", "@types/react": "17.0.39",
"@types/react-datepicker": "^4.3.4", "@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.14", "@types/react-dom": "^17.0.14",
"@types/react-syntax-highlighter": "^13.5.2", "@types/react-syntax-highlighter": "^13.5.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "8.10.0", "eslint": "8.10.0",
"eslint-config-next": "^12.1.1-canary.16", "eslint-config-next": "^12.1.1-canary.16",
"next-unused": "^0.0.6", "next-unused": "^0.0.6",
"prettier": "^2.6.0", "prettier": "^2.6.0",
"typescript": "4.6.2", "typescript": "4.6.2",
"typescript-plugin-css-modules": "^3.4.0" "typescript-plugin-css-modules": "^3.4.0"
}, },
"next-unused": { "next-unused": {
"alias": { "alias": {
"@components": "components/", "@components": "components/",
"@lib": "lib/", "@lib": "lib/",
"@styles": "styles/" "@styles": "styles/"
}, },
"include": [ "include": [
"components", "components",
"lib" "lib"
] ]
} }
} }

View file

@ -1,38 +1,58 @@
import '@styles/globals.css' import "@styles/globals.css"
import type { AppProps as NextAppProps } from "next/app"; import type { AppProps as NextAppProps } from "next/app"
import 'react-loading-skeleton/dist/skeleton.css' import "react-loading-skeleton/dist/skeleton.css"
import Head from 'next/head'; import Head from "next/head"
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from "next-themes"
import App from '@components/app'; import App from "@components/app"
type AppProps<P = any> = { type AppProps<P = any> = {
pageProps: P; pageProps: P
} & Omit<NextAppProps<P>, "pageProps">; } & Omit<NextAppProps<P>, "pageProps">
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<div> <div>
<Head> <Head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <meta
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" /> name="viewport"
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" /> content="width=device-width, initial-scale=1, shrink-to-fit=no"
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" /> />
<link rel="manifest" href="/site.webmanifest" /> <link
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5" /> rel="apple-touch-icon"
<meta name="apple-mobile-web-app-title" content="Drift" /> sizes="180x180"
<meta name="application-name" content="Drift" /> href="/assets/apple-touch-icon.png"
<meta name="msapplication-TileColor" content="#da532c" /> />
<meta name="theme-color" content="#ffffff" /> <link
<title>Drift</title> rel="icon"
</Head> type="image/png"
<ThemeProvider defaultTheme="system" disableTransitionOnChange> sizes="32x32"
<App Component={Component} pageProps={pageProps} /> href="/assets/favicon-32x32.png"
</ThemeProvider> />
</div> <link
) rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link
rel="mask-icon"
href="/assets/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="apple-mobile-web-app-title" content="Drift" />
<meta name="application-name" content="Drift" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<title>Drift</title>
</Head>
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
<App Component={Component} pageProps={pageProps} />
</ThemeProvider>
</div>
)
} }
export default MyApp export default MyApp

View file

@ -1,31 +1,39 @@
import { CssBaseline } from '@geist-ui/core' import { CssBaseline } from "@geist-ui/core"
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document' import Document, {
Html,
Head,
Main,
NextScript,
DocumentContext
} from "next/document"
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) { static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx) const initialProps = await Document.getInitialProps(ctx)
const styles = CssBaseline.flush() const styles = CssBaseline.flush()
return { return {
...initialProps, ...initialProps,
styles: ( styles: (
<> <>
{initialProps.styles} {initialProps.styles}
{styles} {styles}
</> </>
) )
} }
} }
render() { render() {
return (<Html lang="en"> return (
<Head /> <Html lang="en">
<body> <Head />
<Main /> <body>
<NextScript /> <Main />
</body> <NextScript />
</Html>) </body>
} </Html>
)
}
} }
export default MyDocument export default MyDocument

View file

@ -1,17 +1,12 @@
import ErrorComponent from '@components/error' import ErrorComponent from "@components/error"
function Error({ statusCode }: { function Error({ statusCode }: { statusCode: number }) {
statusCode: number return <ErrorComponent status={statusCode} />
}) {
return <ErrorComponent status={statusCode} />
} }
Error.getInitialProps = ({ res, err }: { Error.getInitialProps = ({ res, err }: { res: any; err: any }) => {
res: any const statusCode = res ? res.statusCode : err ? err.statusCode : 404
err: any return { statusCode }
}) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode }
} }
export default Error export default Error

View file

@ -1,53 +1,55 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server' import { NextFetchEvent, NextRequest, NextResponse } from "next/server"
const PUBLIC_FILE = /\.(.*)$/ const PUBLIC_FILE = /\.(.*)$/
export function middleware(req: NextRequest, event: NextFetchEvent) { export function middleware(req: NextRequest, event: NextFetchEvent) {
const pathname = req.nextUrl.pathname const pathname = req.nextUrl.pathname
const signedIn = req.cookies['drift-token'] const signedIn = req.cookies["drift-token"]
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
const isPageRequest = const isPageRequest =
!PUBLIC_FILE.test(pathname) && !PUBLIC_FILE.test(pathname) &&
!pathname.startsWith('/api') && !pathname.startsWith("/api") &&
// header added when next/link pre-fetches a route // header added when next/link pre-fetches a route
!req.headers.get('x-middleware-preflight') !req.headers.get("x-middleware-preflight")
if (!req.headers.get('x-middleware-preflight') && pathname === '/signout') { if (!req.headers.get("x-middleware-preflight") && pathname === "/signout") {
// If you're signed in we remove the cookie and redirect to the home page // If you're signed in we remove the cookie and redirect to the home page
// If you're not signed in we redirect to the home page // If you're not signed in we redirect to the home page
if (signedIn) { if (signedIn) {
const resp = NextResponse.redirect(getURL('')); const resp = NextResponse.redirect(getURL(""))
resp.clearCookie('drift-token'); resp.clearCookie("drift-token")
resp.clearCookie('drift-userid'); resp.clearCookie("drift-userid")
const signoutPromise = new Promise((resolve) => { const signoutPromise = new Promise((resolve) => {
fetch(`${process.env.API_URL}/auth/signout`, { fetch(`${process.env.API_URL}/auth/signout`, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${signedIn}`, Authorization: `Bearer ${signedIn}`,
'x-secret-key': process.env.SECRET_KEY || '', "x-secret-key": process.env.SECRET_KEY || ""
}, }
}) }).then(() => {
.then(() => { resolve(true)
resolve(true) })
}) })
}) event.waitUntil(signoutPromise)
event.waitUntil(signoutPromise)
return resp return resp
} }
} else if (isPageRequest) { } else if (isPageRequest) {
if (signedIn) { if (signedIn) {
if (pathname === '/' || pathname === '/signin' || pathname === '/signup') { if (
return NextResponse.redirect(getURL('new')) pathname === "/" ||
} pathname === "/signin" ||
} else if (!signedIn) { pathname === "/signup"
if (pathname === '/new') { ) {
return NextResponse.redirect(getURL('signin')) return NextResponse.redirect(getURL("new"))
} }
} } else if (!signedIn) {
if (pathname === "/new") {
return NextResponse.redirect(getURL("signin"))
}
}
}
} return NextResponse.next()
return NextResponse.next()
} }

View file

@ -1,55 +1,55 @@
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
import Header from '@components/header' import Header from "@components/header"
import { Page } from '@geist-ui/core'; import { Page } from "@geist-ui/core"
import { useEffect } from 'react'; import { useEffect } from "react"
import Admin from '@components/admin'; import Admin from "@components/admin"
import useSignedIn from '@lib/hooks/use-signed-in'; import useSignedIn from "@lib/hooks/use-signed-in"
import { useRouter } from 'next/router'; import { useRouter } from "next/router"
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from "next"
import cookie from "cookie"; import cookie from "cookie"
const AdminPage = () => { const AdminPage = () => {
const { signedIn } = useSignedIn() const { signedIn } = useSignedIn()
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === "undefined") return
if (!signedIn) { if (!signedIn) {
router.push('/') router.push("/")
} }
}, [router, signedIn]) }, [router, signedIn])
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<Admin /> <Admin />
</Page.Content> </Page.Content>
</Page> </Page>
) )
} }
export const getServerSideProps: GetServerSideProps = async ({ req }) => { export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`] const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
const res = await fetch(`${process.env.API_URL}/admin/is-admin`, { const res = await fetch(`${process.env.API_URL}/admin/is-admin`, {
headers: { headers: {
'Authorization': `Bearer ${driftToken}`, Authorization: `Bearer ${driftToken}`,
'x-secret-key': process.env.SECRET_KEY || '' "x-secret-key": process.env.SECRET_KEY || ""
} }
}) })
if (res.ok) { if (res.ok) {
return { return {
props: { props: {
signedIn: true signedIn: true
} }
} }
} else { } else {
return { return {
redirect: { redirect: {
destination: '/', destination: "/",
permanent: false permanent: false
} }
} }
} }
} }
export default AdminPage export default AdminPage

View file

@ -1,18 +1,19 @@
import Header from "@components/header" import Header from "@components/header"
import { Note, Page, Text } from "@geist-ui/core" import { Note, Page, Text } from "@geist-ui/core"
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
const Expired = () => { const Expired = () => {
return ( return (
<Page> <Page>
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<Note type="error" label={false}> <Note type="error" label={false}>
<Text h4>Error: The Drift you&apos;re trying to view has expired.</Text> <Text h4>
</Note> Error: The Drift you&apos;re trying to view has expired.
</Text>
</Page.Content> </Note>
</Page> </Page.Content>
) </Page>
)
} }
export default Expired export default Expired

View file

@ -1,60 +1,65 @@
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
import PageSeo from '@components/page-seo' import PageSeo from "@components/page-seo"
import HomeComponent from '@components/home' import HomeComponent from "@components/home"
import { Page, Text } from '@geist-ui/core' import { Page, Text } from "@geist-ui/core"
import { GetServerSideProps } from 'next' import { GetServerSideProps } from "next"
export const getServerSideProps: GetServerSideProps = async ({ res }) => { export const getServerSideProps: GetServerSideProps = async ({ res }) => {
try { try {
const resp = await fetch(process.env.API_URL + `/welcome`, const resp = await fetch(process.env.API_URL + `/welcome`, {
{ method: "GET",
method: "GET", headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", "x-secret-key": process.env.SECRET_KEY || ""
"x-secret-key": process.env.SECRET_KEY || '' }
} })
})
const { title, content, rendered } = await resp.json() const { title, content, rendered } = await resp.json()
res.setHeader( res.setHeader(
'Cache-Control', "Cache-Control",
`public, s-maxage=${60 * 60 * 24 * 360}, max-age=${60 * 60 * 24 * 360}` `public, s-maxage=${60 * 60 * 24 * 360}, max-age=${60 * 60 * 24 * 360}`
) )
return { return {
props: { props: {
introContent: content || null, introContent: content || null,
rendered: rendered || null, rendered: rendered || null,
introTitle: title || null, introTitle: title || null
} }
} }
} catch (error) { } catch (error) {
return { return {
props: { props: {
error: true error: true
} }
} }
} }
} }
type Props = { type Props = {
introContent: string introContent: string
introTitle: string introTitle: string
rendered: string rendered: string
error?: boolean error?: boolean
} }
const Home = ({ rendered, introContent, introTitle, error }: Props) => { const Home = ({ rendered, introContent, introTitle, error }: Props) => {
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<PageSeo /> <PageSeo />
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
{error && <Text>Something went wrong. Is the server running?</Text>} {error && <Text>Something went wrong. Is the server running?</Text>}
{!error && <HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />} {!error && (
</Page.Content> <HomeComponent
</Page> rendered={rendered}
) introContent={introContent}
introTitle={introTitle}
/>
)}
</Page.Content>
</Page>
)
} }
export default Home export default Home

View file

@ -1,59 +1,67 @@
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
import Header from '@components/header' import Header from "@components/header"
import MyPosts from '@components/my-posts' import MyPosts from "@components/my-posts"
import cookie from "cookie"; import cookie from "cookie"
import type { GetServerSideProps } from 'next'; import type { GetServerSideProps } from "next"
import { Post } from '@lib/types'; import { Post } from "@lib/types"
import { Page } from '@geist-ui/core'; import { Page } from "@geist-ui/core"
const Home = ({ morePosts, posts, error }: { morePosts: boolean, posts: Post[]; error: boolean; }) => { const Home = ({
return ( morePosts,
<Page className={styles.wrapper}> posts,
<Page.Content className={styles.main}> error
<MyPosts morePosts={morePosts} error={error} posts={posts} /> }: {
</Page.Content> morePosts: boolean
</Page > posts: Post[]
) error: boolean
}) => {
return (
<Page className={styles.wrapper}>
<Page.Content className={styles.main}>
<MyPosts morePosts={morePosts} error={error} posts={posts} />
</Page.Content>
</Page>
)
} }
// get server side props // get server side props
export const getServerSideProps: GetServerSideProps = async ({ req }) => { export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`] const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
if (!driftToken) { if (!driftToken) {
return { return {
redirect: { redirect: {
destination: '/', destination: "/",
permanent: false, permanent: false
} }
} }
} }
const posts = await fetch(process.env.API_URL + `/posts/mine`, { const posts = await fetch(process.env.API_URL + `/posts/mine`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`, Authorization: `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || '' "x-secret-key": process.env.SECRET_KEY || ""
} }
}) })
if (!posts.ok) { if (!posts.ok) {
return { return {
redirect: { redirect: {
destination: '/', destination: "/",
permanent: false, permanent: false
} }
} }
} }
const data = await posts.json() const data = await posts.json()
return { return {
props: { props: {
posts: data.posts, posts: data.posts,
error: posts.status !== 200, error: posts.status !== 200,
morePosts: data.hasMore, morePosts: data.hasMore
} }
} }
} }
export default Home export default Home

View file

@ -1,76 +1,78 @@
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
import NewPost from '@components/new-post' import NewPost from "@components/new-post"
import Header from '@components/header' import Header from "@components/header"
import PageSeo from '@components/page-seo' import PageSeo from "@components/page-seo"
import { Page } from '@geist-ui/core' import { Page } from "@geist-ui/core"
import Head from 'next/head' import Head from "next/head"
import { GetServerSideProps } from 'next' import { GetServerSideProps } from "next"
import { Post } from '@lib/types' import { Post } from "@lib/types"
import cookie from 'cookie' import cookie from "cookie"
const NewFromExisting = ({ const NewFromExisting = ({
post, post,
parentId parentId
}: { }: {
post: Post, post: Post
parentId: string parentId: string
}) => { }) => {
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<PageSeo title="Create a new Drift" /> <PageSeo title="Create a new Drift" />
<Head> <Head>
{/* TODO: solve this. */} {/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */} {/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/css/react-datepicker.css" /> <link rel="stylesheet" href="/css/react-datepicker.css" />
</Head> </Head>
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<NewPost initialPost={post} newPostParent={parentId} /> <NewPost initialPost={post} newPostParent={parentId} />
</Page.Content> </Page.Content>
</Page > </Page>
) )
} }
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => { export const getServerSideProps: GetServerSideProps = async ({
const id = params?.id req,
const redirect = { params
redirect: { }) => {
destination: '/new', const id = params?.id
permanent: false, const redirect = {
} redirect: {
} destination: "/new",
permanent: false
}
}
if (!id) { if (!id) {
return redirect return redirect
} }
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`] const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
const post = await fetch(`${process.env.API_URL}/posts/${id}`, const post = await fetch(`${process.env.API_URL}/posts/${id}`, {
{ method: "GET",
method: "GET", headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", Authorization: `Bearer ${driftToken}`,
"Authorization": `Bearer ${driftToken}`, "x-secret-key": process.env.SECRET_KEY || ""
"x-secret-key": process.env.SECRET_KEY || "" }
} })
})
if (!post.ok) { if (!post.ok) {
return redirect return redirect
} }
const data = await post.json() const data = await post.json()
if (!data) { if (!data) {
return redirect return redirect
} }
return { return {
props: { props: {
post: data, post: data,
parentId: id parentId: id
} }
} }
} }
export default NewFromExisting export default NewFromExisting

View file

@ -1,24 +1,24 @@
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
import NewPost from '@components/new-post' import NewPost from "@components/new-post"
import Header from '@components/header' import Header from "@components/header"
import PageSeo from '@components/page-seo' import PageSeo from "@components/page-seo"
import { Page } from '@geist-ui/core' import { Page } from "@geist-ui/core"
import Head from 'next/head' import Head from "next/head"
const New = () => { const New = () => {
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<PageSeo title="Create a new Drift" /> <PageSeo title="Create a new Drift" />
<Head> <Head>
{/* TODO: solve this. */} {/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */} {/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/css/react-datepicker.css" /> <link rel="stylesheet" href="/css/react-datepicker.css" />
</Head> </Head>
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<NewPost /> <NewPost />
</Page.Content> </Page.Content>
</Page > </Page>
) )
} }
export default New export default New

View file

@ -1,50 +1,51 @@
import type { GetServerSideProps, GetStaticPaths, GetStaticProps } from "next"; import type { GetServerSideProps, GetStaticPaths, GetStaticProps } from "next"
import type { Post } from "@lib/types"; import type { Post } from "@lib/types"
import PostPage from "@components/post-page"; import PostPage from "@components/post-page"
export type PostProps = { export type PostProps = {
post: Post post: Post
} }
const PostView = ({ post }: PostProps) => { const PostView = ({ post }: PostProps) => {
return <PostPage post={post} /> return <PostPage post={post} />
} }
export const getServerSideProps: GetServerSideProps = async ({ params, res }) => { export const getServerSideProps: GetServerSideProps = async ({
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, { params,
method: "GET", res
headers: { }) => {
"Content-Type": "application/json", const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
"x-secret-key": process.env.SECRET_KEY || "", method: "GET",
} headers: {
}) "Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || ""
}
})
const sMaxAge = 60 * 60 * 24 const sMaxAge = 60 * 60 * 24
res.setHeader( res.setHeader(
'Cache-Control', "Cache-Control",
`public, s-maxage=${sMaxAge}, max-age=${sMaxAge}` `public, s-maxage=${sMaxAge}, max-age=${sMaxAge}`
) )
if (!post.ok || post.status !== 200) { if (!post.ok || post.status !== 200) {
return { return {
redirect: { redirect: {
destination: '/404', destination: "/404",
permanent: false, permanent: false
}, },
props: {} props: {}
} }
} }
const json = await post.json()
const json = await post.json(); return {
props: {
return { post: json
props: { }
post: json }
}
}
} }
export default PostView export default PostView

View file

@ -1,58 +1,60 @@
import cookie from "cookie"; import cookie from "cookie"
import type { GetServerSideProps } from "next"; import type { GetServerSideProps } from "next"
import { Post } from "@lib/types"; import { Post } from "@lib/types"
import PostPage from "@components/post-page"; import PostPage from "@components/post-page"
export type PostProps = { export type PostProps = {
post: Post post: Post
} }
const Post = ({ post, }: PostProps) => { const Post = ({ post }: PostProps) => {
return (<PostPage post={post} />) return <PostPage post={post} />
} }
export const getServerSideProps: GetServerSideProps = async (context) => { export const getServerSideProps: GetServerSideProps = async (context) => {
const headers = context.req.headers const headers = context.req.headers
const host = headers.host const host = headers.host
const driftToken = cookie.parse(headers.cookie || '')[`drift-token`] const driftToken = cookie.parse(headers.cookie || "")[`drift-token`]
if (context.query.id) { if (context.query.id) {
const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, { const post = await fetch(
method: "GET", "http://" + host + `/server-api/posts/${context.query.id}`,
headers: { {
"Content-Type": "application/json", method: "GET",
"Authorization": `Bearer ${driftToken}`, headers: {
"x-secret-key": process.env.SECRET_KEY || "", "Content-Type": "application/json",
} Authorization: `Bearer ${driftToken}`,
}) "x-secret-key": process.env.SECRET_KEY || ""
}
}
)
if (!post.ok || post.status !== 200) { if (!post.ok || post.status !== 200) {
return { return {
redirect: { redirect: {
destination: '/', destination: "/",
permanent: false, permanent: false
}, }
} }
} }
try { try {
const json = await post.json(); const json = await post.json()
return { return {
props: { props: {
post: json post: json
} }
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
} }
return { return {
props: { props: {
post: null post: null
} }
} }
} }
export default Post export default Post

View file

@ -1,83 +1,92 @@
import { Page, useToasts } from '@geist-ui/core'; import { Page, useToasts } from "@geist-ui/core"
import type { Post } from "@lib/types"; import type { Post } from "@lib/types"
import PasswordModal from "@components/new-post/password-modal"; import PasswordModal from "@components/new-post/password-modal"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { useRouter } from "next/router"; import { useRouter } from "next/router"
import Cookies from "js-cookie"; import Cookies from "js-cookie"
import PostPage from "@components/post-page"; import PostPage from "@components/post-page"
const Post = () => { const Post = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true)
const [post, setPost] = useState<Post>() const [post, setPost] = useState<Post>()
const router = useRouter() const router = useRouter()
const { setToast } = useToasts() const { setToast } = useToasts()
useEffect(() => { useEffect(() => {
if (router.isReady) { if (router.isReady) {
const fetchPostWithAuth = async () => { const fetchPostWithAuth = async () => {
const resp = await fetch(`/server-api/posts/${router.query.id}`, { const resp = await fetch(`/server-api/posts/${router.query.id}`, {
headers: { headers: {
Authorization: `Bearer ${Cookies.get('drift-token')}` Authorization: `Bearer ${Cookies.get("drift-token")}`
} }
}) })
if (!resp.ok) return if (!resp.ok) return
const post = await resp.json() const post = await resp.json()
if (!post) return if (!post) return
setPost(post) setPost(post)
} }
fetchPostWithAuth() fetchPostWithAuth()
} }
}, [router.isReady, router.query.id]) }, [router.isReady, router.query.id])
const onSubmit = async (password: string) => { const onSubmit = async (password: string) => {
const res = await fetch(`/server-api/posts/${router.query.id}?password=${password}`, { const res = await fetch(
method: "GET", `/server-api/posts/${router.query.id}?password=${password}`,
headers: { {
"Content-Type": "application/json", method: "GET",
} headers: {
}) "Content-Type": "application/json"
}
}
)
if (!res.ok) { if (!res.ok) {
setToast({ setToast({
type: "error", type: "error",
text: "Wrong password" text: "Wrong password"
}) })
return return
} }
const data = await res.json() const data = await res.json()
if (data) { if (data) {
if (data.error) { if (data.error) {
setToast({ setToast({
text: data.error, text: data.error,
type: "error" type: "error"
}) })
} else { } else {
setPost(data) setPost(data)
setIsPasswordModalOpen(false) setIsPasswordModalOpen(false)
} }
} }
} }
const onClose = () => { const onClose = () => {
setIsPasswordModalOpen(false); setIsPasswordModalOpen(false)
router.push("/"); router.push("/")
} }
if (!router.isReady) { if (!router.isReady) {
return <></> return <></>
} }
if (!post) { if (!post) {
return <Page> return (
<PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} /> <Page>
</Page> <PasswordModal
} creating={false}
onClose={onClose}
onSubmit={onSubmit}
isOpen={isPasswordModalOpen}
/>
</Page>
)
}
return (<PostPage post={post} />) return <PostPage post={post} />
} }
export default Post export default Post

View file

@ -1,14 +1,14 @@
import { Page } from '@geist-ui/core'; import { Page } from "@geist-ui/core"
import PageSeo from "@components/page-seo"; import PageSeo from "@components/page-seo"
import Auth from "@components/auth"; import Auth from "@components/auth"
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
const SignIn = () => ( const SignIn = () => (
<Page width={"100%"}> <Page width={"100%"}>
<PageSeo title="Drift - Sign In" /> <PageSeo title="Drift - Sign In" />
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<Auth page="signin" /> <Auth page="signin" />
</Page.Content> </Page.Content>
</Page> </Page>
) )
export default SignIn export default SignIn

View file

@ -1,15 +1,15 @@
import { Page } from '@geist-ui/core'; import { Page } from "@geist-ui/core"
import Auth from "@components/auth"; import Auth from "@components/auth"
import PageSeo from '@components/page-seo'; import PageSeo from "@components/page-seo"
import styles from '@styles/Home.module.css' import styles from "@styles/Home.module.css"
const SignUp = () => ( const SignUp = () => (
<Page width="100%"> <Page width="100%">
<PageSeo title="Drift - Sign Up" /> <PageSeo title="Drift - Sign Up" />
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<Auth page="signup" /> <Auth page="signup" />
</Page.Content> </Page.Content>
</Page> </Page>
) )
export default SignUp export default SignUp