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,11 +1,16 @@
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("/")
? props.href.substring(1)
: props.href
const href = basePath
? `${basePath}/${propHrefWithoutLeadingSlash}`
: props.href
return <GeistLink {...props} href={href} /> return <GeistLink {...props} href={href} />
} }

View file

@ -1,29 +1,38 @@
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) =>
fetch(url, {
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")}`
} }
}).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",
adminFetcher
)
const { data: users, error: usersError } = useSWR<User[]>(
"/server-api/admin/users",
adminFetcher
)
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({}) const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100 const byteToMB = (bytes: number) =>
Math.round((bytes / 1024 / 1024) * 100) / 100
useEffect(() => { useEffect(() => {
if (posts) { if (posts) {
// sum the sizes of each file per post // sum the sizes of each file per post
const sizes = posts.reduce((acc, post) => { const sizes = posts.reduce((acc, post) => {
const size = post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0 const size =
post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
return { ...acc, [post.id]: byteToMB(size) } return { ...acc, [post.id]: byteToMB(size) }
}, {}) }, {})
setPostSizes(sizes) setPostSizes(sizes)
@ -38,7 +47,8 @@ const Admin = () => {
{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 && (
<table>
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Username</th>
@ -48,17 +58,20 @@ const Admin = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users?.map(user => ( {users?.map((user) => (
<tr key={user.id}> <tr key={user.id}>
<td>{user.username}</td> <td>{user.username}</td>
<td>{user.posts?.length}</td> <td>{user.posts?.length}</td>
<td>{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleTimeString()}</td> <td>
{new Date(user.createdAt).toLocaleDateString()}{" "}
{new Date(user.createdAt).toLocaleTimeString()}
</td>
<td>{user.role}</td> <td>{user.role}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table>} </table>
)}
</Fieldset> </Fieldset>
<Spacer height={1} /> <Spacer height={1} />
<Fieldset> <Fieldset>
@ -66,7 +79,8 @@ const Admin = () => {
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>} {posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>} {!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>} {postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{posts && <table> {posts && (
<table>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
@ -79,21 +93,39 @@ const Admin = () => {
<tbody> <tbody>
{posts?.map((post) => ( {posts?.map((post) => (
<tr key={post.id}> <tr key={post.id}>
<td><PostModal id={post.id} /></td> <td>
<PostModal id={post.id} />
</td>
<td>{post.visibility}</td> <td>{post.visibility}</td>
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td> <td>
<td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td> {new Date(post.createdAt).toLocaleDateString()}{" "}
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td> {new Date(post.createdAt).toLocaleTimeString()}
</td>
<td>
{post.users?.length ? (
post.users[0].username
) : (
<i>Deleted</i>
)}
</td>
<td>
{postSizes[post.id] ? `${postSizes[post.id]} MB` : ""}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table>} </table>
{Object.keys(postSizes).length && <div style={{ float: 'right' }}> )}
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text> {Object.keys(postSizes).length && (
</div>} <div style={{ float: "right" }}>
<Text>
Total size:{" "}
{Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB
</Text>
</div>
)}
</Fieldset> </Fieldset>
</div>
</div >
) )
} }

View file

@ -1,15 +1,16 @@
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 { visible, setVisible, bindings } = useModal()
const { data: post, error } = useSWR<Post>(`/server-api/admin/post/${id}`, adminFetcher) const { data: post, error } = useSWR<Post>(
`/server-api/admin/post/${id}`,
adminFetcher
)
if (error) return <Modal>failed to load</Modal> if (error) return <Modal>failed to load</Modal>
if (!post) return <Modal>loading...</Modal> if (!post) return <Modal>loading...</Modal>
@ -18,7 +19,7 @@ const PostModal = ({ 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)
@ -26,8 +27,10 @@ const PostModal = ({ id }: {
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}
</Link>
<Modal width={"var(--main-content)"} {...bindings}>
<Modal.Title>{post.title}</Modal.Title> <Modal.Title>{post.title}</Modal.Title>
<Modal.Subtitle>Click an item to expand</Modal.Subtitle> <Modal.Subtitle>Click an item to expand</Modal.Subtitle>
{post.files?.map((file) => ( {post.files?.map((file) => (
@ -35,17 +38,20 @@ const PostModal = ({ id }: {
<Modal.Content> <Modal.Content>
<details> <details>
<summary>{file.title}</summary> <summary>{file.title}</summary>
<div dangerouslySetInnerHTML={{ __html: file.html }}> <div dangerouslySetInnerHTML={{ __html: file.html }}></div>
</div>
</details> </details>
</Modal.Content> </Modal.Content>
</div> </div>
) ))}
)} <Modal.Action type="warning" onClick={deletePost}>
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action> Delete
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action> </Modal.Action>
<Modal.Action passive onClick={() => setVisible(false)}>
Close
</Modal.Action>
</Modal> </Modal>
</>) </>
)
} }
export default PostModal export default PostModal

View file

@ -5,59 +5,62 @@ 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 (
return (<GeistProvider themes={[customTheme]} themeType={"custom"}> <GeistProvider themes={[customTheme]} themeType={"custom"}>
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}> <SkeletonTheme
baseColor={skeletonBaseColor}
highlightColor={skeletonHighlightColor}
>
<CssBaseline /> <CssBaseline />
<Header /> <Header />
<Component {...pageProps} /> <Component {...pageProps} />
</SkeletonTheme> </SkeletonTheme>
</GeistProvider >) </GeistProvider>
)
} }
export default App export default App

View file

@ -1,29 +1,30 @@
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()
@ -36,33 +37,43 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
fetchRequiresPass() fetchRequiresPass()
}, [page, signingIn]) }, [page, signingIn])
const handleJson = (json: any) => { const handleJson = (json: any) => {
signin(json.token) signin(json.token)
Cookies.set('drift-userid', json.userId); Cookies.set("drift-userid", json.userId)
router.push('/new') router.push("/new")
} }
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE) if (
if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE) !signingIn &&
else setErrorMsg(''); (!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 reqOpts = { const reqOpts = {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json"
}, },
body: JSON.stringify({ username, password, serverPassword }) body: JSON.stringify({ username, password, serverPassword })
} }
try { try {
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup'; const signUrl = signingIn
const resp = await fetch(signUrl, reqOpts); ? "/server-api/auth/signin"
const json = await resp.json(); : "/server-api/auth/signup"
if (!resp.ok) throw new Error(json.error.message); const resp = await fetch(signUrl, reqOpts)
const json = await resp.json()
if (!resp.ok) throw new Error(json.error.message)
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {
@ -74,7 +85,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
<div className={styles.container}> <div className={styles.container}>
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1> <h1>{signingIn ? "Sign In" : "Sign Up"}</h1>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -88,7 +99,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
scale={4 / 3} scale={4 / 3}
/> />
<Input <Input
htmlType='password' htmlType="password"
id="password" id="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
@ -96,35 +107,47 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
required required
scale={4 / 3} scale={4 / 3}
/> />
{requiresServerPassword && <Input {requiresServerPassword && (
htmlType='password' <Input
htmlType="password"
id="server-password" id="server-password"
value={serverPassword} value={serverPassword}
onChange={(event) => setServerPassword(event.target.value)} onChange={(event) => setServerPassword(event.target.value)}
placeholder="Server Password" placeholder="Server Password"
required required
scale={4 / 3} scale={4 / 3}
/>} />
)}
<Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button> <Button type="success" htmlType="submit">
{signingIn ? "Sign In" : "Sign Up"}
</Button>
</div> </div>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
{signingIn ? ( {signingIn ? (
<Text> <Text>
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link color href="/signup">Sign up</Link> <Link color href="/signup">
Sign up
</Link>
</Text> </Text>
) : ( ) : (
<Text> <Text>
Already have an account?{" "} Already have an account?{" "}
<Link color href="/signin">Sign in</Link> <Link color href="/signin">
Sign in
</Link>
</Text> </Text>
)} )}
</div> </div>
{errorMsg && <Note scale={0.75} type='error'>{errorMsg}</Note>} {errorMsg && (
<Note scale={0.75} type="error">
{errorMsg}
</Note>
)}
</form> </form>
</div> </div>
</div > </div>
) )
} }

View file

@ -1,10 +1,8 @@
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 createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate)) const [time, setTimeAgo] = useState(timeAgo(createdDate))
@ -16,7 +14,14 @@ const CreatedAgoBadge = ({ createdAt }: {
}, [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,19 +1,24 @@
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) {
@ -43,14 +48,15 @@ const ExpirationBadge = ({
// }, [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}`} {isExpired ? "Expired" : `Expires ${timeUntilString}`}
</Tooltip> </Tooltip>
</Badge> </Badge>

View file

@ -17,7 +17,7 @@ const VisibilityBadge = ({ visibility }: Props) => {
} }
} }
return (<Badge type={getBadgeType()}>{visibility}</Badge>) return <Badge type={getBadgeType()}>{visibility}</Badge>
} }
export default VisibilityBadge export default VisibilityBadge

View file

@ -1,8 +1,8 @@
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
@ -14,14 +14,9 @@ type Props = {
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,
loading,
iconHeight = 24,
...props
}) => {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null) const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
@ -57,11 +52,14 @@ const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = (
} }
} }
const onClickOutside = useCallback(() => (e: React.MouseEvent<HTMLDivElement>) => { const onClickOutside = useCallback(
() => (e: React.MouseEvent<HTMLDivElement>) => {
if (dropdown && !dropdown.contains(e.target as Node)) { if (dropdown && !dropdown.contains(e.target as Node)) {
setVisible(false) setVisible(false)
} }
}, [dropdown]) },
[dropdown]
)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@ -88,29 +86,31 @@ const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = (
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={onBlur} onBlur={onBlur}
> >
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'flex-end' }}> <div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-end"
}}
>
{props.children[0]} {props.children[0]}
<Button style={{ height: iconHeight, width: iconHeight }} className={styles.icon} onClick={() => setVisible(!visible)}><DownIcon /></Button> <Button
style={{ height: iconHeight, width: iconHeight }}
className={styles.icon}
onClick={() => setVisible(!visible)}
>
<DownIcon />
</Button>
</div> </div>
{ {visible && (
visible && ( <div className={`${styles.dropdown}`}>
<div <div className={`${styles.dropdownContent}`}>
className={`${styles.dropdown}`}
>
<div
className={`${styles.dropdownContent}`}
>
{props.children.slice(1)} {props.children.slice(1)}
</div> </div>
</div> </div>
)}
</div>
) )
}
</div >
)
} }
export default ButtonDropdown export default ButtonDropdown

