client: lint tsx files with prettier
This commit is contained in:
parent
c44ab907bb
commit
36e255ad2b
52 changed files with 3306 additions and 2705 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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'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'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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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'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'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
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
<Divider h="1px" my={0} />
|
||||
|
||||
<Card.Content >
|
||||
<Skeleton width={200} />
|
||||
</Card.Content>
|
||||
</Card>)
|
||||
<Card.Content>
|
||||
<Skeleton width={200} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
|
||||
export default ListItemSkeleton
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'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're trying to view has expired.
|
||||
</Text>
|
||||
</Note>
|
||||
</Page.Content>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default Expired
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue