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
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
52 changed files with 3306 additions and 2705 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +1,26 @@
import Head from "next/head";
import React from "react";
import Head from "next/head"
import React from "react"
type PageSeoProps = {
title?: string;
description?: string;
isLoading?: boolean;
isPrivate?: boolean
};
title?: string
description?: string
isLoading?: boolean
isPrivate?: boolean
}
const PageSeo = ({
title = 'Drift',
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
title = "Drift",
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
}: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
)
}
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</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 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 ListItem from "./list-item"
import { Post } from "@lib/types"
@ -11,128 +11,156 @@ import debounce from "lodash.debounce"
import Cookies from "js-cookie"
type Props = {
initialPosts: Post[]
error: boolean
morePosts: boolean
initialPosts: Post[]
error: boolean
morePosts: boolean
}
const PostList = ({ morePosts, initialPosts, error }: Props) => {
const [search, setSearchValue] = useState('')
const [posts, setPosts] = useState<Post[]>(initialPosts)
const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
const loadMoreClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (hasMorePosts) {
async function fetchPosts() {
const res = await fetch(`/server-api/posts/mine`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`,
"x-page": `${posts.length / 10 + 1}`,
}
}
)
const json = await res.json()
setPosts([...posts, ...json.posts])
setHasMorePosts(json.morePosts)
}
fetchPosts()
}
}, [posts, hasMorePosts])
const [search, setSearchValue] = useState("")
const [posts, setPosts] = useState<Post[]>(initialPosts)
const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
const loadMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (hasMorePosts) {
async function fetchPosts() {
const res = await fetch(`/server-api/posts/mine`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`,
"x-page": `${posts.length / 10 + 1}`
}
})
const json = await res.json()
setPosts([...posts, ...json.posts])
setHasMorePosts(json.morePosts)
}
fetchPosts()
}
},
[posts, hasMorePosts]
)
// update posts on search
useEffect(() => {
if (search) {
// fetch results from /server-api/posts/search
const fetchResults = async () => {
setSearching(true)
//encode search
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
// "tok": process.env.SECRET_KEY || ''
}
})
const data = await res.json()
setPosts(data)
setSearching(false)
}
fetchResults()
} else {
setPosts(initialPosts)
}
}, [initialPosts, search])
// update posts on search
useEffect(() => {
if (search) {
// fetch results from /server-api/posts/search
const fetchResults = async () => {
setSearching(true)
//encode search
const res = await fetch(
`/server-api/posts/search?q=${encodeURIComponent(search)}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
// "tok": process.env.SECRET_KEY || ''
}
}
)
const data = await res.json()
setPosts(data)
setSearching(false)
}
fetchResults()
} else {
setPosts(initialPosts)
}
}, [initialPosts, search])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
}
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
}
const debouncedSearchHandler = useMemo(
() => debounce(handleSearchChange, 300)
, []);
const debouncedSearchHandler = useMemo(
() => debounce(handleSearchChange, 300),
[]
)
useEffect(() => {
return () => {
debouncedSearchHandler.cancel();
}
}, [debouncedSearchHandler]);
useEffect(() => {
return () => {
debouncedSearchHandler.cancel()
}
}, [debouncedSearchHandler])
const deletePost = useCallback((postId: string) => async () => {
const res = await fetch(`/server-api/posts/${postId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`
},
})
const deletePost = useCallback(
(postId: string) => async () => {
const res = await fetch(`/server-api/posts/${postId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
}
})
if (!res.ok) {
console.error(res)
return
} else {
setPosts((posts) => posts.filter(post => post.id !== postId))
}
}, [])
if (!res.ok) {
console.error(res)
return
} else {
setPosts((posts) => posts.filter((post) => post.id !== postId))
}
},
[]
)
return (
<div className={styles.container}>
<div className={styles.searchContainer}>
<Input scale={3 / 2}
clearable
placeholder="Search..."
onChange={debouncedSearchHandler} />
</div>
{error && <Text type='error'>Failed to load.</Text>}
{!posts.length && searching && <ul>
<li>
<ListItemSkeleton />
</li>
<li>
<ListItemSkeleton />
</li>
</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 && <div>
<ul>
{posts.map((post) => {
return <ListItem deletePost={deletePost(post.id)} post={post} key={post.id} />
})}
</ul>
</div>
}
{hasMorePosts && !setSearchValue && <div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>}
</div>
)
return (
<div className={styles.container}>
<div className={styles.searchContainer}>
<Input
scale={3 / 2}
clearable
placeholder="Search..."
onChange={debouncedSearchHandler}
/>
</div>
{error && <Text type="error">Failed to load.</Text>}
{!posts.length && searching && (
<ul>
<li>
<ListItemSkeleton />
</li>
<li>
<ListItemSkeleton />
</li>
</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 && (
<div>
<ul>
{posts.map((post) => {
return (
<ListItem
deletePost={deletePost(post.id)}
post={post}
key={post.id}
/>
)
})}
</ul>
</div>
)}
{hasMorePosts && !setSearchValue && (
<div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>
)}
</div>
)
}
export default PostList