View file

@ -1,16 +1,27 @@
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) => { (
{
children,
onClick,
className,
buttonType = "primary",
type = "button",
disabled = false,
...props
},
ref
) => {
return ( return (
<button <button
ref={ref} ref={ref}

View file

@ -2,19 +2,29 @@ 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,
removeDoc,
updateDocContent,
updateDocTitle,
onPaste
}: {
docs: Document[]
updateDocTitle: (i: number) => (title: string) => void updateDocTitle: (i: number) => (title: string) => void
updateDocContent: (i: number) => (content: string) => void updateDocContent: (i: number) => (content: string) => void
removeDoc: (i: number) => () => void removeDoc: (i: number) => () => void
onPaste: (e: any) => void onPaste: (e: any) => void
}) => { }) => {
const handleOnChange = useCallback((i) => (e: ChangeEvent<HTMLTextAreaElement>) => { const handleOnChange = useCallback(
(i) => (e: ChangeEvent<HTMLTextAreaElement>) => {
updateDocContent(i)(e.target.value) updateDocContent(i)(e.target.value)
}, [updateDocContent]) },
[updateDocContent]
)
return (<>{ return (
docs.map(({ content, id, title }, i) => { <>
{docs.map(({ content, id, title }, i) => {
return ( return (
<DocumentComponent <DocumentComponent
onPaste={onPaste} onPaste={onPaste}
@ -27,9 +37,9 @@ const DocumentList = ({ docs, removeDoc, updateDocContent, updateDocTitle, onPas
title={title} title={title}
/> />
) )
}) })}
} </>
</>) )
} }
export default memo(DocumentList) export default memo(DocumentList)

View file

@ -1,14 +1,20 @@
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 = ({
textareaRef,
setText
}: {
textareaRef?: RefObject<HTMLTextAreaElement>
setText?: (text: string) => void
}) => {
// const { textBefore, textAfter, selectedText } = useMemo(() => { // const { textBefore, textAfter, selectedText } = useMemo(() => {
// if (textareaRef && textareaRef.current) { // if (textareaRef && textareaRef.current) {
// const textarea = textareaRef.current // const textarea = textareaRef.current
@ -58,8 +64,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
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://)`
@ -77,8 +83,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
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://)`
@ -88,15 +94,16 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const formattingActions = useMemo(() => [ const formattingActions = useMemo(
() => [
{ {
icon: <Bold />, icon: <Bold />,
name: 'bold', name: "bold",
action: handleBoldClick action: handleBoldClick
}, },
{ {
icon: <Italic />, icon: <Italic />,
name: 'italic', name: "italic",
action: handleItalicClick action: handleItalicClick
}, },
// { // {
@ -106,26 +113,36 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
// }, // },
{ {
icon: <Link />, icon: <Link />,
name: 'hyperlink', name: "hyperlink",
action: handleLinkClick action: handleLinkClick
}, },
{ {
icon: <ImageIcon />, icon: <ImageIcon />,
name: 'image', name: "image",
action: handleImageClick action: handleImageClick
} }
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]) ],
[handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]
)
return ( return (
<div className={styles.actionWrapper}> <div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}> <ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => ( {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} /> <Button
auto
scale={2 / 3}
px={0.6}
aria-label={name}
key={name}
icon={icon}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
/>
))} ))}
</ButtonGroup> </ButtonGroup>
</div> </div>
) )
} }
export default FormattingIcons export default FormattingIcons

View file

@ -1,11 +1,25 @@
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"
@ -20,25 +34,41 @@ type Props = {
onPaste?: (e: any) => void onPaste?: (e: any) => void
} }
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => { const Document = ({
onPaste,
remove,
title,
content,
setTitle,
setContent,
initialTab = "edit",
handleOnContentChange
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null) const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab) const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%' // const height = editable ? "500px" : '100%'
const height = "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(
(remove?: () => void) => {
if (remove) { if (remove) {
if (content && content.trim().length > 0) { if (content && content.trim().length > 0) {
const confirmed = window.confirm("Are you sure you want to remove this file?") const confirmed = window.confirm(
"Are you sure you want to remove this file?"
)
if (confirmed) { if (confirmed) {
remove() remove()
} }
@ -46,7 +76,9 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
remove() remove()
} }
} }
}, [content]) },
[content]
)
// if (skeleton) { // if (skeleton) {
// return <> // return <>
@ -80,14 +112,37 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
width={"100%"} width={"100%"}
id={title} id={title}
/> />
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />} {remove && (
<Button
type="abort"
ghost
icon={<Trash />}
auto
height={"36px"}
width={"36px"}
onClick={() => removeFile(remove)}
/>
)}
</div> </div>
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />} {tab === "edit" && (
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />
)}
<Tabs
onChange={handleTabChange}
initialValue={initialTab}
hideDivider
leftSpace={0}
>
<Tabs.Item label={"Edit"} value="edit"> <Tabs.Item label={"Edit"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> <div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<Textarea <Textarea
onPaste={onPaste ? onPaste : undefined} onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef} ref={codeEditorRef}
@ -103,16 +158,15 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
</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)" }}>
<Preview height={height} title={title} content={content} /> <Preview height={height} title={title} content={content} />
</div> </div>
</Tabs.Item> </Tabs.Item>
</Tabs> </Tabs>
</div > </div>
</div > </div>
</> </>
) )
} }
export default memo(Document) export default memo(Document)

View file

@ -1,10 +1,8 @@
import { Page } from '@geist-ui/core' import { Page } from "@geist-ui/core"
const Error = ({ status }: { const Error = ({ status }: { status: number }) => {
status: number
}) => {
return ( return (
<Page title={status.toString() || 'Error'}> <Page title={status.toString() || "Error"}>
{status === 404 ? ( {status === 404 ? (
<h1>This page cannot be found.</h1> <h1>This page cannot be found.</h1>
) : ( ) : (

View file

@ -1,5 +1,5 @@
// 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,
@ -7,10 +7,10 @@ const FadeIn = ({
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
@ -18,13 +18,13 @@ const FadeIn = ({
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,12 +1,12 @@
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
@ -16,7 +16,7 @@ const FileDropdown = ({
files, files,
isMobile isMobile
}: { }: {
files: File[], files: File[]
isMobile: boolean isMobile: boolean
}) => { }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
@ -35,9 +35,9 @@ const FileDropdown = ({
}, []) }, [])
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 />
@ -52,31 +52,49 @@ const FileDropdown = ({
setItems(newItems) setItems(newItems)
}, [files]) }, [files])
const content = useCallback(() => (<ul className={styles.content}> const content = useCallback(
{items.map(item => ( () => (
<ul className={styles.content}>
{items.map((item) => (
<li key={item.id} onClick={onClose}> <li key={item.id} onClick={onClose}>
<a href={`#${item.title}`}> <a href={`#${item.title}`}>
<ShiftBy y={5}><span className={styles.fileIcon}> <ShiftBy y={5}>
{item.icon}</span></ShiftBy> <span className={styles.fileIcon}>{item.icon}</span>
<span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span> </ShiftBy>
<span className={styles.fileTitle}>
{item.title ? item.title : "Untitled"}
</span>
</a> </a>
</li> </li>
))} ))}
</ul> </ul>
), [items, onClose]) ),
[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
onClick={onOpen}
className={styles.button}
iconRight={<ChevronDown />}
style={{ textTransform: "none" }}
>
Jump to {files.length} {files.length === 1 ? "file" : "files"}
</Button> </Button>
<Popover <Popover
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }} style={{
transform: isMobile ? "translateX(110px)" : "translateX(-75px)"
}}
onVisibleChange={changeHandler} onVisibleChange={changeHandler}
content={content} visible={expanded} hideArrow={true} onClick={onClose} /> content={content}
visible={expanded}
hideArrow={true}
onClick={onClose}
/>
</> </>
) )
} }

View file

@ -1,8 +1,8 @@
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"
@ -11,16 +11,12 @@ type Item = File & {
icon: JSX.Element icon: JSX.Element
} }
const FileTree = ({ const FileTree = ({ files }: { files: File[] }) => {
files
}: {
files: File[]
}) => {
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
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 />
@ -38,15 +34,16 @@ const FileTree = ({
// 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>
</ShiftBy>
<span className={styles.fileTreeTitle}>{title}</span> <span className={styles.fileTreeTitle}>{title}</span>
</Link> </Link>
</li> </li>
@ -54,7 +51,7 @@ const FileTree = ({
</ul> </ul>
</div> </div>
</Card> </Card>
</div > </div>
) )
} }

View file

@ -1,19 +1,18 @@
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 ( return (
<> <>
<Head> <Head>
@ -21,7 +20,7 @@ const PageSeo = ({
{!isPrivate && <meta name="description" content={description} />} {!isPrivate && <meta name="description" content={description} />}
</Head> </Head>
</> </>
); )
}; }
export default PageSeo; export default PageSeo

View file

@ -1,10 +1,10 @@
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)
@ -12,10 +12,10 @@ const Controls = () => {
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")
} }
} }
@ -39,8 +39,8 @@ const Controls = () => {
</span> </span>
</Select.Option> </Select.Option>
</Select> </Select>
</div > </div>
) )
} }
export default React.memo(Controls); export default React.memo(Controls)

View file