View file

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

View file

@ -1,8 +1,15 @@
import NextLink from "next/link"
import VisibilityBadge from "../badges/visibility-badge"
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 FadeIn from "@components/fade-in"
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 Edit from "@geist-ui/icons/edit"
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"
// 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 router = useRouter()
const ListItem = ({
post,
isOwner = true,
deletePost
}: {
post: Post
isOwner?: boolean
deletePost: () => void
}) => {
const router = useRouter()
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
return (<FadeIn><li key={post.id}>
<Card style={{ overflowY: 'scroll' }}>
<Card.Body>
<Text h3 className={styles.title}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color marginRight={'var(--gap)'}>
{post.title}
</Link>
</NextLink>
{isOwner && <span className={styles.buttons}>
{post.parent && <Tooltip text={"View parent"} hideArrow>
<Button
auto
icon={<Parent />}
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 text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip>
</span>}
</Text>
return (
<FadeIn>
<li key={post.id}>
<Card style={{ overflowY: "scroll" }}>
<Card.Body>
<Text h3 className={styles.title}>
<NextLink
passHref={true}
href={getPostPath(post.visibility, post.id)}
>
<Link color marginRight={"var(--gap)"}>
{post.title}
</Link>
</NextLink>
{isOwner && (
<span className={styles.buttons}>
{post.parent && (
<Tooltip text={"View parent"} hideArrow>
<Button
auto
icon={<Parent />}
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 text={"Delete"} hideArrow>
<Button iconRight={<Trash />} onClick={deletePost} auto />
</Tooltip>
</span>
)}
</Text>
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</Card.Body>
<Divider h="1px" my={0} />
<Card.Content>
{post.files?.map((file: File) => {
return <div key={file.id}>
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
{file.title || 'Untitled file'}
</Link></div>
})}
</Card.Content>
</Card>
</li> </FadeIn>)
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</Card.Body>
<Divider h="1px" my={0} />
<Card.Content>
{post.files?.map((file: File) => {
return (
<div key={file.id}>
<Link
color
href={`${getPostPath(post.visibility, post.id)}#${
file.title
}`}
>
{file.title || "Untitled file"}
</Link>
</div>
)
})}
</Card.Content>
</Card>
</li>{" "}
</FadeIn>
)
}
export default ListItem
export default ListItem

View file

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

View file

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

View file

@ -1,37 +1,58 @@
import { Tooltip, Button, Spacer } from '@geist-ui/core'
import ChevronUp from '@geist-ui/icons/chevronUpCircleFill'
import { useEffect, useState } from 'react'
import styles from './scroll.module.css'
import { Tooltip, Button, Spacer } from "@geist-ui/core"
import ChevronUp from "@geist-ui/icons/chevronUpCircleFill"
import { useEffect, useState } from "react"
import styles from "./scroll.module.css"
const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false)
useEffect(() => {
// if user is scrolled, set visible
const handleScroll = () => {
setShouldShow(window.scrollY > 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const [shouldShow, setShouldShow] = useState(false)
useEffect(() => {
// if user is scrolled, set visible
const handleScroll = () => {
setShouldShow(window.scrollY > 100)
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const isReducedMotion = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur()
const isReducedMotion =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? 'auto' : 'smooth' })
window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
}
}
return (
<div style={{ 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} />
<ChevronUp />
</Button>
</Tooltip>
</div>
)
return (
<div
style={{
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} />
<ChevronUp />
</Button>
</Tooltip>
</div>
)
}
export default ScrollToTop
export default ScrollToTop

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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