@ -1,25 +1,31 @@
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
@ -29,14 +35,13 @@ type Tab = {
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()
@ -61,27 +66,27 @@ const Header = () => {
{ {
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',
@ -90,32 +95,32 @@ const Header = () => {
// 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
]) ])
@ -123,10 +128,10 @@ const Header = () => {
setPages((pages) => [ setPages((pages) => [
...pages, ...pages,
{ {
name: 'admin', name: "admin",
icon: <SettingsIcon />, icon: <SettingsIcon />,
value: 'admin', value: "admin",
href: '/admin' href: "/admin"
} }
]) ])
} }
@ -134,18 +139,23 @@ const Header = () => {
// 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
const match = pages.find((page) => page.value === tab)
if (match?.onClick) { if (match?.onClick) {
match.onClick() match.onClick()
} }
}, [pages]) },
[pages]
)
const getButton = useCallback((tab: Tab) => { const getButton = useCallback(
(tab: Tab) => {
const activeStyle = router.pathname === tab.href ? styles.active : "" const activeStyle = router.pathname === tab.href ? styles.active : ""
if (tab.onClick) { if (tab.onClick) {
return <Button return (
<Button
auto={isMobile ? false : true} auto={isMobile ? false : true}
key={tab.value} key={tab.value}
icon={tab.icon} icon={tab.icon}
@ -155,8 +165,10 @@ const Header = () => {
> >
{tab.name ? tab.name : undefined} {tab.name ? tab.name : undefined}
</Button> </Button>
)
} else if (tab.href) { } else if (tab.href) {
return <Link key={tab.value} href={tab.href}> return (
<Link key={tab.value} href={tab.href}>
<a className={styles.tab}> <a className={styles.tab}>
<Button <Button
className={activeStyle} className={activeStyle}
@ -168,17 +180,18 @@ const Header = () => {
</Button> </Button>
</a> </a>
</Link> </Link>
)
} }
}, [isMobile, onTabChange, router.pathname]) },
[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>
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button
@ -193,14 +206,19 @@ const Header = () => {
</Button> </Button>
</div> </div>
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */} {/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
{isMobile && expanded && (<div className={styles.mobile} onClick={() => setExpanded(!expanded)}> {isMobile && expanded && (
<ButtonGroup vertical style={{ <div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
background: "var(--bg)", <ButtonGroup
}}> vertical
style={{
background: "var(--bg)"
}}
>
{buttons} {buttons}
</ButtonGroup> </ButtonGroup>
</div>)} </div>
</Page.Header > )}
</Page.Header>
) )
} }

View file

@ -1,23 +1,46 @@
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,
introContent,
rendered
}: {
introTitle: string introTitle: string
introContent: string introContent: string
rendered: 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> <>
<div
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
>
<ShiftBy y={-2}>
<Image
src={"/assets/logo-optimized.svg"}
width={"48px"}
height={"48px"}
alt=""
/>
</ShiftBy>
<Spacer /> <Spacer />
<Text style={{ display: 'inline' }} h1>{introTitle}</Text> <Text style={{ display: "inline" }} h1>
{introTitle}
</Text>
</div> </div>
<Card> <Card>
<Tabs initialValue={'preview'} hideDivider leftSpace={0}> <Tabs initialValue={"preview"} hideDivider leftSpace={0}>
<Tabs.Item label={"Raw"} value="edit"> <Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> <div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<Textarea <Textarea
readOnly readOnly
value={introContent} value={introContent}
@ -30,14 +53,20 @@ const Home = ({ introTitle, introContent, rendered }: {
</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)" }}>
<article className={markdownStyles.markdownPreview} dangerouslySetInnerHTML={{ __html: rendered }} style={{ <article
className={markdownStyles.markdownPreview}
dangerouslySetInnerHTML={{ __html: rendered }}
style={{
height: "100%" height: "100%"
}} /> }}
/>
</div> </div>
</Tabs.Item> </Tabs.Item>
</Tabs> </Tabs>
</Card></>) </Card>
</>
)
} }
export default Home export default Home

View file

@ -1,5 +1,5 @@
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
@ -7,18 +7,19 @@ type Props = React.HTMLProps<HTMLInputElement> & {
} }
// 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) => {
return (
<div className={styles.wrapper}>
{label && <label className={styles.label}>{label}</label>} {label && <label className={styles.label}>{label}</label>}
<input <input
ref={ref} ref={ref}
className={className ? `${styles.input} ${className}` : styles.input} className={className ? `${styles.input} ${className}` : styles.input}
{...props} {...props}
/> />
</div> </div>
) )
}) }
)
export default Input export default Input

View file

@ -1,12 +1,15 @@
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
}: {
posts: Post[]
error: boolean
morePosts: boolean morePosts: boolean
}) => { }) => {
return <PostList morePosts={morePosts} initialPosts={posts} error={error} /> return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
} }

View file

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

View file

@ -1,35 +1,51 @@
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: '', {
title: "",
content: "",
id: generateUUID() id: generateUUID()
}], []) }
],
[]
)
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc) const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
@ -37,37 +53,41 @@ const Post = ({
useEffect(() => { useEffect(() => {
if (initialPost) { if (initialPost) {
setTitle(`Copy of ${initialPost.title}`) setTitle(`Copy of ${initialPost.title}`)
setDocs(initialPost.files?.map(doc => ({ setDocs(
initialPost.files?.map((doc) => ({
title: doc.title, title: doc.title,
content: doc.content, content: doc.content,
id: doc.id id: doc.id
})) || emptyDoc) })) || 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[]
password?: string
userId: string
parentId?: string parentId?: string
}) => { }
) => {
const res = await fetch(url, { const res = await fetch(url, {
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,
files: docs, files: docs,
...data, ...data
}) })
}) })
@ -77,19 +97,21 @@ const Post = ({
} else { } else {
const json = await res.json() const json = await res.json()
setToast({ setToast({
text: json.error.message || 'Please fill out all fields', text: json.error.message || "Please fill out all fields",
type: 'error' type: "error"
}) })
setPasswordModalVisible(false) setPasswordModalVisible(false)
setSubmitting(false) setSubmitting(false)
} }
},
}, [docs, router, setToast, title]) [docs, router, setToast, title]
)
const [isSubmitting, setSubmitting] = useState(false) const [isSubmitting, setSubmitting] = useState(false)
const onSubmit = useCallback(async (visibility: PostVisibility, password?: string) => { const onSubmit = useCallback(
if (visibility === 'protected' && !password) { async (visibility: PostVisibility, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true) setPasswordModalVisible(true)
return return
} }
@ -102,16 +124,16 @@ const Post = ({
if (!title) { if (!title) {
setToast({ setToast({
text: 'Please fill out the post title', text: "Please fill out the post title",
type: 'error' type: "error"
}) })
hasErrored = true hasErrored = true
} }
if (!docs.length) { if (!docs.length) {
setToast({ setToast({
text: 'Please add at least one document', text: "Please add at least one document",
type: 'error' type: "error"
}) })
hasErrored = true hasErrored = true
} }
@ -119,8 +141,8 @@ const Post = ({
for (const doc of docs) { for (const doc of docs) {
if (!doc.title) { if (!doc.title) {
setToast({ setToast({
text: 'Please fill out all the document titles', text: "Please fill out all the document titles",
type: 'error' type: "error"
}) })
hasErrored = true hasErrored = true
} }
@ -131,58 +153,82 @@ const Post = ({
return return
} }
await sendRequest('/server-api/posts/create', { await sendRequest("/server-api/posts/create", {
title, title,
files: docs, files: docs,
visibility, visibility,
password, password,
userId: Cookies.get('drift-userid') || '', userId: Cookies.get("drift-userid") || "",
expiresAt, expiresAt,
parentId: newPostParent parentId: newPostParent
}) })
}, [docs, expiresAt, newPostParent, sendRequest, setToast, title]) },
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
)
const onClosePasswordModal = () => { const onClosePasswordModal = () => {
setPasswordModalVisible(false) setPasswordModalVisible(false)
setSubmitting(false) setSubmitting(false)
} }
const submitPassword = useCallback((password) => onSubmit('protected', password), [onSubmit]) const submitPassword = useCallback(
(password) => onSubmit("protected", password),
[onSubmit]
)
const onChangeExpiration = useCallback((date) => setExpiresAt(date), []) const onChangeExpiration = useCallback((date) => setExpiresAt(date), [])
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => { const onChangeTitle = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value) setTitle(e.target.value)
}, [setTitle]) },
[setTitle]
)
const updateDocTitle = useCallback(
(i: number) => (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
},
[setDocs]
)
const updateDocTitle = useCallback((i: number) => (title: string) => { const updateDocContent = useCallback(
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, title } : doc)) (i: number) => (content: string) => {
}, [setDocs]) setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
},
[setDocs]
)
const updateDocContent = useCallback((i: number) => (content: string) => { const removeDoc = useCallback(
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, content } : doc)) (i: number) => () => {
}, [setDocs])
const removeDoc = useCallback((i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index)) setDocs((docs) => docs.filter((_, index) => i !== index))
}, [setDocs]) },
[setDocs]
)
const uploadDocs = useCallback((files: DocumentType[]) => { const uploadDocs = useCallback(
(files: DocumentType[]) => {
// if no title is set and the only document is empty, // if no title is set and the only document is empty,
const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true) const isFirstDocEmpty =
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
const shouldSetTitle = !title && isFirstDocEmpty const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) { if (shouldSetTitle) {
if (files.length === 1) { if (files.length === 1) {
setTitle(files[0].title) setTitle(files[0].title)
} else if (files.length > 1) { } else if (files.length > 1) {
setTitle('Uploaded files') setTitle("Uploaded files")
} }
} }
if (isFirstDocEmpty) setDocs(files) if (isFirstDocEmpty) setDocs(files)
else setDocs((docs) => [...docs, ...files]) else setDocs((docs) => [...docs, ...files])
}, [docs, title]) },
[docs, title]
)
// pasted files // pasted files
// const files = e.clipboardData.files as File[] // const files = e.clipboardData.files as File[]
@ -194,19 +240,26 @@ const Post = ({
// })) // }))
// } // }
const onPaste = useCallback((e: any) => { const onPaste = useCallback(
const pastedText = (e.clipboardData).getData('text') (e: any) => {
const pastedText = e.clipboardData.getData("text")
if (pastedText) { if (pastedText) {
if (!title) { if (!title) {
setTitle("Pasted text") setTitle("Pasted text")
} }
} }
}, [title]) },
[title]
)
const CustomTimeInput = ({ date, value, onChange }: { const CustomTimeInput = ({
date: Date, date,
value: string, value,
onChange
}: {
date: Date
value: string
onChange: (date: string) => void onChange: (date: string) => void
}) => ( }) => (
<input <input
@ -218,37 +271,54 @@ const Post = ({
} }
}} }}
style={{ style={{
backgroundColor: 'var(--bg)', backgroundColor: "var(--bg)",
border: '1px solid var(--light-gray)', border: "1px solid var(--light-gray)",
borderRadius: 'var(--radius)' borderRadius: "var(--radius)"
}} }}
required required
/> />
); )
return ( return (
<div style={{ paddingBottom: 150 }}> <div style={{ paddingBottom: 150 }}>
<Title title={title} onChange={onChangeTitle} /> <Title title={title} onChange={onChangeTitle} />
<FileDropzone setDocs={uploadDocs} /> <FileDropzone setDocs={uploadDocs} />
<EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} /> <EditDocumentList
onPaste={onPaste}
docs={docs}
updateDocTitle={updateDocTitle}
updateDocContent={updateDocContent}
removeDoc={removeDoc}
/>
<div className={styles.buttons}> <div className={styles.buttons}>
<Button <Button
className={styles.button} className={styles.button}
onClick={() => { onClick={() => {
setDocs([...docs, { setDocs([
title: '', ...docs,
content: '', {
title: "",
content: "",
id: generateUUID() id: generateUUID()
}]) }
])
}} }}
type="default" type="default"
> >
Add a File Add a File
</Button> </Button>
<div className={styles.rightButtons}> <div className={styles.rightButtons}>
{<DatePicker {
<DatePicker
onChange={onChangeExpiration} onChange={onChangeExpiration}
customInput={<Input label="Expires at" clearable width="100%" height="40px" />} customInput={
<Input
label="Expires at"
clearable
width="100%"
height="40px"
/>
}
placeholderText="Won't expire" placeholderText="Won't expire"
selected={expiresAt} selected={expiresAt}
showTimeInput={true} showTimeInput={true}
@ -261,16 +331,30 @@ const Post = ({
// TODO: investigate why this causes margin shift if true // TODO: investigate why this causes margin shift if true
enableTabLoop={false} enableTabLoop={false}
minDate={new Date()} minDate={new Date()}
/>} />
}
<ButtonDropdown loading={isSubmitting} type="success"> <ButtonDropdown loading={isSubmitting} type="success">
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item> <ButtonDropdown.Item main onClick={() => onSubmit("private")}>
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item> Create Private
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item> </ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item> <ButtonDropdown.Item onClick={() => onSubmit("public")}>
Create Public
</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit("unlisted")}>
Create Unlisted
</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit("protected")}>
Create with Password
</ButtonDropdown.Item>
</ButtonDropdown> </ButtonDropdown>
</div> </div>
</div> </div>
<PasswordModal creating={true} isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} /> <PasswordModal
creating={true}
isOpen={passwordModalVisible}
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</div> </div>
) )
} }

View file

@ -1,4 +1,3 @@
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"
@ -9,14 +8,19 @@ type Props = {
onSubmit: (password: string) => void onSubmit: (password: string) => void
} }
const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creating }: Props) => { const PasswordModal = ({
isOpen,
onClose,
onSubmit: onSubmitAfterVerify,
creating
}: Props) => {
const [password, setPassword] = useState<string>() const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>() const [confirmPassword, setConfirmPassword] = useState<string>()
const [error, setError] = 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
} }
@ -28,27 +32,52 @@ const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creatin
onSubmitAfterVerify(password) onSubmitAfterVerify(password)
} }
return (<> return (
<>
{/* TODO: investigate disableBackdropClick not updating state? */} {/* TODO: investigate disableBackdropClick not updating state? */}
{<Modal visible={isOpen} disableBackdropClick={true} > {
<Modal visible={isOpen} disableBackdropClick={true}>
<Modal.Title>Enter a password</Modal.Title> <Modal.Title>Enter a password</Modal.Title>
<Modal.Content> <Modal.Content>
{!error && creating && <Note type="warning" label='Warning'> {!error && creating && (
This doesn&apos;t protect your post from the server administrator. <Note type="warning" label="Warning">
</Note>} This doesn&apos;t protect your post from the server
{error && <Note type="error" label='Error'> administrator.
</Note>
)}
{error && (
<Note type="error" label="Error">
{error} {error}
</Note>} </Note>
)}
<Spacer /> <Spacer />
<Input width={"100%"} label="Password" marginBottom={1} htmlType="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} /> <Input
{creating && <Input width={"100%"} label="Confirm" htmlType="password" placeholder="Confirm Password" onChange={(e) => setConfirmPassword(e.target.value)} />} 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.Content>
<Modal.Action passive onClick={onClose}>Cancel</Modal.Action> <Modal.Action passive onClick={onClose}>
Cancel
</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action> <Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>} </Modal>
</>) }
</>
)
} }
export default PasswordModal export default PasswordModal

View file

@ -1,9 +1,9 @@
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...",
@ -12,7 +12,7 @@ const titlePlaceholders = [
"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 = {
@ -24,10 +24,15 @@ 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}> return (
<Text h1 width={"150px"} className={styles.drift}>Drift</Text> <div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>
Drift
</Text>
<ShiftBy y={-3}> <ShiftBy y={-3}>
<Input <Input
placeholder={placeholder} placeholder={placeholder}
@ -39,7 +44,8 @@ const Title = ({ onChange, title }: props) => {
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
</ShiftBy> </ShiftBy>
</div>) </div>
)
} }
export default memo(Title) export default memo(Title)

View file

@ -1,19 +1,18 @@
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 ( return (
<> <>
<Head> <Head>
@ -21,7 +20,7 @@ const PageSeo = ({
{!isPrivate && <meta name="description" content={description} />} {!isPrivate && <meta name="description" content={description} />}
</Head> </Head>
</> </>
); )
}; }
export default PageSeo; 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"
@ -17,31 +17,32 @@ type Props = {
} }
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: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault() e.preventDefault()
if (hasMorePosts) { if (hasMorePosts) {
async function fetchPosts() { async function fetchPosts() {
const res = await fetch(`/server-api/posts/mine`, 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(() => {
@ -50,14 +51,17 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => {
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(
`/server-api/posts/search?q=${encodeURIComponent(search)}`,
{
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")}`
// "tok": process.env.SECRET_KEY || '' // "tok": process.env.SECRET_KEY || ''
} }
}) }
)
const data = await res.json() const data = await res.json()
setPosts(data) setPosts(data)
setSearching(false) setSearching(false)
@ -73,64 +77,88 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => {
} }
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(
(postId: string) => async () => {
const res = await fetch(`/server-api/posts/${postId}`, { const res = await fetch(`/server-api/posts/${postId}`, {
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")}`
}, }
}) })
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
scale={3 / 2}
clearable clearable
placeholder="Search..." placeholder="Search..."
onChange={debouncedSearchHandler} /> onChange={debouncedSearchHandler}
/>
</div> </div>
{error && <Text type='error'>Failed to load.</Text>} {error && <Text type="error">Failed to load.</Text>}
{!posts.length && searching && <ul> {!posts.length && searching && (
<ul>
<li> <li>
<ListItemSkeleton /> <ListItemSkeleton />
</li> </li>
<li> <li>
<ListItemSkeleton /> <ListItemSkeleton />
</li> </li>
</ul>} </ul>
{posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>} )}
{ {posts?.length === 0 && !error && (
posts?.length > 0 && <div> <Text type="secondary">
No posts found. Create one{" "}
<NextLink passHref={true} href="/new">
<Link color>here</Link>
</NextLink>
.
</Text>
)}
{posts?.length > 0 && (
<div>
<ul> <ul>
{posts.map((post) => { {posts.map((post) => {
return <ListItem deletePost={deletePost(post.id)} post={post} key={post.id} /> return (
<ListItem
deletePost={deletePost(post.id)}
post={post}
key={post.id}
/>
)
})} })}
</ul> </ul>
</div> </div>
} )}
{hasMorePosts && !setSearchValue && <div className={styles.moreContainer}> {hasMorePosts && !setSearchValue && (
<div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}> <Button width={"100%"} onClick={loadMoreClick}>
Load more Load more
</Button> </Button>
</div>} </div>
)}
</div> </div>
) )
} }

View file

@ -1,21 +1,27 @@
import Skeleton from "react-loading-skeleton"
import { Card, Divider, Grid, Spacer } from "@geist-ui/core"
const ListItemSkeleton = () => (
import Skeleton from "react-loading-skeleton"; <Card>
import { Card, Divider, Grid, Spacer } from "@geist-ui/core";
const ListItemSkeleton = () => (<Card>
<Spacer height={1 / 2} /> <Spacer height={1 / 2} />
<Grid.Container justify={'space-between'} marginBottom={1 / 2}> <Grid.Container justify={"space-between"} marginBottom={1 / 2}>
<Grid xs={8} paddingLeft={1 / 2}><Skeleton width={150} /></Grid> <Grid xs={8} paddingLeft={1 / 2}>
<Grid xs={7}><Skeleton width={100} /></Grid> <Skeleton width={150} />
<Grid xs={4}><Skeleton width={70} /></Grid> </Grid>
<Grid xs={7}>
<Skeleton width={100} />
</Grid>
<Grid xs={4}>
<Skeleton width={70} />
</Grid>
</Grid.Container> </Grid.Container>
<Divider h="1px" my={0} /> <Divider h="1px" my={0} />
<Card.Content > <Card.Content>
<Skeleton width={200} /> <Skeleton width={200} />
</Card.Content> </Card.Content>
</Card>) </Card>
)
export default ListItemSkeleton 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 = ({
post,
isOwner = true,
deletePost
}: {
post: Post
isOwner?: boolean
deletePost: () => void
}) => {
const router = useRouter() 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>
<li key={post.id}>
<Card style={{ overflowY: "scroll" }}>
<Card.Body> <Card.Body>
<Text h3 className={styles.title}> <Text h3 className={styles.title}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}> <NextLink
<Link color marginRight={'var(--gap)'}> passHref={true}
href={getPostPath(post.visibility, post.id)}
>
<Link color marginRight={"var(--gap)"}>
{post.title} {post.title}
</Link> </Link>
</NextLink> </NextLink>
{isOwner && <span className={styles.buttons}> {isOwner && (
{post.parent && <Tooltip text={"View parent"} hideArrow> <span className={styles.buttons}>
{post.parent && (
<Tooltip text={"View parent"} hideArrow>
<Button <Button
auto auto
icon={<Parent />} icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))} onClick={() =>
router.push(
getPostPath(
post.parent!.visibility,
post.parent!.id
)
)
}
/> />
</Tooltip>}
<Tooltip text={"Make a copy"} hideArrow>
<Button
auto
iconRight={<Edit />}
onClick={editACopy} />
</Tooltip> </Tooltip>
<Tooltip text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip> )}
</span>} <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> </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">
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<ExpirationBadge postExpirationDate={post.expiresAt} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
</div> </div>
</Card.Body> </Card.Body>
<Divider h="1px" my={0} /> <Divider h="1px" my={0} />
<Card.Content> <Card.Content>
{post.files?.map((file: File) => { {post.files?.map((file: File) => {
return <div key={file.id}> return (
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}> <div key={file.id}>
{file.title || 'Untitled file'} <Link
</Link></div> color
href={`${getPostPath(post.visibility, post.id)}#${
file.title
}`}
>
{file.title || "Untitled file"}
</Link>
</div>
)
})} })}
</Card.Content> </Card.Content>
</Card> </Card>
</li>{" "}
</li> </FadeIn>) </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"
@ -26,10 +26,14 @@ 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(
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false const isOwner = post.users
? post.users[0].id === Cookies.get("drift-userid")
: false
if (!isOwner && isExpired) { if (!isOwner && isExpired) {
router.push("/expired") router.push("/expired")
} }
@ -41,7 +45,7 @@ const PostPage = ({ post }: Props) => {
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 : "")
@ -53,17 +57,18 @@ const PostPage = ({ post }: Props) => {
} }
}, [isExpired, post.expiresAt, post.users, router]) }, [isExpired, post.expiresAt, post.users, router])
const download = async () => { const download = async () => {
if (!post.files) return if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(post.files.map((file: any) => { const blob = await downloadZip(
post.files.map((file: any) => {
return { return {
name: file.title, name: file.title,
input: file.content, input: file.content,
lastModified: new Date(file.updatedAt) lastModified: new Date(file.updatedAt)
} }
})).blob() })
).blob()
const link = document.createElement("a") const link = document.createElement("a")
link.href = URL.createObjectURL(blob) link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip` link.download = `${post.title}.zip`
@ -90,22 +95,40 @@ const PostPage = ({ post }: Props) => {
<Page.Content className={homeStyles.main}> <Page.Content className={homeStyles.main}>
<div className={styles.header}> <div className={styles.header}>
<span className={styles.buttons}> <span className={styles.buttons}>
<ButtonGroup vertical={isMobile} marginLeft={0} marginRight={0} marginTop={1} marginBottom={1}> <ButtonGroup
vertical={isMobile}
marginLeft={0}
marginRight={0}
marginTop={1}
marginBottom={1}
>
<Button <Button
auto auto
icon={<Edit />} icon={<Edit />}
onClick={editACopy} onClick={editACopy}
style={{ textTransform: 'none' }}> style={{ textTransform: "none" }}
>
Edit a Copy Edit a Copy
</Button> </Button>
{post.parent && <Button {post.parent && (
<Button
auto auto
icon={<Parent />} icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))} onClick={() =>
router.push(
getPostPath(post.parent!.visibility, post.parent!.id)
)
}
> >
View Parent View Parent
</Button>} </Button>
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}> )}
<Button
auto
onClick={download}
icon={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive Download as ZIP Archive
</Button> </Button>
<FileDropdown isMobile={isMobile} files={post.files || []} /> <FileDropdown isMobile={isMobile} files={post.files || []} />
@ -119,23 +142,20 @@ const PostPage = ({ post }: Props) => {
<ExpirationBadge postExpirationDate={post.expiresAt} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
</span> </span>
</span> </span>
</div> </div>
{/* {post.files.length > 1 && <FileTree files={post.files} />} */} {/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title }: File) => ( {post.files?.map(({ id, content, title }: File) => (
<DocumentComponent <DocumentComponent
key={id} key={id}
title={title} title={title}
initialTab={'preview'} initialTab={"preview"}
id={id} id={id}
content={content} content={content}
/> />
))} ))}
<ScrollToTop /> <ScrollToTop />
</Page.Content> </Page.Content>
</Page > </Page>
) )
} }

View file

@ -1,6 +1,6 @@
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
@ -17,7 +17,7 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
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()
@ -29,12 +29,12 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
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) { if (resp.ok) {
@ -47,12 +47,21 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
} }
fetchPost() fetchPost()
}, [content, fileId, title]) }, [content, fileId, title])
return (<> return (
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{ <>
{isLoading ? (
<div>Loading...</div>
) : (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height height
}} />} }}
</>) />
)}
</>
)
} }
export default memo(MarkdownPreview) export default memo(MarkdownPreview)

View file

@ -1,7 +1,7 @@
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)
@ -10,22 +10,43 @@ const ScrollToTop = () => {
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 =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => { const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur() e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? 'auto' : 'smooth' }) window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}> <div
<Tooltip hideArrow text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}> style={{
<Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto > display: "flex",
flexDirection: "row",
width: "100%",
height: 24,
justifyContent: "flex-end"
}}
>
<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} /> <Spacer height={2 / 3} inline width={0} />
<ChevronUp /> <ChevronUp />
</Button> </Button>

View file

@ -10,7 +10,7 @@ function ShiftBy({ x = 0, y = 0, children }: Props) {
<div <div
style={{ style={{
transform: `translate(${x}px, ${y}px)`, transform: `translate(${x}px, ${y}px)`,
display: 'inline-block' display: "inline-block"
}} }}
> >
{children} {children}

View file

@ -1,12 +1,20 @@
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"
@ -20,12 +28,18 @@ type Props = {
} }
const DownloadButton = ({ rawLink }: { rawLink?: string }) => { const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}> return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}> <ButtonGroup className={styles.actions}>
<Tooltip hideArrow text="Download"> <Tooltip hideArrow text="Download">
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer"> <a
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button <Button
scale={2 / 3} px={0.6} scale={2 / 3}
px={0.6}
icon={<Download />} icon={<Download />}
auto auto
aria-label="Download" aria-label="Download"
@ -35,7 +49,8 @@ const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
<Tooltip hideArrow text="Open raw in new tab"> <Tooltip hideArrow text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer"> <a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button <Button
scale={2 / 3} px={0.6} scale={2 / 3}
px={0.6}
icon={<ExternalLink />} icon={<ExternalLink />}
auto auto
aria-label="Open raw file in new tab" aria-label="Open raw file in new tab"
@ -43,21 +58,27 @@ const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
</a> </a>
</Tooltip> </Tooltip>
</ButtonGroup> </ButtonGroup>
</div>) </div>
)
} }
const Document = ({
const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props) => { content,
title,
initialTab = "edit",
skeleton,
id
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null) const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab) const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%' // const height = editable ? "500px" : '100%'
const height = "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 rawLink = () => { const rawLink = () => {
@ -67,36 +88,55 @@ const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props)
} }
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} />
</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={'100%'} height={350} /> <Skeleton width={125} height={36} />
</div > </div>
<Skeleton width={"100%"} height={350} />
</div>
</div> </div>
</> </>
)
} }
return ( return (
<FadeIn> <FadeIn>
<Spacer height={1} /> <Spacer height={1} />
<div className={styles.card}> <div className={styles.card}>
<Link href={`#${title}`} className={styles.fileNameContainer}> <Link href={`#${title}`} className={styles.fileNameContainer}>
<Tag height={"100%"} id={`${title}`} width={"100%"} style={{ borderRadius: 0 }}> <Tag
{title || 'Untitled'} height={"100%"}
id={`${title}`}
width={"100%"}
style={{ borderRadius: 0 }}
>
{title || "Untitled"}
</Tag> </Tag>
</Link> </Link>
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
<DownloadButton rawLink={rawLink()} /> <DownloadButton rawLink={rawLink()} />
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> <Tabs
onChange={handleTabChange}
initialValue={initialTab}
hideDivider
leftSpace={0}
>
<Tabs.Item label={"Raw"} value="edit"> <Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}> <div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<Textarea <Textarea
readOnly readOnly
ref={codeEditorRef} ref={codeEditorRef}
@ -110,8 +150,13 @@ const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props)
</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
height={height}
fileId={id}
content={content}
title={title}
/>
</div> </div>
</Tabs.Item> </Tabs.Item>
</Tabs> </Tabs>
@ -121,5 +166,4 @@ const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props)
) )
} }
export default memo(Document) export default memo(Document)

View file

@ -6,7 +6,7 @@
"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"
}, },

View file

@ -1,27 +1,47 @@
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="apple-touch-icon"
sizes="180x180"
href="/assets/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5" /> <link
rel="mask-icon"
href="/assets/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="apple-mobile-web-app-title" content="Drift" /> <meta name="apple-mobile-web-app-title" content="Drift" />
<meta name="application-name" content="Drift" /> <meta name="application-name" content="Drift" />
<meta name="msapplication-TileColor" content="#da532c" /> <meta name="msapplication-TileColor" content="#da532c" />

View file

@ -1,5 +1,11 @@
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) {
@ -18,13 +24,15 @@ class MyDocument extends Document {
} }
render() { render() {
return (<Html lang="en"> return (
<Html lang="en">
<Head /> <Head />
<body> <body>
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>
</Html>) </Html>
)
} }
} }

View file

@ -1,15 +1,10 @@
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
err: any
}) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404 const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode } return { statusCode }
} }

View file

@ -1,34 +1,33 @@
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)
}) })
}) })
@ -38,15 +37,18 @@ export function middleware(req: NextRequest, event: NextFetchEvent) {
} }
} 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" ||
pathname === "/signup"
) {
return NextResponse.redirect(getURL("new"))
} }
} else if (!signedIn) { } else if (!signedIn) {
if (pathname === '/new') { if (pathname === "/new") {
return NextResponse.redirect(getURL('signin')) return NextResponse.redirect(getURL("signin"))
} }
} }
} }
return NextResponse.next() return NextResponse.next()

View file

@ -1,21 +1,21 @@
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 (
@ -28,11 +28,11 @@ const AdminPage = () => {
} }
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 || ""
} }
}) })
@ -45,7 +45,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
} else { } else {
return { return {
redirect: { redirect: {
destination: '/', destination: "/",
permanent: false permanent: false
} }
} }

View file

@ -1,15 +1,16 @@
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>
Error: The Drift you&apos;re trying to view has expired.
</Text>
</Note> </Note>
</Page.Content> </Page.Content>
</Page> </Page>
) )

View file

@ -1,24 +1,23 @@
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}`
) )
@ -26,7 +25,7 @@ export const getServerSideProps: GetServerSideProps = async ({ res }) => {
props: { props: {
introContent: content || null, introContent: content || null,
rendered: rendered || null, rendered: rendered || null,
introTitle: title || null, introTitle: title || null
} }
} }
} catch (error) { } catch (error) {
@ -51,7 +50,13 @@ const Home = ({ rendered, introContent, introTitle, error }: Props) => {
<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 && (
<HomeComponent
rendered={rendered}
introContent={introContent}
introTitle={introTitle}
/>
)}
</Page.Content> </Page.Content>
</Page> </Page>
) )

View file

@ -1,29 +1,37 @@
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 = ({
morePosts,
posts,
error
}: {
morePosts: boolean
posts: Post[]
error: boolean
}) => {
return ( return (
<Page className={styles.wrapper}> <Page className={styles.wrapper}>
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<MyPosts morePosts={morePosts} error={error} posts={posts} /> <MyPosts morePosts={morePosts} error={error} posts={posts} />
</Page.Content> </Page.Content>
</Page > </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
} }
} }
} }
@ -32,16 +40,16 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
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
} }
} }
} }
@ -51,7 +59,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
props: { props: {
posts: data.posts, posts: data.posts,
error: posts.status !== 200, error: posts.status !== 200,
morePosts: data.hasMore, morePosts: data.hasMore
} }
} }
} }

View file

@ -1,18 +1,18 @@
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 (
@ -26,16 +26,19 @@ const NewFromExisting = ({
<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 ({
req,
params
}) => {
const id = params?.id const id = params?.id
const redirect = { const redirect = {
redirect: { redirect: {
destination: '/new', destination: "/new",
permanent: false, permanent: false
} }
} }
@ -43,14 +46,13 @@ export const getServerSideProps: GetServerSideProps = async ({ req, params }) =>
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 || ""
} }
}) })

View file

@ -1,9 +1,9 @@
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 (
@ -17,7 +17,7 @@ const New = () => {
<Page.Content className={styles.main}> <Page.Content className={styles.main}>
<NewPost /> <NewPost />
</Page.Content> </Page.Content>
</Page > </Page>
) )
} }

View file

@ -1,7 +1,7 @@
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
@ -11,33 +11,35 @@ const PostView = ({ post }: PostProps) => {
return <PostPage post={post} /> return <PostPage post={post} />
} }
export const getServerSideProps: GetServerSideProps = async ({ params, res }) => { export const getServerSideProps: GetServerSideProps = async ({
params,
res
}) => {
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, { const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
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 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 { return {
props: { props: {
@ -47,4 +49,3 @@ export const getServerSideProps: GetServerSideProps = async ({ params, res }) =>
} }
export default PostView export default PostView

View file

@ -1,41 +1,44 @@
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(
"http://" + host + `/server-api/posts/${context.query.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 || 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: {
@ -55,4 +58,3 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
} }
export default Post export default Post

View file

@ -1,14 +1,14 @@
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()
@ -18,7 +18,7 @@ const Post = () => {
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
@ -32,12 +32,15 @@ const Post = () => {
}, [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(
`/server-api/posts/${router.query.id}?password=${password}`,
{
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json"
} }
}) }
)
if (!res.ok) { if (!res.ok) {
setToast({ setToast({
@ -62,8 +65,8 @@ const Post = () => {
} }
const onClose = () => { const onClose = () => {
setIsPasswordModalOpen(false); setIsPasswordModalOpen(false)
router.push("/"); router.push("/")
} }
if (!router.isReady) { if (!router.isReady) {
@ -71,13 +74,19 @@ const Post = () => {
} }
if (!post) { if (!post) {
return <Page> return (
<PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} /> <Page>
<PasswordModal
creating={false}
onClose={onClose}
onSubmit={onSubmit}
isOpen={isPasswordModalOpen}
/>
</Page> </Page>
)
} }
return (<PostPage post={post} />) return <PostPage post={post} />
} }
export default Post export default Post

View file

@ -1,7 +1,7 @@
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" />

View file

@ -1,7 +1,7 @@
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%">