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 type { LinkProps } from "@geist-ui/core"
|
||||||
import { Link as GeistLink } from "@geist-ui/core"
|
import { Link as GeistLink } from "@geist-ui/core"
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router"
|
||||||
|
|
||||||
const Link = (props: LinkProps) => {
|
const Link = (props: LinkProps) => {
|
||||||
const { basePath } = useRouter();
|
const { basePath } = useRouter()
|
||||||
const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href;
|
const propHrefWithoutLeadingSlash =
|
||||||
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
|
props.href && props.href.startsWith("/")
|
||||||
return <GeistLink {...props} href={href} />
|
? props.href.substring(1)
|
||||||
|
: props.href
|
||||||
|
const href = basePath
|
||||||
|
? `${basePath}/${propHrefWithoutLeadingSlash}`
|
||||||
|
: props.href
|
||||||
|
return <GeistLink {...props} href={href} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Link
|
export default Link
|
||||||
|
|
|
@ -1,100 +1,132 @@
|
||||||
import { Text, Fieldset, Spacer, Link } from '@geist-ui/core'
|
import { Text, Fieldset, Spacer, Link } from "@geist-ui/core"
|
||||||
import { Post, User } from '@lib/types'
|
import { Post, User } from "@lib/types"
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from "js-cookie"
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react"
|
||||||
import useSWR from 'swr'
|
import useSWR from "swr"
|
||||||
import styles from './admin.module.css'
|
import styles from "./admin.module.css"
|
||||||
import PostModal from './post-modal-link'
|
import PostModal from "./post-modal-link"
|
||||||
|
|
||||||
export const adminFetcher = (url: string) => fetch(url, {
|
export const adminFetcher = (url: string) =>
|
||||||
method: "GET",
|
fetch(url, {
|
||||||
headers: {
|
method: "GET",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
"Authorization": `Bearer ${Cookies.get('drift-token')}`,
|
"Content-Type": "application/json",
|
||||||
}
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
}).then(res => res.json())
|
}
|
||||||
|
}).then((res) => res.json())
|
||||||
|
|
||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher)
|
const { data: posts, error: postsError } = useSWR<Post[]>(
|
||||||
const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher)
|
"/server-api/admin/posts",
|
||||||
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
|
adminFetcher
|
||||||
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
|
)
|
||||||
useEffect(() => {
|
const { data: users, error: usersError } = useSWR<User[]>(
|
||||||
if (posts) {
|
"/server-api/admin/users",
|
||||||
// sum the sizes of each file per post
|
adminFetcher
|
||||||
const sizes = posts.reduce((acc, post) => {
|
)
|
||||||
const size = post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
|
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
|
||||||
return { ...acc, [post.id]: byteToMB(size) }
|
const byteToMB = (bytes: number) =>
|
||||||
}, {})
|
Math.round((bytes / 1024 / 1024) * 100) / 100
|
||||||
setPostSizes(sizes)
|
useEffect(() => {
|
||||||
}
|
if (posts) {
|
||||||
}, [posts])
|
// sum the sizes of each file per post
|
||||||
|
const sizes = posts.reduce((acc, post) => {
|
||||||
|
const size =
|
||||||
|
post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
|
||||||
|
return { ...acc, [post.id]: byteToMB(size) }
|
||||||
|
}, {})
|
||||||
|
setPostSizes(sizes)
|
||||||
|
}
|
||||||
|
}, [posts])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminWrapper}>
|
<div className={styles.adminWrapper}>
|
||||||
<Text h2>Administration</Text>
|
<Text h2>Administration</Text>
|
||||||
<Fieldset>
|
<Fieldset>
|
||||||
<Fieldset.Title>Users</Fieldset.Title>
|
<Fieldset.Title>Users</Fieldset.Title>
|
||||||
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
|
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
|
||||||
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||||
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||||
{users && <table>
|
{users && (
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Username</th>
|
<tr>
|
||||||
<th>Posts</th>
|
<th>Username</th>
|
||||||
<th>Created</th>
|
<th>Posts</th>
|
||||||
<th>Role</th>
|
<th>Created</th>
|
||||||
</tr>
|
<th>Role</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{users?.map(user => (
|
<tbody>
|
||||||
<tr key={user.id}>
|
{users?.map((user) => (
|
||||||
<td>{user.username}</td>
|
<tr key={user.id}>
|
||||||
<td>{user.posts?.length}</td>
|
<td>{user.username}</td>
|
||||||
<td>{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleTimeString()}</td>
|
<td>{user.posts?.length}</td>
|
||||||
<td>{user.role}</td>
|
<td>
|
||||||
</tr>
|
{new Date(user.createdAt).toLocaleDateString()}{" "}
|
||||||
))}
|
{new Date(user.createdAt).toLocaleTimeString()}
|
||||||
</tbody>
|
</td>
|
||||||
</table>}
|
<td>{user.role}</td>
|
||||||
|
</tr>
|
||||||
</Fieldset>
|
))}
|
||||||
<Spacer height={1} />
|
</tbody>
|
||||||
<Fieldset>
|
</table>
|
||||||
<Fieldset.Title>Posts</Fieldset.Title>
|
)}
|
||||||
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
|
</Fieldset>
|
||||||
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
<Spacer height={1} />
|
||||||
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
<Fieldset>
|
||||||
{posts && <table>
|
<Fieldset.Title>Posts</Fieldset.Title>
|
||||||
<thead>
|
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
|
||||||
<tr>
|
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||||
<th>Title</th>
|
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
|
||||||
<th>Visibility</th>
|
{posts && (
|
||||||
<th>Created</th>
|
<table>
|
||||||
<th>Author</th>
|
<thead>
|
||||||
<th>Size</th>
|
<tr>
|
||||||
</tr>
|
<th>Title</th>
|
||||||
</thead>
|
<th>Visibility</th>
|
||||||
<tbody>
|
<th>Created</th>
|
||||||
{posts?.map((post) => (
|
<th>Author</th>
|
||||||
<tr key={post.id}>
|
<th>Size</th>
|
||||||
<td><PostModal id={post.id} /></td>
|
</tr>
|
||||||
<td>{post.visibility}</td>
|
</thead>
|
||||||
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td>
|
<tbody>
|
||||||
<td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td>
|
{posts?.map((post) => (
|
||||||
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td>
|
<tr key={post.id}>
|
||||||
</tr>
|
<td>
|
||||||
))}
|
<PostModal id={post.id} />
|
||||||
</tbody>
|
</td>
|
||||||
</table>}
|
<td>{post.visibility}</td>
|
||||||
{Object.keys(postSizes).length && <div style={{ float: 'right' }}>
|
<td>
|
||||||
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text>
|
{new Date(post.createdAt).toLocaleDateString()}{" "}
|
||||||
</div>}
|
{new Date(post.createdAt).toLocaleTimeString()}
|
||||||
</Fieldset>
|
</td>
|
||||||
|
<td>
|
||||||
</div >
|
{post.users?.length ? (
|
||||||
)
|
post.users[0].username
|
||||||
|
) : (
|
||||||
|
<i>Deleted</i>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{postSizes[post.id] ? `${postSizes[post.id]} MB` : ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{Object.keys(postSizes).length && (
|
||||||
|
<div style={{ float: "right" }}>
|
||||||
|
<Text>
|
||||||
|
Total size:{" "}
|
||||||
|
{Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Admin
|
export default Admin
|
||||||
|
|
|
@ -1,51 +1,57 @@
|
||||||
import { Link, Modal, useModal } from "@geist-ui/core";
|
import { Link, Modal, useModal } from "@geist-ui/core"
|
||||||
import { Post } from "@lib/types";
|
import { Post } from "@lib/types"
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie"
|
||||||
import useSWR from "swr";
|
import useSWR from "swr"
|
||||||
import { adminFetcher } from ".";
|
import { adminFetcher } from "."
|
||||||
import styles from './admin.module.css'
|
import styles from "./admin.module.css"
|
||||||
|
|
||||||
const PostModal = ({ id }: {
|
const PostModal = ({ id }: { id: string }) => {
|
||||||
id: string,
|
const { visible, setVisible, bindings } = useModal()
|
||||||
}) => {
|
const { data: post, error } = useSWR<Post>(
|
||||||
const { visible, setVisible, bindings } = useModal()
|
`/server-api/admin/post/${id}`,
|
||||||
const { data: post, error } = useSWR<Post>(`/server-api/admin/post/${id}`, adminFetcher)
|
adminFetcher
|
||||||
if (error) return <Modal>failed to load</Modal>
|
)
|
||||||
if (!post) return <Modal>loading...</Modal>
|
if (error) return <Modal>failed to load</Modal>
|
||||||
|
if (!post) return <Modal>loading...</Modal>
|
||||||
|
|
||||||
const deletePost = async () => {
|
const deletePost = async () => {
|
||||||
await fetch(`/server-api/admin/post/${id}`, {
|
await fetch(`/server-api/admin/post/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link href="#" color onClick={() => setVisible(true)}>{post.title}</Link>
|
<Link href="#" color onClick={() => setVisible(true)}>
|
||||||
<Modal width={'var(--main-content)'} {...bindings}>
|
{post.title}
|
||||||
<Modal.Title>{post.title}</Modal.Title>
|
</Link>
|
||||||
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
|
<Modal width={"var(--main-content)"} {...bindings}>
|
||||||
{post.files?.map((file) => (
|
<Modal.Title>{post.title}</Modal.Title>
|
||||||
<div key={file.id} className={styles.postModal}>
|
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
|
||||||
<Modal.Content>
|
{post.files?.map((file) => (
|
||||||
<details>
|
<div key={file.id} className={styles.postModal}>
|
||||||
<summary>{file.title}</summary>
|
<Modal.Content>
|
||||||
<div dangerouslySetInnerHTML={{ __html: file.html }}>
|
<details>
|
||||||
</div>
|
<summary>{file.title}</summary>
|
||||||
</details>
|
<div dangerouslySetInnerHTML={{ __html: file.html }}></div>
|
||||||
</Modal.Content>
|
</details>
|
||||||
</div>
|
</Modal.Content>
|
||||||
)
|
</div>
|
||||||
)}
|
))}
|
||||||
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action>
|
<Modal.Action type="warning" onClick={deletePost}>
|
||||||
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action>
|
Delete
|
||||||
</Modal>
|
</Modal.Action>
|
||||||
</>)
|
<Modal.Action passive onClick={() => setVisible(false)}>
|
||||||
|
Close
|
||||||
|
</Modal.Action>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostModal
|
export default PostModal
|
||||||
|
|
|
@ -4,60 +4,63 @@ import type { NextComponentType, NextPageContext } from "next"
|
||||||
import { SkeletonTheme } from "react-loading-skeleton"
|
import { SkeletonTheme } from "react-loading-skeleton"
|
||||||
|
|
||||||
const App = ({
|
const App = ({
|
||||||
Component,
|
Component,
|
||||||
pageProps,
|
pageProps
|
||||||
}: {
|
}: {
|
||||||
Component: NextComponentType<NextPageContext, any, any>
|
Component: NextComponentType<NextPageContext, any, any>
|
||||||
pageProps: any
|
pageProps: any
|
||||||
}) => {
|
}) => {
|
||||||
const skeletonBaseColor = 'var(--light-gray)'
|
const skeletonBaseColor = "var(--light-gray)"
|
||||||
const skeletonHighlightColor = 'var(--lighter-gray)'
|
const skeletonHighlightColor = "var(--lighter-gray)"
|
||||||
|
|
||||||
const customTheme = Themes.createFromLight(
|
const customTheme = Themes.createFromLight({
|
||||||
{
|
type: "custom",
|
||||||
type: "custom",
|
palette: {
|
||||||
palette: {
|
background: "var(--bg)",
|
||||||
background: 'var(--bg)',
|
foreground: "var(--fg)",
|
||||||
foreground: 'var(--fg)',
|
accents_1: "var(--lightest-gray)",
|
||||||
accents_1: 'var(--lightest-gray)',
|
accents_2: "var(--lighter-gray)",
|
||||||
accents_2: 'var(--lighter-gray)',
|
accents_3: "var(--light-gray)",
|
||||||
accents_3: 'var(--light-gray)',
|
accents_4: "var(--gray)",
|
||||||
accents_4: 'var(--gray)',
|
accents_5: "var(--darker-gray)",
|
||||||
accents_5: 'var(--darker-gray)',
|
accents_6: "var(--darker-gray)",
|
||||||
accents_6: 'var(--darker-gray)',
|
accents_7: "var(--darkest-gray)",
|
||||||
accents_7: 'var(--darkest-gray)',
|
accents_8: "var(--darkest-gray)",
|
||||||
accents_8: 'var(--darkest-gray)',
|
border: "var(--light-gray)",
|
||||||
border: 'var(--light-gray)',
|
warning: "var(--warning)"
|
||||||
warning: 'var(--warning)'
|
},
|
||||||
},
|
expressiveness: {
|
||||||
expressiveness: {
|
dropdownBoxShadow: "0 0 0 1px var(--light-gray)",
|
||||||
dropdownBoxShadow: '0 0 0 1px var(--light-gray)',
|
shadowSmall: "0 0 0 1px var(--light-gray)",
|
||||||
shadowSmall: '0 0 0 1px var(--light-gray)',
|
shadowLarge: "0 0 0 1px var(--light-gray)",
|
||||||
shadowLarge: '0 0 0 1px var(--light-gray)',
|
shadowMedium: "0 0 0 1px var(--light-gray)"
|
||||||
shadowMedium: '0 0 0 1px var(--light-gray)',
|
},
|
||||||
},
|
layout: {
|
||||||
layout: {
|
gap: "var(--gap)",
|
||||||
gap: 'var(--gap)',
|
gapHalf: "var(--gap-half)",
|
||||||
gapHalf: 'var(--gap-half)',
|
gapQuarter: "var(--gap-quarter)",
|
||||||
gapQuarter: 'var(--gap-quarter)',
|
gapNegative: "var(--gap-negative)",
|
||||||
gapNegative: 'var(--gap-negative)',
|
gapHalfNegative: "var(--gap-half-negative)",
|
||||||
gapHalfNegative: 'var(--gap-half-negative)',
|
gapQuarterNegative: "var(--gap-quarter-negative)",
|
||||||
gapQuarterNegative: 'var(--gap-quarter-negative)',
|
radius: "var(--radius)"
|
||||||
radius: 'var(--radius)',
|
},
|
||||||
},
|
font: {
|
||||||
font: {
|
mono: "var(--font-mono)",
|
||||||
mono: 'var(--font-mono)',
|
sans: "var(--font-sans)"
|
||||||
sans: 'var(--font-sans)',
|
}
|
||||||
}
|
})
|
||||||
}
|
return (
|
||||||
)
|
<GeistProvider themes={[customTheme]} themeType={"custom"}>
|
||||||
return (<GeistProvider themes={[customTheme]} themeType={"custom"}>
|
<SkeletonTheme
|
||||||
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
|
baseColor={skeletonBaseColor}
|
||||||
<CssBaseline />
|
highlightColor={skeletonHighlightColor}
|
||||||
<Header />
|
>
|
||||||
<Component {...pageProps} />
|
<CssBaseline />
|
||||||
</SkeletonTheme>
|
<Header />
|
||||||
</GeistProvider >)
|
<Component {...pageProps} />
|
||||||
|
</SkeletonTheme>
|
||||||
|
</GeistProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
@ -1,131 +1,154 @@
|
||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useEffect, useState } from "react"
|
||||||
import { Button, Input, Text, Note } from '@geist-ui/core'
|
import { Button, Input, Text, Note } from "@geist-ui/core"
|
||||||
import styles from './auth.module.css'
|
import styles from "./auth.module.css"
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from "next/router"
|
||||||
import Link from '../Link'
|
import Link from "../Link"
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie"
|
||||||
import useSignedIn from '@lib/hooks/use-signed-in'
|
import useSignedIn from "@lib/hooks/use-signed-in"
|
||||||
|
|
||||||
const NO_EMPTY_SPACE_REGEX = /^\S*$/;
|
const NO_EMPTY_SPACE_REGEX = /^\S*$/
|
||||||
const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters";
|
const ERROR_MESSAGE =
|
||||||
|
"Provide a non empty username and a password with at least 6 characters"
|
||||||
|
|
||||||
const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState("")
|
||||||
const [serverPassword, setServerPassword] = useState('');
|
const [serverPassword, setServerPassword] = useState("")
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState("")
|
||||||
const [requiresServerPassword, setRequiresServerPassword] = useState(false);
|
const [requiresServerPassword, setRequiresServerPassword] = useState(false)
|
||||||
const signingIn = page === 'signin'
|
const signingIn = page === "signin"
|
||||||
const { signin } = useSignedIn();
|
const { signin } = useSignedIn()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchRequiresPass() {
|
async function fetchRequiresPass() {
|
||||||
if (!signingIn) {
|
if (!signingIn) {
|
||||||
const resp = await fetch("/server-api/auth/requires-passcode", {
|
const resp = await fetch("/server-api/auth/requires-passcode", {
|
||||||
method: "GET",
|
method: "GET"
|
||||||
})
|
})
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const res = await resp.json()
|
const res = await resp.json()
|
||||||
setRequiresServerPassword(res.requiresPasscode)
|
setRequiresServerPassword(res.requiresPasscode)
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg("Something went wrong. Is the server running?")
|
setErrorMsg("Something went wrong. Is the server running?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchRequiresPass()
|
fetchRequiresPass()
|
||||||
}, [page, signingIn])
|
}, [page, signingIn])
|
||||||
|
|
||||||
|
const handleJson = (json: any) => {
|
||||||
|
signin(json.token)
|
||||||
|
Cookies.set("drift-userid", json.userId)
|
||||||
|
|
||||||
const handleJson = (json: any) => {
|
router.push("/new")
|
||||||
signin(json.token)
|
}
|
||||||
Cookies.set('drift-userid', json.userId);
|
|
||||||
|
|
||||||
router.push('/new')
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
}
|
e.preventDefault()
|
||||||
|
if (
|
||||||
|
!signingIn &&
|
||||||
|
(!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)
|
||||||
|
)
|
||||||
|
return setErrorMsg(ERROR_MESSAGE)
|
||||||
|
if (
|
||||||
|
!signingIn &&
|
||||||
|
requiresServerPassword &&
|
||||||
|
!NO_EMPTY_SPACE_REGEX.test(serverPassword)
|
||||||
|
)
|
||||||
|
return setErrorMsg(ERROR_MESSAGE)
|
||||||
|
else setErrorMsg("")
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const reqOpts = {
|
||||||
e.preventDefault()
|
method: "POST",
|
||||||
if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE)
|
headers: {
|
||||||
if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE)
|
"Content-Type": "application/json"
|
||||||
else setErrorMsg('');
|
},
|
||||||
|
body: JSON.stringify({ username, password, serverPassword })
|
||||||
|
}
|
||||||
|
|
||||||
const reqOpts = {
|
try {
|
||||||
method: 'POST',
|
const signUrl = signingIn
|
||||||
headers: {
|
? "/server-api/auth/signin"
|
||||||
'Content-Type': 'application/json'
|
: "/server-api/auth/signup"
|
||||||
},
|
const resp = await fetch(signUrl, reqOpts)
|
||||||
body: JSON.stringify({ username, password, serverPassword })
|
const json = await resp.json()
|
||||||
}
|
if (!resp.ok) throw new Error(json.error.message)
|
||||||
|
|
||||||
try {
|
handleJson(json)
|
||||||
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup';
|
} catch (err: any) {
|
||||||
const resp = await fetch(signUrl, reqOpts);
|
setErrorMsg(err.message ?? "Something went wrong")
|
||||||
const json = await resp.json();
|
}
|
||||||
if (!resp.ok) throw new Error(json.error.message);
|
}
|
||||||
|
|
||||||
handleJson(json)
|
return (
|
||||||
} catch (err: any) {
|
<div className={styles.container}>
|
||||||
setErrorMsg(err.message ?? "Something went wrong")
|
<div className={styles.form}>
|
||||||
}
|
<div className={styles.formContentSpace}>
|
||||||
}
|
<h1>{signingIn ? "Sign In" : "Sign Up"}</h1>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<Input
|
||||||
|
htmlType="text"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
scale={4 / 3}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
htmlType="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
scale={4 / 3}
|
||||||
|
/>
|
||||||
|
{requiresServerPassword && (
|
||||||
|
<Input
|
||||||
|
htmlType="password"
|
||||||
|
id="server-password"
|
||||||
|
value={serverPassword}
|
||||||
|
onChange={(event) => setServerPassword(event.target.value)}
|
||||||
|
placeholder="Server Password"
|
||||||
|
required
|
||||||
|
scale={4 / 3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
<Button type="success" htmlType="submit">
|
||||||
<div className={styles.container}>
|
{signingIn ? "Sign In" : "Sign Up"}
|
||||||
<div className={styles.form}>
|
</Button>
|
||||||
<div className={styles.formContentSpace}>
|
</div>
|
||||||
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1>
|
<div className={styles.formContentSpace}>
|
||||||
</div>
|
{signingIn ? (
|
||||||
<form onSubmit={handleSubmit}>
|
<Text>
|
||||||
<div className={styles.formGroup}>
|
Don't have an account?{" "}
|
||||||
<Input
|
<Link color href="/signup">
|
||||||
htmlType="text"
|
Sign up
|
||||||
id="username"
|
</Link>
|
||||||
value={username}
|
</Text>
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
) : (
|
||||||
placeholder="Username"
|
<Text>
|
||||||
required
|
Already have an account?{" "}
|
||||||
scale={4 / 3}
|
<Link color href="/signin">
|
||||||
/>
|
Sign in
|
||||||
<Input
|
</Link>
|
||||||
htmlType='password'
|
</Text>
|
||||||
id="password"
|
)}
|
||||||
value={password}
|
</div>
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
{errorMsg && (
|
||||||
placeholder="Password"
|
<Note scale={0.75} type="error">
|
||||||
required
|
{errorMsg}
|
||||||
scale={4 / 3}
|
</Note>
|
||||||
/>
|
)}
|
||||||
{requiresServerPassword && <Input
|
</form>
|
||||||
htmlType='password'
|
</div>
|
||||||
id="server-password"
|
</div>
|
||||||
value={serverPassword}
|
)
|
||||||
onChange={(event) => setServerPassword(event.target.value)}
|
|
||||||
placeholder="Server Password"
|
|
||||||
required
|
|
||||||
scale={4 / 3}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.formContentSpace}>
|
|
||||||
{signingIn ? (
|
|
||||||
<Text>
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link color href="/signup">Sign up</Link>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text>
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link color href="/signin">Sign in</Link>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{errorMsg && <Note scale={0.75} type='error'>{errorMsg}</Note>}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div >
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Auth
|
export default Auth
|
||||||
|
|
|
@ -1,22 +1,27 @@
|
||||||
import { Badge, Tooltip } from "@geist-ui/core";
|
import { Badge, Tooltip } from "@geist-ui/core"
|
||||||
import { timeAgo } from "@lib/time-ago";
|
import { timeAgo } from "@lib/time-ago"
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect } from "react"
|
||||||
|
|
||||||
const CreatedAgoBadge = ({ createdAt }: {
|
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
||||||
createdAt: string | Date;
|
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
||||||
}) => {
|
const [time, setTimeAgo] = useState(timeAgo(createdDate))
|
||||||
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
|
||||||
const [time, setTimeAgo] = useState(timeAgo(createdDate))
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setTimeAgo(timeAgo(createdDate))
|
setTimeAgo(timeAgo(createdDate))
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [createdDate])
|
}, [createdDate])
|
||||||
|
|
||||||
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
|
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
|
||||||
return (<Badge type="secondary"> <Tooltip hideArrow text={formattedTime}>Created {time}</Tooltip></Badge>)
|
return (
|
||||||
|
<Badge type="secondary">
|
||||||
|
{" "}
|
||||||
|
<Tooltip hideArrow text={formattedTime}>
|
||||||
|
Created {time}
|
||||||
|
</Tooltip>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CreatedAgoBadge
|
export default CreatedAgoBadge
|
||||||
|
|
|
@ -1,60 +1,66 @@
|
||||||
import { Badge, Tooltip } from "@geist-ui/core";
|
import { Badge, Tooltip } from "@geist-ui/core"
|
||||||
import { timeUntil } from "@lib/time-ago";
|
import { timeUntil } from "@lib/time-ago"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
const ExpirationBadge = ({
|
const ExpirationBadge = ({
|
||||||
postExpirationDate,
|
postExpirationDate
|
||||||
// onExpires
|
}: // onExpires
|
||||||
}: {
|
{
|
||||||
postExpirationDate: Date | string | null
|
postExpirationDate: Date | string | null
|
||||||
onExpires?: () => void
|
onExpires?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const expirationDate = useMemo(() => postExpirationDate ? new Date(postExpirationDate) : null, [postExpirationDate])
|
const expirationDate = useMemo(
|
||||||
const [timeUntilString, setTimeUntil] = useState<string | null>(expirationDate ? timeUntil(expirationDate) : null);
|
() => (postExpirationDate ? new Date(postExpirationDate) : null),
|
||||||
|
[postExpirationDate]
|
||||||
|
)
|
||||||
|
const [timeUntilString, setTimeUntil] = useState<string | null>(
|
||||||
|
expirationDate ? timeUntil(expirationDate) : null
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: NodeJS.Timer | null = null;
|
let interval: NodeJS.Timer | null = null
|
||||||
if (expirationDate) {
|
if (expirationDate) {
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
if (expirationDate) {
|
if (expirationDate) {
|
||||||
setTimeUntil(timeUntil(expirationDate))
|
setTimeUntil(timeUntil(expirationDate))
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [expirationDate])
|
}, [expirationDate])
|
||||||
|
|
||||||
const isExpired = useMemo(() => {
|
const isExpired = useMemo(() => {
|
||||||
return timeUntilString && timeUntilString === "in 0 seconds"
|
return timeUntilString && timeUntilString === "in 0 seconds"
|
||||||
}, [timeUntilString])
|
}, [timeUntilString])
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// // check if expired every
|
// // check if expired every
|
||||||
// if (isExpired) {
|
// if (isExpired) {
|
||||||
// if (onExpires) {
|
// if (onExpires) {
|
||||||
// onExpires();
|
// onExpires();
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }, [isExpired, onExpires])
|
// }, [isExpired, onExpires])
|
||||||
|
|
||||||
if (!expirationDate) {
|
if (!expirationDate) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge type={isExpired ? "error" : "warning"}>
|
<Badge type={isExpired ? "error" : "warning"}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
hideArrow
|
hideArrow
|
||||||
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
|
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
|
||||||
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
|
>
|
||||||
</Tooltip>
|
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
|
||||||
</Badge>
|
</Tooltip>
|
||||||
)
|
</Badge>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ExpirationBadge
|
export default ExpirationBadge
|
||||||
|
|
|
@ -2,22 +2,22 @@ import { Badge } from "@geist-ui/core"
|
||||||
import type { PostVisibility } from "@lib/types"
|
import type { PostVisibility } from "@lib/types"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visibility: PostVisibility
|
visibility: PostVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
const VisibilityBadge = ({ visibility }: Props) => {
|
const VisibilityBadge = ({ visibility }: Props) => {
|
||||||
const getBadgeType = () => {
|
const getBadgeType = () => {
|
||||||
switch (visibility) {
|
switch (visibility) {
|
||||||
case "public":
|
case "public":
|
||||||
return "success"
|
return "success"
|
||||||
case "private":
|
case "private":
|
||||||
return "warning"
|
return "warning"
|
||||||
case "unlisted":
|
case "unlisted":
|
||||||
return "default"
|
return "default"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<Badge type={getBadgeType()}>{visibility}</Badge>)
|
return <Badge type={getBadgeType()}>{visibility}</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisibilityBadge
|
export default VisibilityBadge
|
||||||
|
|
|
@ -1,116 +1,116 @@
|
||||||
import Button from "@components/button"
|
import Button from "@components/button"
|
||||||
import React, { useCallback, useEffect } from "react"
|
import React, { useCallback, useEffect } from "react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import styles from './dropdown.module.css'
|
import styles from "./dropdown.module.css"
|
||||||
import DownIcon from '@geist-ui/icons/arrowDown'
|
import DownIcon from "@geist-ui/icons/arrowDown"
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "primary" | "secondary"
|
type?: "primary" | "secondary"
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
iconHeight?: number
|
iconHeight?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Attrs = Omit<React.HTMLAttributes<any>, keyof Props>
|
type Attrs = Omit<React.HTMLAttributes<any>, keyof Props>
|
||||||
type ButtonDropdownProps = Props & Attrs
|
type ButtonDropdownProps = Props & Attrs
|
||||||
|
|
||||||
const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = ({
|
const ButtonDropdown: React.FC<
|
||||||
type,
|
React.PropsWithChildren<ButtonDropdownProps>
|
||||||
className,
|
> = ({ type, className, disabled, loading, iconHeight = 24, ...props }) => {
|
||||||
disabled,
|
const [visible, setVisible] = useState(false)
|
||||||
loading,
|
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
|
||||||
iconHeight = 24,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.nativeEvent.stopImmediatePropagation()
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
setVisible(!visible)
|
setVisible(!visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.nativeEvent.stopImmediatePropagation()
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.nativeEvent.stopImmediatePropagation()
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.nativeEvent.stopImmediatePropagation()
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickOutside = useCallback(() => (e: React.MouseEvent<HTMLDivElement>) => {
|
const onClickOutside = useCallback(
|
||||||
if (dropdown && !dropdown.contains(e.target as Node)) {
|
() => (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
setVisible(false)
|
if (dropdown && !dropdown.contains(e.target as Node)) {
|
||||||
}
|
setVisible(false)
|
||||||
}, [dropdown])
|
}
|
||||||
|
},
|
||||||
|
[dropdown]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
document.addEventListener("mousedown", onClickOutside)
|
document.addEventListener("mousedown", onClickOutside)
|
||||||
} else {
|
} else {
|
||||||
document.removeEventListener("mousedown", onClickOutside)
|
document.removeEventListener("mousedown", onClickOutside)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onClickOutside)
|
|
||||||
}
|
|
||||||
}, [visible, onClickOutside])
|
|
||||||
|
|
||||||
if (!Array.isArray(props.children)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${styles.main} ${className}`}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onBlur={onBlur}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'flex-end' }}>
|
|
||||||
{props.children[0]}
|
|
||||||
<Button style={{ height: iconHeight, width: iconHeight }} className={styles.icon} onClick={() => setVisible(!visible)}><DownIcon /></Button>
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
visible && (
|
|
||||||
<div
|
|
||||||
className={`${styles.dropdown}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${styles.dropdownContent}`}
|
|
||||||
>
|
|
||||||
{props.children.slice(1)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div >
|
|
||||||
)
|
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside)
|
||||||
|
}
|
||||||
|
}, [visible, onClickOutside])
|
||||||
|
|
||||||
|
if (!Array.isArray(props.children)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.main} ${className}`}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children[0]}
|
||||||
|
<Button
|
||||||
|
style={{ height: iconHeight, width: iconHeight }}
|
||||||
|
className={styles.icon}
|
||||||
|
onClick={() => setVisible(!visible)}
|
||||||
|
>
|
||||||
|
<DownIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{visible && (
|
||||||
|
<div className={`${styles.dropdown}`}>
|
||||||
|
<div className={`${styles.dropdownContent}`}>
|
||||||
|
{props.children.slice(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ButtonDropdown
|
export default ButtonDropdown
|
||||||
|
|
|
@ -1,28 +1,39 @@
|
||||||
import styles from './button.module.css'
|
import styles from "./button.module.css"
|
||||||
import { forwardRef, Ref } from 'react'
|
import { forwardRef, Ref } from "react"
|
||||||
|
|
||||||
type Props = React.HTMLProps<HTMLButtonElement> & {
|
type Props = React.HTMLProps<HTMLButtonElement> & {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
buttonType?: 'primary' | 'secondary'
|
buttonType?: "primary" | "secondary"
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
const Button = forwardRef<HTMLButtonElement, Props>(
|
const Button = forwardRef<HTMLButtonElement, Props>(
|
||||||
({ children, onClick, className, buttonType = 'primary', type = 'button', disabled = false, ...props }, ref) => {
|
(
|
||||||
return (
|
{
|
||||||
<button
|
children,
|
||||||
ref={ref}
|
onClick,
|
||||||
className={`${styles.button} ${styles[type]} ${className}`}
|
className,
|
||||||
disabled={disabled}
|
buttonType = "primary",
|
||||||
onClick={onClick}
|
type = "button",
|
||||||
{...props}
|
disabled = false,
|
||||||
>
|
...props
|
||||||
{children}
|
},
|
||||||
</button>
|
ref
|
||||||
)
|
) => {
|
||||||
}
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`${styles.button} ${styles[type]} ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default Button
|
export default Button
|
||||||
|
|
|
@ -2,34 +2,44 @@ import type { Document } from "@lib/types"
|
||||||
import DocumentComponent from "@components/edit-document"
|
import DocumentComponent from "@components/edit-document"
|
||||||
import { ChangeEvent, memo, useCallback } from "react"
|
import { ChangeEvent, memo, useCallback } from "react"
|
||||||
|
|
||||||
const DocumentList = ({ docs, removeDoc, updateDocContent, updateDocTitle, onPaste }: {
|
const DocumentList = ({
|
||||||
docs: Document[],
|
docs,
|
||||||
updateDocTitle: (i: number) => (title: string) => void
|
removeDoc,
|
||||||
updateDocContent: (i: number) => (content: string) => void
|
updateDocContent,
|
||||||
removeDoc: (i: number) => () => void
|
updateDocTitle,
|
||||||
onPaste: (e: any) => void
|
onPaste
|
||||||
|
}: {
|
||||||
|
docs: Document[]
|
||||||
|
updateDocTitle: (i: number) => (title: string) => void
|
||||||
|
updateDocContent: (i: number) => (content: string) => void
|
||||||
|
removeDoc: (i: number) => () => void
|
||||||
|
onPaste: (e: any) => void
|
||||||
}) => {
|
}) => {
|
||||||
const handleOnChange = useCallback((i) => (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleOnChange = useCallback(
|
||||||
updateDocContent(i)(e.target.value)
|
(i) => (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
}, [updateDocContent])
|
updateDocContent(i)(e.target.value)
|
||||||
|
},
|
||||||
|
[updateDocContent]
|
||||||
|
)
|
||||||
|
|
||||||
return (<>{
|
return (
|
||||||
docs.map(({ content, id, title }, i) => {
|
<>
|
||||||
return (
|
{docs.map(({ content, id, title }, i) => {
|
||||||
<DocumentComponent
|
return (
|
||||||
onPaste={onPaste}
|
<DocumentComponent
|
||||||
key={id}
|
onPaste={onPaste}
|
||||||
remove={removeDoc(i)}
|
key={id}
|
||||||
setContent={updateDocContent(i)}
|
remove={removeDoc(i)}
|
||||||
setTitle={updateDocTitle(i)}
|
setContent={updateDocContent(i)}
|
||||||
handleOnContentChange={handleOnChange(i)}
|
setTitle={updateDocTitle(i)}
|
||||||
content={content}
|
handleOnContentChange={handleOnChange(i)}
|
||||||
title={title}
|
content={content}
|
||||||
/>
|
title={title}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
}
|
})}
|
||||||
</>)
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(DocumentList)
|
export default memo(DocumentList)
|
||||||
|
|
|
@ -1,131 +1,148 @@
|
||||||
import Bold from '@geist-ui/icons/bold'
|
import Bold from "@geist-ui/icons/bold"
|
||||||
import Italic from '@geist-ui/icons/italic'
|
import Italic from "@geist-ui/icons/italic"
|
||||||
import Link from '@geist-ui/icons/link'
|
import Link from "@geist-ui/icons/link"
|
||||||
import ImageIcon from '@geist-ui/icons/image'
|
import ImageIcon from "@geist-ui/icons/image"
|
||||||
import { RefObject, useCallback, useMemo } from "react"
|
import { RefObject, useCallback, useMemo } from "react"
|
||||||
import styles from '../document.module.css'
|
import styles from "../document.module.css"
|
||||||
import { Button, ButtonGroup } from "@geist-ui/core"
|
import { Button, ButtonGroup } from "@geist-ui/core"
|
||||||
|
|
||||||
// TODO: clean up
|
// TODO: clean up
|
||||||
|
|
||||||
const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTMLTextAreaElement>, setText?: (text: string) => void }) => {
|
const FormattingIcons = ({
|
||||||
// const { textBefore, textAfter, selectedText } = useMemo(() => {
|
textareaRef,
|
||||||
// if (textareaRef && textareaRef.current) {
|
setText
|
||||||
// const textarea = textareaRef.current
|
}: {
|
||||||
// const text = textareaRef.current.value
|
textareaRef?: RefObject<HTMLTextAreaElement>
|
||||||
// const selectionStart = textarea.selectionStart
|
setText?: (text: string) => void
|
||||||
// const selectionEnd = textarea.selectionEnd
|
}) => {
|
||||||
// const textBefore = text.substring(0, selectionStart)
|
// const { textBefore, textAfter, selectedText } = useMemo(() => {
|
||||||
// const textAfter = text.substring(selectionEnd)
|
// if (textareaRef && textareaRef.current) {
|
||||||
// const selectedText = text.substring(selectionStart, selectionEnd)
|
// const textarea = textareaRef.current
|
||||||
// return { textBefore, textAfter, selectedText }
|
// const text = textareaRef.current.value
|
||||||
// }
|
// const selectionStart = textarea.selectionStart
|
||||||
// return { textBefore: '', textAfter: '' }
|
// const selectionEnd = textarea.selectionEnd
|
||||||
// }, [textareaRef,])
|
// const textBefore = text.substring(0, selectionStart)
|
||||||
|
// const textAfter = text.substring(selectionEnd)
|
||||||
|
// const selectedText = text.substring(selectionStart, selectionEnd)
|
||||||
|
// return { textBefore, textAfter, selectedText }
|
||||||
|
// }
|
||||||
|
// return { textBefore: '', textAfter: '' }
|
||||||
|
// }, [textareaRef,])
|
||||||
|
|
||||||
const handleBoldClick = useCallback(() => {
|
const handleBoldClick = useCallback(() => {
|
||||||
if (textareaRef?.current && setText) {
|
if (textareaRef?.current && setText) {
|
||||||
const selectionStart = textareaRef.current.selectionStart
|
const selectionStart = textareaRef.current.selectionStart
|
||||||
const selectionEnd = textareaRef.current.selectionEnd
|
const selectionEnd = textareaRef.current.selectionEnd
|
||||||
const text = textareaRef.current.value
|
const text = textareaRef.current.value
|
||||||
const before = text.substring(0, selectionStart)
|
const before = text.substring(0, selectionStart)
|
||||||
const after = text.substring(selectionEnd)
|
const after = text.substring(selectionEnd)
|
||||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||||
|
|
||||||
const newText = `${before}**${selectedText}**${after}`
|
const newText = `${before}**${selectedText}**${after}`
|
||||||
setText(newText)
|
setText(newText)
|
||||||
}
|
}
|
||||||
}, [setText, textareaRef])
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
const handleItalicClick = useCallback(() => {
|
const handleItalicClick = useCallback(() => {
|
||||||
if (textareaRef?.current && setText) {
|
if (textareaRef?.current && setText) {
|
||||||
const selectionStart = textareaRef.current.selectionStart
|
const selectionStart = textareaRef.current.selectionStart
|
||||||
const selectionEnd = textareaRef.current.selectionEnd
|
const selectionEnd = textareaRef.current.selectionEnd
|
||||||
const text = textareaRef.current.value
|
const text = textareaRef.current.value
|
||||||
const before = text.substring(0, selectionStart)
|
const before = text.substring(0, selectionStart)
|
||||||
const after = text.substring(selectionEnd)
|
const after = text.substring(selectionEnd)
|
||||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||||
const newText = `${before}*${selectedText}*${after}`
|
const newText = `${before}*${selectedText}*${after}`
|
||||||
setText(newText)
|
setText(newText)
|
||||||
}
|
}
|
||||||
}, [setText, textareaRef])
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
const handleLinkClick = useCallback(() => {
|
const handleLinkClick = useCallback(() => {
|
||||||
if (textareaRef?.current && setText) {
|
if (textareaRef?.current && setText) {
|
||||||
const selectionStart = textareaRef.current.selectionStart
|
const selectionStart = textareaRef.current.selectionStart
|
||||||
const selectionEnd = textareaRef.current.selectionEnd
|
const selectionEnd = textareaRef.current.selectionEnd
|
||||||
const text = textareaRef.current.value
|
const text = textareaRef.current.value
|
||||||
const before = text.substring(0, selectionStart)
|
const before = text.substring(0, selectionStart)
|
||||||
const after = text.substring(selectionEnd)
|
const after = text.substring(selectionEnd)
|
||||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||||
let formattedText = '';
|
let formattedText = ""
|
||||||
if (selectedText.includes('http')) {
|
if (selectedText.includes("http")) {
|
||||||
formattedText = `[](${selectedText})`
|
formattedText = `[](${selectedText})`
|
||||||
} else {
|
} else {
|
||||||
formattedText = `[${selectedText}](https://)`
|
formattedText = `[${selectedText}](https://)`
|
||||||
}
|
}
|
||||||
const newText = `${before}${formattedText}${after}`
|
const newText = `${before}${formattedText}${after}`
|
||||||
setText(newText)
|
setText(newText)
|
||||||
}
|
}
|
||||||
}, [setText, textareaRef])
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
const handleImageClick = useCallback(() => {
|
const handleImageClick = useCallback(() => {
|
||||||
if (textareaRef?.current && setText) {
|
if (textareaRef?.current && setText) {
|
||||||
const selectionStart = textareaRef.current.selectionStart
|
const selectionStart = textareaRef.current.selectionStart
|
||||||
const selectionEnd = textareaRef.current.selectionEnd
|
const selectionEnd = textareaRef.current.selectionEnd
|
||||||
const text = textareaRef.current.value
|
const text = textareaRef.current.value
|
||||||
const before = text.substring(0, selectionStart)
|
const before = text.substring(0, selectionStart)
|
||||||
const after = text.substring(selectionEnd)
|
const after = text.substring(selectionEnd)
|
||||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||||
let formattedText = '';
|
let formattedText = ""
|
||||||
if (selectedText.includes('http')) {
|
if (selectedText.includes("http")) {
|
||||||
formattedText = `![](${selectedText})`
|
formattedText = `![](${selectedText})`
|
||||||
} else {
|
} else {
|
||||||
formattedText = `![${selectedText}](https://)`
|
formattedText = `![${selectedText}](https://)`
|
||||||
}
|
}
|
||||||
const newText = `${before}${formattedText}${after}`
|
const newText = `${before}${formattedText}${after}`
|
||||||
setText(newText)
|
setText(newText)
|
||||||
}
|
}
|
||||||
}, [setText, textareaRef])
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
const formattingActions = useMemo(() => [
|
const formattingActions = useMemo(
|
||||||
{
|
() => [
|
||||||
icon: <Bold />,
|
{
|
||||||
name: 'bold',
|
icon: <Bold />,
|
||||||
action: handleBoldClick
|
name: "bold",
|
||||||
},
|
action: handleBoldClick
|
||||||
{
|
},
|
||||||
icon: <Italic />,
|
{
|
||||||
name: 'italic',
|
icon: <Italic />,
|
||||||
action: handleItalicClick
|
name: "italic",
|
||||||
},
|
action: handleItalicClick
|
||||||
// {
|
},
|
||||||
// icon: <Underline />,
|
// {
|
||||||
// name: 'underline',
|
// icon: <Underline />,
|
||||||
// action: handleUnderlineClick
|
// name: 'underline',
|
||||||
// },
|
// action: handleUnderlineClick
|
||||||
{
|
// },
|
||||||
icon: <Link />,
|
{
|
||||||
name: 'hyperlink',
|
icon: <Link />,
|
||||||
action: handleLinkClick
|
name: "hyperlink",
|
||||||
},
|
action: handleLinkClick
|
||||||
{
|
},
|
||||||
icon: <ImageIcon />,
|
{
|
||||||
name: 'image',
|
icon: <ImageIcon />,
|
||||||
action: handleImageClick
|
name: "image",
|
||||||
}
|
action: handleImageClick
|
||||||
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
|
}
|
||||||
|
],
|
||||||
return (
|
[handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]
|
||||||
<div className={styles.actionWrapper}>
|
)
|
||||||
<ButtonGroup className={styles.actions}>
|
|
||||||
{formattingActions.map(({ icon, name, action }) => (
|
|
||||||
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
|
|
||||||
))}
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.actionWrapper}>
|
||||||
|
<ButtonGroup className={styles.actions}>
|
||||||
|
{formattingActions.map(({ icon, name, action }) => (
|
||||||
|
<Button
|
||||||
|
auto
|
||||||
|
scale={2 / 3}
|
||||||
|
px={0.6}
|
||||||
|
aria-label={name}
|
||||||
|
key={name}
|
||||||
|
icon={icon}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FormattingIcons
|
export default FormattingIcons
|
||||||
|
|
|
@ -1,118 +1,172 @@
|
||||||
|
import {
|
||||||
|
ChangeEvent,
|
||||||
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
|
memo,
|
||||||
import styles from './document.module.css'
|
useCallback,
|
||||||
import Trash from '@geist-ui/icons/trash'
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react"
|
||||||
|
import styles from "./document.module.css"
|
||||||
|
import Trash from "@geist-ui/icons/trash"
|
||||||
import FormattingIcons from "./formatting-icons"
|
import FormattingIcons from "./formatting-icons"
|
||||||
|
|
||||||
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
Spacer,
|
||||||
|
Tabs,
|
||||||
|
Textarea,
|
||||||
|
Tooltip
|
||||||
|
} from "@geist-ui/core"
|
||||||
import Preview from "@components/preview"
|
import Preview from "@components/preview"
|
||||||
|
|
||||||
// import Link from "next/link"
|
// import Link from "next/link"
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string
|
title?: string
|
||||||
content?: string
|
content?: string
|
||||||
setTitle?: (title: string) => void
|
setTitle?: (title: string) => void
|
||||||
setContent?: (content: string) => void
|
setContent?: (content: string) => void
|
||||||
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
initialTab?: "edit" | "preview"
|
initialTab?: "edit" | "preview"
|
||||||
remove?: () => void
|
remove?: () => void
|
||||||
onPaste?: (e: any) => void
|
onPaste?: (e: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => {
|
const Document = ({
|
||||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
onPaste,
|
||||||
const [tab, setTab] = useState(initialTab)
|
remove,
|
||||||
// const height = editable ? "500px" : '100%'
|
title,
|
||||||
const height = "100%";
|
content,
|
||||||
|
setTitle,
|
||||||
|
setContent,
|
||||||
|
initialTab = "edit",
|
||||||
|
handleOnContentChange
|
||||||
|
}: Props) => {
|
||||||
|
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [tab, setTab] = useState(initialTab)
|
||||||
|
// const height = editable ? "500px" : '100%'
|
||||||
|
const height = "100%"
|
||||||
|
|
||||||
const handleTabChange = (newTab: string) => {
|
const handleTabChange = (newTab: string) => {
|
||||||
if (newTab === 'edit') {
|
if (newTab === "edit") {
|
||||||
codeEditorRef.current?.focus()
|
codeEditorRef.current?.focus()
|
||||||
}
|
}
|
||||||
setTab(newTab as 'edit' | 'preview')
|
setTab(newTab as "edit" | "preview")
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTitleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null, [setTitle])
|
const onTitleChange = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setTitle ? setTitle(event.target.value) : null,
|
||||||
|
[setTitle]
|
||||||
|
)
|
||||||
|
|
||||||
const removeFile = useCallback((remove?: () => void) => {
|
const removeFile = useCallback(
|
||||||
if (remove) {
|
(remove?: () => void) => {
|
||||||
if (content && content.trim().length > 0) {
|
if (remove) {
|
||||||
const confirmed = window.confirm("Are you sure you want to remove this file?")
|
if (content && content.trim().length > 0) {
|
||||||
if (confirmed) {
|
const confirmed = window.confirm(
|
||||||
remove()
|
"Are you sure you want to remove this file?"
|
||||||
}
|
)
|
||||||
} else {
|
if (confirmed) {
|
||||||
remove()
|
remove()
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}, [content])
|
remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[content]
|
||||||
|
)
|
||||||
|
|
||||||
// if (skeleton) {
|
// if (skeleton) {
|
||||||
// return <>
|
// return <>
|
||||||
// <Spacer height={1} />
|
// <Spacer height={1} />
|
||||||
// <div className={styles.card}>
|
// <div className={styles.card}>
|
||||||
// <div className={styles.fileNameContainer}>
|
// <div className={styles.fileNameContainer}>
|
||||||
// <Skeleton width={275} height={36} />
|
// <Skeleton width={275} height={36} />
|
||||||
// {remove && <Skeleton width={36} height={36} />}
|
// {remove && <Skeleton width={36} height={36} />}
|
||||||
// </div>
|
// </div>
|
||||||
// <div className={styles.descriptionContainer}>
|
// <div className={styles.descriptionContainer}>
|
||||||
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
||||||
// <Skeleton width={'100%'} height={350} />
|
// <Skeleton width={'100%'} height={350} />
|
||||||
// </div >
|
// </div >
|
||||||
// </div>
|
// </div>
|
||||||
// </>
|
// </>
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spacer height={1} />
|
<Spacer height={1} />
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.fileNameContainer}>
|
<div className={styles.fileNameContainer}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="MyFile.md"
|
placeholder="MyFile.md"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={onTitleChange}
|
onChange={onTitleChange}
|
||||||
marginTop="var(--gap-double)"
|
marginTop="var(--gap-double)"
|
||||||
size={1.2}
|
size={1.2}
|
||||||
font={1.2}
|
font={1.2}
|
||||||
label="Filename"
|
label="Filename"
|
||||||
width={"100%"}
|
width={"100%"}
|
||||||
id={title}
|
id={title}
|
||||||
/>
|
/>
|
||||||
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
|
{remove && (
|
||||||
</div>
|
<Button
|
||||||
<div className={styles.descriptionContainer}>
|
type="abort"
|
||||||
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
|
ghost
|
||||||
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
icon={<Trash />}
|
||||||
<Tabs.Item label={"Edit"} value="edit">
|
auto
|
||||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
height={"36px"}
|
||||||
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
|
width={"36px"}
|
||||||
<Textarea
|
onClick={() => removeFile(remove)}
|
||||||
onPaste={onPaste ? onPaste : undefined}
|
/>
|
||||||
ref={codeEditorRef}
|
)}
|
||||||
placeholder=""
|
</div>
|
||||||
value={content}
|
<div className={styles.descriptionContainer}>
|
||||||
onChange={handleOnContentChange}
|
{tab === "edit" && (
|
||||||
width="100%"
|
<FormattingIcons setText={setContent} textareaRef={codeEditorRef} />
|
||||||
// TODO: Textarea should grow to fill parent if height == 100%
|
)}
|
||||||
style={{ flex: 1, minHeight: 350 }}
|
<Tabs
|
||||||
resize="vertical"
|
onChange={handleTabChange}
|
||||||
className={styles.textarea}
|
initialValue={initialTab}
|
||||||
/>
|
hideDivider
|
||||||
</div>
|
leftSpace={0}
|
||||||
</Tabs.Item>
|
>
|
||||||
<Tabs.Item label="Preview" value="preview">
|
<Tabs.Item label={"Edit"} value="edit">
|
||||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||||
<Preview height={height} title={title} content={content} />
|
<div
|
||||||
</div>
|
style={{
|
||||||
</Tabs.Item>
|
marginTop: "var(--gap-half)",
|
||||||
</Tabs>
|
display: "flex",
|
||||||
</div >
|
flexDirection: "column"
|
||||||
</div >
|
}}
|
||||||
</>
|
>
|
||||||
)
|
<Textarea
|
||||||
|
onPaste={onPaste ? onPaste : undefined}
|
||||||
|
ref={codeEditorRef}
|
||||||
|
placeholder=""
|
||||||
|
value={content}
|
||||||
|
onChange={handleOnContentChange}
|
||||||
|
width="100%"
|
||||||
|
// TODO: Textarea should grow to fill parent if height == 100%
|
||||||
|
style={{ flex: 1, minHeight: 350 }}
|
||||||
|
resize="vertical"
|
||||||
|
className={styles.textarea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Item>
|
||||||
|
<Tabs.Item label="Preview" value="preview">
|
||||||
|
<div style={{ marginTop: "var(--gap-half)" }}>
|
||||||
|
<Preview height={height} title={title} content={content} />
|
||||||
|
</div>
|
||||||
|
</Tabs.Item>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(Document)
|
||||||
export default memo(Document)
|
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
import { Page } from '@geist-ui/core'
|
import { Page } from "@geist-ui/core"
|
||||||
|
|
||||||
const Error = ({ status }: {
|
const Error = ({ status }: { status: number }) => {
|
||||||
status: number
|
return (
|
||||||
}) => {
|
<Page title={status.toString() || "Error"}>
|
||||||
return (
|
{status === 404 ? (
|
||||||
<Page title={status.toString() || 'Error'}>
|
<h1>This page cannot be found.</h1>
|
||||||
{status === 404 ? (
|
) : (
|
||||||
<h1>This page cannot be found.</h1>
|
<section>
|
||||||
) : (
|
<p>An error occurred: {status}</p>
|
||||||
<section>
|
</section>
|
||||||
<p>An error occurred: {status}</p>
|
)}
|
||||||
</section>
|
</Page>
|
||||||
)}
|
)
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Error
|
export default Error
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
|
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
|
||||||
import styles from './fade.module.css';
|
import styles from "./fade.module.css"
|
||||||
|
|
||||||
const FadeIn = ({
|
const FadeIn = ({
|
||||||
duration = 300,
|
duration = 300,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
children,
|
children,
|
||||||
...delegated
|
...delegated
|
||||||
}: {
|
}: {
|
||||||
duration?: number;
|
duration?: number
|
||||||
delay?: number;
|
delay?: number
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
[key: string]: any;
|
[key: string]: any
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...delegated}
|
{...delegated}
|
||||||
className={styles.fadeIn}
|
className={styles.fadeIn}
|
||||||
style={{
|
style={{
|
||||||
...(delegated.style || {}),
|
...(delegated.style || {}),
|
||||||
animationDuration: duration + 'ms',
|
animationDuration: duration + "ms",
|
||||||
animationDelay: delay + 'ms',
|
animationDelay: delay + "ms"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FadeIn
|
export default FadeIn
|
||||||
|
|
|
@ -1,83 +1,101 @@
|
||||||
import { Button, Link, Text, Popover } from '@geist-ui/core'
|
import { Button, Link, Text, Popover } from "@geist-ui/core"
|
||||||
import FileIcon from '@geist-ui/icons/fileText'
|
import FileIcon from "@geist-ui/icons/fileText"
|
||||||
import CodeIcon from '@geist-ui/icons/fileFunction'
|
import CodeIcon from "@geist-ui/icons/fileFunction"
|
||||||
import styles from './dropdown.module.css'
|
import styles from "./dropdown.module.css"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { codeFileExtensions } from "@lib/constants"
|
import { codeFileExtensions } from "@lib/constants"
|
||||||
import ChevronDown from '@geist-ui/icons/chevronDown'
|
import ChevronDown from "@geist-ui/icons/chevronDown"
|
||||||
import ShiftBy from "@components/shift-by"
|
import ShiftBy from "@components/shift-by"
|
||||||
import type { File } from '@lib/types'
|
import type { File } from "@lib/types"
|
||||||
|
|
||||||
type Item = File & {
|
type Item = File & {
|
||||||
icon: JSX.Element
|
icon: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileDropdown = ({
|
const FileDropdown = ({
|
||||||
files,
|
files,
|
||||||
isMobile
|
isMobile
|
||||||
}: {
|
}: {
|
||||||
files: File[],
|
files: File[]
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [items, setItems] = useState<Item[]>([])
|
const [items, setItems] = useState<Item[]>([])
|
||||||
const changeHandler = (next: boolean) => {
|
const changeHandler = (next: boolean) => {
|
||||||
setExpanded(next)
|
setExpanded(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOpen = useCallback(() => {
|
const onOpen = useCallback(() => {
|
||||||
setExpanded(true)
|
setExpanded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
setExpanded(false)
|
setExpanded(false)
|
||||||
// contentRef.current?.focus()
|
// contentRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newItems = files.map(file => {
|
const newItems = files.map((file) => {
|
||||||
const extension = file.title.split('.').pop()
|
const extension = file.title.split(".").pop()
|
||||||
if (codeFileExtensions.includes(extension || '')) {
|
if (codeFileExtensions.includes(extension || "")) {
|
||||||
return {
|
return {
|
||||||
...file,
|
...file,
|
||||||
icon: <CodeIcon />
|
icon: <CodeIcon />
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...file,
|
...file,
|
||||||
icon: <FileIcon />
|
icon: <FileIcon />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setItems(newItems)
|
setItems(newItems)
|
||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
const content = useCallback(() => (<ul className={styles.content}>
|
const content = useCallback(
|
||||||
{items.map(item => (
|
() => (
|
||||||
<li key={item.id} onClick={onClose}>
|
<ul className={styles.content}>
|
||||||
<a href={`#${item.title}`}>
|
{items.map((item) => (
|
||||||
<ShiftBy y={5}><span className={styles.fileIcon}>
|
<li key={item.id} onClick={onClose}>
|
||||||
{item.icon}</span></ShiftBy>
|
<a href={`#${item.title}`}>
|
||||||
<span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span>
|
<ShiftBy y={5}>
|
||||||
</a>
|
<span className={styles.fileIcon}>{item.icon}</span>
|
||||||
</li>
|
</ShiftBy>
|
||||||
))}
|
<span className={styles.fileTitle}>
|
||||||
</ul>
|
{item.title ? item.title : "Untitled"}
|
||||||
), [items, onClose])
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
[items, onClose]
|
||||||
|
)
|
||||||
|
|
||||||
// a list of files with an icon and a title
|
// a list of files with an icon and a title
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />} style={{ textTransform: 'none' }} >
|
<Button
|
||||||
Jump to {files.length} {files.length === 1 ? 'file' : 'files'}
|
auto
|
||||||
</Button>
|
onClick={onOpen}
|
||||||
<Popover
|
className={styles.button}
|
||||||
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }}
|
iconRight={<ChevronDown />}
|
||||||
onVisibleChange={changeHandler}
|
style={{ textTransform: "none" }}
|
||||||
content={content} visible={expanded} hideArrow={true} onClick={onClose} />
|
>
|
||||||
</>
|
Jump to {files.length} {files.length === 1 ? "file" : "files"}
|
||||||
|
</Button>
|
||||||
)
|
<Popover
|
||||||
|
style={{
|
||||||
|
transform: isMobile ? "translateX(110px)" : "translateX(-75px)"
|
||||||
|
}}
|
||||||
|
onVisibleChange={changeHandler}
|
||||||
|
content={content}
|
||||||
|
visible={expanded}
|
||||||
|
hideArrow={true}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileDropdown
|
export default FileDropdown
|
||||||
|
|
|
@ -1,61 +1,58 @@
|
||||||
import { File } from "@lib/types"
|
import { File } from "@lib/types"
|
||||||
import { Card, Link, Text } from '@geist-ui/core'
|
import { Card, Link, Text } from "@geist-ui/core"
|
||||||
import FileIcon from '@geist-ui/icons/fileText'
|
import FileIcon from "@geist-ui/icons/fileText"
|
||||||
import CodeIcon from '@geist-ui/icons/fileLambda'
|
import CodeIcon from "@geist-ui/icons/fileLambda"
|
||||||
import styles from './file-tree.module.css'
|
import styles from "./file-tree.module.css"
|
||||||
import ShiftBy from "@components/shift-by"
|
import ShiftBy from "@components/shift-by"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { codeFileExtensions } from "@lib/constants"
|
import { codeFileExtensions } from "@lib/constants"
|
||||||
|
|
||||||
type Item = File & {
|
type Item = File & {
|
||||||
icon: JSX.Element
|
icon: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileTree = ({
|
const FileTree = ({ files }: { files: File[] }) => {
|
||||||
files
|
const [items, setItems] = useState<Item[]>([])
|
||||||
}: {
|
useEffect(() => {
|
||||||
files: File[]
|
const newItems = files.map((file) => {
|
||||||
}) => {
|
const extension = file.title.split(".").pop()
|
||||||
const [items, setItems] = useState<Item[]>([])
|
if (codeFileExtensions.includes(extension || "")) {
|
||||||
useEffect(() => {
|
return {
|
||||||
const newItems = files.map(file => {
|
...file,
|
||||||
const extension = file.title.split('.').pop()
|
icon: <CodeIcon />
|
||||||
if (codeFileExtensions.includes(extension || '')) {
|
}
|
||||||
return {
|
} else {
|
||||||
...file,
|
return {
|
||||||
icon: <CodeIcon />
|
...file,
|
||||||
}
|
icon: <FileIcon />
|
||||||
} else {
|
}
|
||||||
return {
|
}
|
||||||
...file,
|
})
|
||||||
icon: <FileIcon />
|
setItems(newItems)
|
||||||
}
|
}, [files])
|
||||||
}
|
|
||||||
})
|
|
||||||
setItems(newItems)
|
|
||||||
}, [files])
|
|
||||||
|
|
||||||
// a list of files with an icon and a title
|
// a list of files with an icon and a title
|
||||||
return (
|
return (
|
||||||
<div className={styles.fileTreeWrapper}>
|
<div className={styles.fileTreeWrapper}>
|
||||||
<Card height={'100%'} className={styles.card}>
|
<Card height={"100%"} className={styles.card}>
|
||||||
<div className={styles.cardContent}>
|
<div className={styles.cardContent}>
|
||||||
<Text h4>Files</Text>
|
<Text h4>Files</Text>
|
||||||
<ul className={styles.fileTree}>
|
<ul className={styles.fileTree}>
|
||||||
{items.map(({ id, title, icon }) => (
|
{items.map(({ id, title, icon }) => (
|
||||||
<li key={id}>
|
<li key={id}>
|
||||||
<Link color={false} href={`#${title}`}>
|
<Link color={false} href={`#${title}`}>
|
||||||
<ShiftBy y={5}><span className={styles.fileTreeIcon}>
|
<ShiftBy y={5}>
|
||||||
{icon}</span></ShiftBy>
|
<span className={styles.fileTreeIcon}>{icon}</span>
|
||||||
<span className={styles.fileTreeTitle}>{title}</span>
|
</ShiftBy>
|
||||||
</Link>
|
<span className={styles.fileTreeTitle}>{title}</span>
|
||||||
</li>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</Card>
|
</div>
|
||||||
</div >
|
</Card>
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileTree
|
export default FileTree
|
||||||
|
|
|
@ -1,27 +1,26 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head"
|
||||||
import React from "react";
|
import React from "react"
|
||||||
|
|
||||||
type PageSeoProps = {
|
type PageSeoProps = {
|
||||||
title?: string;
|
title?: string
|
||||||
description?: string;
|
description?: string
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
isPrivate?: boolean
|
isPrivate?: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
const PageSeo = ({
|
const PageSeo = ({
|
||||||
title = 'Drift',
|
title = "Drift",
|
||||||
description = "A self-hostable clone of GitHub Gist",
|
description = "A self-hostable clone of GitHub Gist",
|
||||||
isPrivate = false
|
isPrivate = false
|
||||||
}: PageSeoProps) => {
|
}: PageSeoProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{title}</title>
|
||||||
|
{!isPrivate && <meta name="description" content={description} />}
|
||||||
|
</Head>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export default PageSeo
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{title}</title>
|
|
||||||
{!isPrivate && <meta name="description" content={description} />}
|
|
||||||
</Head>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSeo;
|
|
||||||
|
|
|
@ -1,46 +1,46 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from "react"
|
||||||
import MoonIcon from '@geist-ui/icons/moon'
|
import MoonIcon from "@geist-ui/icons/moon"
|
||||||
import SunIcon from '@geist-ui/icons/sun'
|
import SunIcon from "@geist-ui/icons/sun"
|
||||||
// import { useAllThemes, useTheme } from '@geist-ui/core'
|
// import { useAllThemes, useTheme } from '@geist-ui/core'
|
||||||
import styles from './header.module.css'
|
import styles from "./header.module.css"
|
||||||
import { Select } from '@geist-ui/core'
|
import { Select } from "@geist-ui/core"
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
const Controls = () => {
|
const Controls = () => {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
const switchThemes = () => {
|
const switchThemes = () => {
|
||||||
if (resolvedTheme === 'dark') {
|
if (resolvedTheme === "dark") {
|
||||||
setTheme('light')
|
setTheme("light")
|
||||||
} else {
|
} else {
|
||||||
setTheme('dark')
|
setTheme("dark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<Select
|
<Select
|
||||||
scale={0.5}
|
scale={0.5}
|
||||||
h="28px"
|
h="28px"
|
||||||
pure
|
pure
|
||||||
onChange={switchThemes}
|
onChange={switchThemes}
|
||||||
value={resolvedTheme}
|
value={resolvedTheme}
|
||||||
>
|
>
|
||||||
<Select.Option value="light">
|
<Select.Option value="light">
|
||||||
<span className={styles.selectContent}>
|
<span className={styles.selectContent}>
|
||||||
<SunIcon size={14} /> Light
|
<SunIcon size={14} /> Light
|
||||||
</span>
|
</span>
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
<Select.Option value="dark">
|
<Select.Option value="dark">
|
||||||
<span className={styles.selectContent}>
|
<span className={styles.selectContent}>
|
||||||
<MoonIcon size={14} /> Dark
|
<MoonIcon size={14} /> Dark
|
||||||
</span>
|
</span>
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div >
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(Controls);
|
export default React.memo(Controls)
|
||||||
|
|
|
@ -1,207 +1,225 @@
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
Button,
|
||||||
|
Page,
|
||||||
|
Spacer,
|
||||||
|
useBodyScroll,
|
||||||
|
useMediaQuery
|
||||||
|
} from "@geist-ui/core"
|
||||||
|
|
||||||
import { ButtonGroup, Button, Page, Spacer, useBodyScroll, useMediaQuery, } from "@geist-ui/core";
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import styles from "./header.module.css"
|
||||||
|
import useSignedIn from "../../lib/hooks/use-signed-in"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import HomeIcon from "@geist-ui/icons/home"
|
||||||
import styles from './header.module.css';
|
import MenuIcon from "@geist-ui/icons/menu"
|
||||||
import useSignedIn from "../../lib/hooks/use-signed-in";
|
import GitHubIcon from "@geist-ui/icons/github"
|
||||||
|
import SignOutIcon from "@geist-ui/icons/userX"
|
||||||
import HomeIcon from '@geist-ui/icons/home';
|
import SignInIcon from "@geist-ui/icons/user"
|
||||||
import MenuIcon from '@geist-ui/icons/menu';
|
import SignUpIcon from "@geist-ui/icons/userPlus"
|
||||||
import GitHubIcon from '@geist-ui/icons/github';
|
import NewIcon from "@geist-ui/icons/plusCircle"
|
||||||
import SignOutIcon from '@geist-ui/icons/userX';
|
import YourIcon from "@geist-ui/icons/list"
|
||||||
import SignInIcon from '@geist-ui/icons/user';
|
import MoonIcon from "@geist-ui/icons/moon"
|
||||||
import SignUpIcon from '@geist-ui/icons/userPlus';
|
import SettingsIcon from "@geist-ui/icons/settings"
|
||||||
import NewIcon from '@geist-ui/icons/plusCircle';
|
import SunIcon from "@geist-ui/icons/sun"
|
||||||
import YourIcon from '@geist-ui/icons/list'
|
|
||||||
import MoonIcon from '@geist-ui/icons/moon';
|
|
||||||
import SettingsIcon from '@geist-ui/icons/settings';
|
|
||||||
import SunIcon from '@geist-ui/icons/sun';
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import useUserData from "@lib/hooks/use-user-data";
|
import useUserData from "@lib/hooks/use-user-data"
|
||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router"
|
||||||
|
|
||||||
type Tab = {
|
type Tab = {
|
||||||
name: string
|
name: string
|
||||||
icon: JSX.Element
|
icon: JSX.Element
|
||||||
value: string
|
value: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
href?: string
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [expanded, setExpanded] = useState<boolean>(false)
|
const [expanded, setExpanded] = useState<boolean>(false)
|
||||||
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
|
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
|
||||||
const isMobile = useMediaQuery('xs', { match: 'down' })
|
const isMobile = useMediaQuery("xs", { match: "down" })
|
||||||
const { signedIn: isSignedIn } = useSignedIn()
|
const { signedIn: isSignedIn } = useSignedIn()
|
||||||
const userData = useUserData();
|
const userData = useUserData()
|
||||||
const [pages, setPages] = useState<Tab[]>([])
|
const [pages, setPages] = useState<Tab[]>([])
|
||||||
const { setTheme, resolvedTheme } = useTheme()
|
const { setTheme, resolvedTheme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBodyHidden(expanded)
|
setBodyHidden(expanded)
|
||||||
}, [expanded, setBodyHidden])
|
}, [expanded, setBodyHidden])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
setExpanded(false)
|
setExpanded(false)
|
||||||
}
|
}
|
||||||
}, [isMobile])
|
}, [isMobile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultPages: Tab[] = [
|
const defaultPages: Tab[] = [
|
||||||
{
|
{
|
||||||
name: isMobile ? "GitHub" : "",
|
name: isMobile ? "GitHub" : "",
|
||||||
href: "https://github.com/maxleiter/drift",
|
href: "https://github.com/maxleiter/drift",
|
||||||
icon: <GitHubIcon />,
|
icon: <GitHubIcon />,
|
||||||
value: "github"
|
value: "github"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: isMobile ? "Change theme" : "",
|
name: isMobile ? "Change theme" : "",
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (typeof window !== 'undefined')
|
if (typeof window !== "undefined")
|
||||||
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
|
setTheme(resolvedTheme === "light" ? "dark" : "light")
|
||||||
},
|
},
|
||||||
icon: resolvedTheme === 'light' ? <MoonIcon /> : <SunIcon />,
|
icon: resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
|
||||||
value: "theme",
|
value: "theme"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (isSignedIn)
|
if (isSignedIn)
|
||||||
setPages([
|
setPages([
|
||||||
{
|
{
|
||||||
name: 'new',
|
name: "new",
|
||||||
icon: <NewIcon />,
|
icon: <NewIcon />,
|
||||||
value: 'new',
|
value: "new",
|
||||||
href: '/new'
|
href: "/new"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'yours',
|
name: "yours",
|
||||||
icon: <YourIcon />,
|
icon: <YourIcon />,
|
||||||
value: 'yours',
|
value: "yours",
|
||||||
href: '/mine'
|
href: "/mine"
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: 'settings',
|
// name: 'settings',
|
||||||
// icon: <SettingsIcon />,
|
// icon: <SettingsIcon />,
|
||||||
// value: 'settings',
|
// value: 'settings',
|
||||||
// href: '/settings'
|
// href: '/settings'
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
name: 'sign out',
|
name: "sign out",
|
||||||
icon: <SignOutIcon />,
|
icon: <SignOutIcon />,
|
||||||
value: 'signout',
|
value: "signout",
|
||||||
href: '/signout'
|
href: "/signout"
|
||||||
},
|
},
|
||||||
...defaultPages
|
...defaultPages
|
||||||
])
|
])
|
||||||
else
|
else
|
||||||
setPages([
|
setPages([
|
||||||
{
|
{
|
||||||
name: 'home',
|
name: "home",
|
||||||
icon: <HomeIcon />,
|
icon: <HomeIcon />,
|
||||||
value: 'home',
|
value: "home",
|
||||||
href: '/'
|
href: "/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sign in',
|
name: "Sign in",
|
||||||
icon: <SignInIcon />,
|
icon: <SignInIcon />,
|
||||||
value: 'signin',
|
value: "signin",
|
||||||
href: '/signin'
|
href: "/signin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sign up',
|
name: "Sign up",
|
||||||
icon: <SignUpIcon />,
|
icon: <SignUpIcon />,
|
||||||
value: 'signup',
|
value: "signup",
|
||||||
href: '/signup'
|
href: "/signup"
|
||||||
},
|
},
|
||||||
...defaultPages
|
...defaultPages
|
||||||
])
|
])
|
||||||
if (userData?.role === "admin") {
|
if (userData?.role === "admin") {
|
||||||
setPages((pages) => [
|
setPages((pages) => [
|
||||||
...pages,
|
...pages,
|
||||||
{
|
{
|
||||||
name: 'admin',
|
name: "admin",
|
||||||
icon: <SettingsIcon />,
|
icon: <SettingsIcon />,
|
||||||
value: 'admin',
|
value: "admin",
|
||||||
href: '/admin'
|
href: "/admin"
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
// TODO: investigate deps causing infinite loop
|
// TODO: investigate deps causing infinite loop
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMobile, isSignedIn, resolvedTheme, userData])
|
}, [isMobile, isSignedIn, resolvedTheme, userData])
|
||||||
|
|
||||||
const onTabChange = useCallback((tab: string) => {
|
const onTabChange = useCallback(
|
||||||
if (typeof window === 'undefined') return
|
(tab: string) => {
|
||||||
const match = pages.find(page => page.value === tab)
|
if (typeof window === "undefined") return
|
||||||
if (match?.onClick) {
|
const match = pages.find((page) => page.value === tab)
|
||||||
match.onClick()
|
if (match?.onClick) {
|
||||||
}
|
match.onClick()
|
||||||
}, [pages])
|
}
|
||||||
|
},
|
||||||
|
[pages]
|
||||||
|
)
|
||||||
|
|
||||||
const getButton = useCallback((tab: Tab) => {
|
const getButton = useCallback(
|
||||||
const activeStyle = router.pathname === tab.href ? styles.active : ""
|
(tab: Tab) => {
|
||||||
if (tab.onClick) {
|
const activeStyle = router.pathname === tab.href ? styles.active : ""
|
||||||
return <Button
|
if (tab.onClick) {
|
||||||
auto={isMobile ? false : true}
|
return (
|
||||||
key={tab.value}
|
<Button
|
||||||
icon={tab.icon}
|
auto={isMobile ? false : true}
|
||||||
onClick={() => onTabChange(tab.value)}
|
key={tab.value}
|
||||||
className={`${styles.tab} ${activeStyle}`}
|
icon={tab.icon}
|
||||||
shadow={false}
|
onClick={() => onTabChange(tab.value)}
|
||||||
>
|
className={`${styles.tab} ${activeStyle}`}
|
||||||
{tab.name ? tab.name : undefined}
|
shadow={false}
|
||||||
</Button>
|
>
|
||||||
} else if (tab.href) {
|
{tab.name ? tab.name : undefined}
|
||||||
return <Link key={tab.value} href={tab.href}>
|
</Button>
|
||||||
<a className={styles.tab}>
|
)
|
||||||
<Button
|
} else if (tab.href) {
|
||||||
className={activeStyle}
|
return (
|
||||||
auto={isMobile ? false : true}
|
<Link key={tab.value} href={tab.href}>
|
||||||
icon={tab.icon}
|
<a className={styles.tab}>
|
||||||
shadow={false}
|
<Button
|
||||||
>
|
className={activeStyle}
|
||||||
{tab.name ? tab.name : undefined}
|
auto={isMobile ? false : true}
|
||||||
</Button>
|
icon={tab.icon}
|
||||||
</a>
|
shadow={false}
|
||||||
</Link>
|
>
|
||||||
}
|
{tab.name ? tab.name : undefined}
|
||||||
}, [isMobile, onTabChange, router.pathname])
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isMobile, onTabChange, router.pathname]
|
||||||
|
)
|
||||||
|
|
||||||
const buttons = useMemo(() => pages.map(getButton), [pages, getButton])
|
const buttons = useMemo(() => pages.map(getButton), [pages, getButton])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>{buttons}</div>
|
||||||
{buttons}
|
</div>
|
||||||
</div>
|
<div className={styles.controls}>
|
||||||
</div>
|
<Button
|
||||||
<div className={styles.controls}>
|
effect={false}
|
||||||
<Button
|
auto
|
||||||
effect={false}
|
type="abort"
|
||||||
auto
|
onClick={() => setExpanded(!expanded)}
|
||||||
type="abort"
|
aria-label="Menu"
|
||||||
onClick={() => setExpanded(!expanded)}
|
>
|
||||||
aria-label="Menu"
|
<Spacer height={5 / 6} width={0} />
|
||||||
>
|
<MenuIcon />
|
||||||
<Spacer height={5 / 6} width={0} />
|
</Button>
|
||||||
<MenuIcon />
|
</div>
|
||||||
</Button>
|
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
|
||||||
</div>
|
{isMobile && expanded && (
|
||||||
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
|
<div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
|
||||||
{isMobile && expanded && (<div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
|
<ButtonGroup
|
||||||
<ButtonGroup vertical style={{
|
vertical
|
||||||
background: "var(--bg)",
|
style={{
|
||||||
}}>
|
background: "var(--bg)"
|
||||||
{buttons}
|
}}
|
||||||
</ButtonGroup>
|
>
|
||||||
</div>)}
|
{buttons}
|
||||||
</Page.Header >
|
</ButtonGroup>
|
||||||
)
|
</div>
|
||||||
|
)}
|
||||||
|
</Page.Header>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Header
|
export default Header
|
||||||
|
|
|
@ -1,43 +1,72 @@
|
||||||
import ShiftBy from "@components/shift-by"
|
import ShiftBy from "@components/shift-by"
|
||||||
import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core"
|
import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core"
|
||||||
import Image from 'next/image'
|
import Image from "next/image"
|
||||||
import styles from './home.module.css'
|
import styles from "./home.module.css"
|
||||||
import markdownStyles from '@components/preview/preview.module.css'
|
import markdownStyles from "@components/preview/preview.module.css"
|
||||||
const Home = ({ introTitle, introContent, rendered }: {
|
const Home = ({
|
||||||
introTitle: string
|
introTitle,
|
||||||
introContent: string
|
introContent,
|
||||||
rendered: string
|
rendered
|
||||||
|
}: {
|
||||||
|
introTitle: string
|
||||||
|
introContent: string
|
||||||
|
rendered: string
|
||||||
}) => {
|
}) => {
|
||||||
return (<><div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
return (
|
||||||
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy>
|
<>
|
||||||
<Spacer />
|
<div
|
||||||
<Text style={{ display: 'inline' }} h1>{introTitle}</Text>
|
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
|
||||||
</div>
|
>
|
||||||
<Card>
|
<ShiftBy y={-2}>
|
||||||
<Tabs initialValue={'preview'} hideDivider leftSpace={0}>
|
<Image
|
||||||
<Tabs.Item label={"Raw"} value="edit">
|
src={"/assets/logo-optimized.svg"}
|
||||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
width={"48px"}
|
||||||
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
|
height={"48px"}
|
||||||
<Textarea
|
alt=""
|
||||||
readOnly
|
/>
|
||||||
value={introContent}
|
</ShiftBy>
|
||||||
width="100%"
|
<Spacer />
|
||||||
// TODO: Textarea should grow to fill parent if height == 100%
|
<Text style={{ display: "inline" }} h1>
|
||||||
style={{ flex: 1, minHeight: 350 }}
|
{introTitle}
|
||||||
resize="vertical"
|
</Text>
|
||||||
className={styles.textarea}
|
</div>
|
||||||
/>
|
<Card>
|
||||||
</div>
|
<Tabs initialValue={"preview"} hideDivider leftSpace={0}>
|
||||||
</Tabs.Item>
|
<Tabs.Item label={"Raw"} value="edit">
|
||||||
<Tabs.Item label="Preview" value="preview">
|
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
<div
|
||||||
<article className={markdownStyles.markdownPreview} dangerouslySetInnerHTML={{ __html: rendered }} style={{
|
style={{
|
||||||
height: "100%"
|
marginTop: "var(--gap-half)",
|
||||||
}} />
|
display: "flex",
|
||||||
</div>
|
flexDirection: "column"
|
||||||
</Tabs.Item>
|
}}
|
||||||
</Tabs>
|
>
|
||||||
</Card></>)
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
value={introContent}
|
||||||
|
width="100%"
|
||||||
|
// TODO: Textarea should grow to fill parent if height == 100%
|
||||||
|
style={{ flex: 1, minHeight: 350 }}
|
||||||
|
resize="vertical"
|
||||||
|
className={styles.textarea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Item>
|
||||||
|
<Tabs.Item label="Preview" value="preview">
|
||||||
|
<div style={{ marginTop: "var(--gap-half)" }}>
|
||||||
|
<article
|
||||||
|
className={markdownStyles.markdownPreview}
|
||||||
|
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||||
|
style={{
|
||||||
|
height: "100%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Item>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default Home
|
||||||
|
|
|
@ -1,24 +1,25 @@
|
||||||
import React from 'react'
|
import React from "react"
|
||||||
import styles from './input.module.css'
|
import styles from "./input.module.css"
|
||||||
|
|
||||||
type Props = React.HTMLProps<HTMLInputElement> & {
|
type Props = React.HTMLProps<HTMLInputElement> & {
|
||||||
label?: string
|
label?: string
|
||||||
fontSize?: number | string
|
fontSize?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
const Input = React.forwardRef<HTMLInputElement, Props>(({ label, className, ...props }, ref) => {
|
const Input = React.forwardRef<HTMLInputElement, Props>(
|
||||||
return (<div className={styles.wrapper}>
|
({ label, className, ...props }, ref) => {
|
||||||
{label && <label className={styles.label}>{label}</label>}
|
return (
|
||||||
<input
|
<div className={styles.wrapper}>
|
||||||
|
{label && <label className={styles.label}>{label}</label>}
|
||||||
ref={ref}
|
<input
|
||||||
className={className ? `${styles.input} ${className}` : styles.input}
|
ref={ref}
|
||||||
{...props}
|
className={className ? `${styles.input} ${className}` : styles.input}
|
||||||
/>
|
{...props}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default Input
|
export default Input
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import type { Post } from "@lib/types"
|
import type { Post } from "@lib/types"
|
||||||
import PostList from "../post-list"
|
import PostList from "../post-list"
|
||||||
|
|
||||||
const MyPosts = ({ posts, error, morePosts }:
|
const MyPosts = ({
|
||||||
{
|
posts,
|
||||||
posts: Post[],
|
error,
|
||||||
error: boolean,
|
morePosts
|
||||||
morePosts: boolean
|
}: {
|
||||||
}) => {
|
posts: Post[]
|
||||||
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
|
error: boolean
|
||||||
|
morePosts: boolean
|
||||||
|
}) => {
|
||||||
|
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyPosts
|
export default MyPosts
|
||||||
|
|
|
@ -1,89 +1,115 @@
|
||||||
import { Text, useTheme, useToasts } from '@geist-ui/core'
|
import { Text, useTheme, useToasts } from "@geist-ui/core"
|
||||||
import { memo } from 'react'
|
import { memo } from "react"
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from "react-dropzone"
|
||||||
import styles from './drag-and-drop.module.css'
|
import styles from "./drag-and-drop.module.css"
|
||||||
import type { Document } from '@lib/types'
|
import type { Document } from "@lib/types"
|
||||||
import generateUUID from '@lib/generate-uuid'
|
import generateUUID from "@lib/generate-uuid"
|
||||||
import { allowedFileTypes, allowedFileNames, allowedFileExtensions } from '@lib/constants'
|
import {
|
||||||
|
allowedFileTypes,
|
||||||
|
allowedFileNames,
|
||||||
|
allowedFileExtensions
|
||||||
|
} from "@lib/constants"
|
||||||
|
|
||||||
|
function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
|
||||||
|
const { palette } = useTheme()
|
||||||
|
const { setToast } = useToasts()
|
||||||
|
const onDrop = async (acceptedFiles: File[]) => {
|
||||||
|
const newDocs = await Promise.all(
|
||||||
|
acceptedFiles.map((file) => {
|
||||||
|
return new Promise<Document>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
|
reader.onabort = () =>
|
||||||
const { palette } = useTheme()
|
setToast({ text: "File reading was aborted", type: "error" })
|
||||||
const { setToast } = useToasts()
|
reader.onerror = () =>
|
||||||
const onDrop = async (acceptedFiles: File[]) => {
|
setToast({ text: "File reading failed", type: "error" })
|
||||||
const newDocs = await Promise.all(acceptedFiles.map((file) => {
|
reader.onload = () => {
|
||||||
return new Promise<Document>((resolve) => {
|
const content = reader.result as string
|
||||||
const reader = new FileReader()
|
resolve({
|
||||||
|
title: file.name,
|
||||||
|
content,
|
||||||
|
id: generateUUID()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
|
setDocs(newDocs)
|
||||||
reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' })
|
}
|
||||||
reader.onload = () => {
|
|
||||||
const content = reader.result as string
|
|
||||||
resolve({
|
|
||||||
title: file.name,
|
|
||||||
content,
|
|
||||||
id: generateUUID()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
setDocs(newDocs)
|
const validator = (file: File) => {
|
||||||
}
|
const byteToMB = (bytes: number) =>
|
||||||
|
Math.round((bytes / 1024 / 1024) * 100) / 100
|
||||||
|
|
||||||
const validator = (file: File) => {
|
// TODO: make this configurable
|
||||||
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
|
const maxFileSize = 50000000
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
return {
|
||||||
|
code: "file-too-big",
|
||||||
|
message:
|
||||||
|
"File is too big. Maximum file size is " +
|
||||||
|
byteToMB(maxFileSize) +
|
||||||
|
" MB."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: make this configurable
|
// We initially try to use the browser provided mime type, and then fall back to file names and finally extensions
|
||||||
const maxFileSize = 50000000;
|
if (
|
||||||
if (file.size > maxFileSize) {
|
allowedFileTypes.includes(file.type) ||
|
||||||
return {
|
allowedFileNames.includes(file.name) ||
|
||||||
code: 'file-too-big',
|
allowedFileExtensions.includes(file.name?.split(".").pop() || "")
|
||||||
message: 'File is too big. Maximum file size is ' + byteToMB(maxFileSize) + ' MB.',
|
) {
|
||||||
}
|
return null
|
||||||
}
|
} else {
|
||||||
|
return {
|
||||||
|
code: "not-plain-text",
|
||||||
|
message: `Only plain text files are allowed.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We initially try to use the browser provided mime type, and then fall back to file names and finally extensions
|
const { getRootProps, getInputProps, isDragActive, fileRejections } =
|
||||||
if (allowedFileTypes.includes(file.type) || allowedFileNames.includes(file.name) || allowedFileExtensions.includes(file.name?.split('.').pop() || '')) {
|
useDropzone({ onDrop, validator })
|
||||||
return null
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
code: "not-plain-text",
|
|
||||||
message: `Only plain text files are allowed.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, validator })
|
const fileRejectionItems = fileRejections.map(({ file, errors }) => (
|
||||||
|
<li key={file.name}>
|
||||||
|
{file.name}:
|
||||||
|
<ul>
|
||||||
|
{errors.map((e) => (
|
||||||
|
<li key={e.code}>
|
||||||
|
<Text>{e.message}</Text>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
|
||||||
const fileRejectionItems = fileRejections.map(({ file, errors }) => (
|
return (
|
||||||
<li key={file.name}>
|
<div className={styles.container}>
|
||||||
{file.name}:
|
<div
|
||||||
<ul>
|
{...getRootProps()}
|
||||||
{errors.map(e => (
|
className={styles.dropzone}
|
||||||
<li key={e.code}><Text>{e.message}</Text></li>
|
style={{
|
||||||
))}
|
borderColor: palette.accents_3
|
||||||
</ul>
|
}}
|
||||||
</li>
|
>
|
||||||
));
|
<input {...getInputProps()} />
|
||||||
|
{!isDragActive && (
|
||||||
return (
|
<Text p>Drag some files here, or click to select files</Text>
|
||||||
<div className={styles.container}>
|
)}
|
||||||
<div {...getRootProps()} className={styles.dropzone} style={{
|
{isDragActive && <Text p>Release to drop the files here</Text>}
|
||||||
borderColor: palette.accents_3,
|
</div>
|
||||||
}}>
|
{fileRejections.length > 0 && (
|
||||||
<input {...getInputProps()} />
|
<ul className={styles.error}>
|
||||||
{!isDragActive && <Text p>Drag some files here, or click to select files</Text>}
|
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
|
||||||
{isDragActive && <Text p>Release to drop the files here</Text>}
|
<Text h5>There was a problem with one or more of your files.</Text>
|
||||||
</div>
|
{fileRejectionItems}
|
||||||
{fileRejections.length > 0 && <ul className={styles.error}>
|
</ul>
|
||||||
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
|
)}
|
||||||
<Text h5>There was a problem with one or more of your files.</Text>
|
</div>
|
||||||
{fileRejectionItems}
|
)
|
||||||
</ul>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(FileDropzone)
|
export default memo(FileDropzone)
|
||||||
|
|
|
@ -1,278 +1,362 @@
|
||||||
import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core'
|
import {
|
||||||
import { useRouter } from 'next/router';
|
Button,
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
useToasts,
|
||||||
import generateUUID from '@lib/generate-uuid';
|
ButtonDropdown,
|
||||||
import FileDropzone from './drag-and-drop';
|
Toggle,
|
||||||
import styles from './post.module.css'
|
Input,
|
||||||
import Title from './title';
|
useClickAway
|
||||||
import Cookies from 'js-cookie'
|
} from "@geist-ui/core"
|
||||||
import type { Post as PostType, PostVisibility, Document as DocumentType } from '@lib/types';
|
import { useRouter } from "next/router"
|
||||||
import PasswordModal from './password-modal';
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import getPostPath from '@lib/get-post-path';
|
import generateUUID from "@lib/generate-uuid"
|
||||||
import EditDocumentList from '@components/edit-document-list';
|
import FileDropzone from "./drag-and-drop"
|
||||||
import { ChangeEvent } from 'react';
|
import styles from "./post.module.css"
|
||||||
import DatePicker from 'react-datepicker';
|
import Title from "./title"
|
||||||
|
import Cookies from "js-cookie"
|
||||||
|
import type {
|
||||||
|
Post as PostType,
|
||||||
|
PostVisibility,
|
||||||
|
Document as DocumentType
|
||||||
|
} from "@lib/types"
|
||||||
|
import PasswordModal from "./password-modal"
|
||||||
|
import getPostPath from "@lib/get-post-path"
|
||||||
|
import EditDocumentList from "@components/edit-document-list"
|
||||||
|
import { ChangeEvent } from "react"
|
||||||
|
import DatePicker from "react-datepicker"
|
||||||
|
|
||||||
const Post = ({
|
const Post = ({
|
||||||
initialPost,
|
initialPost,
|
||||||
newPostParent
|
newPostParent
|
||||||
}: {
|
}: {
|
||||||
initialPost?: PostType,
|
initialPost?: PostType
|
||||||
newPostParent?: string
|
newPostParent?: string
|
||||||
}) => {
|
}) => {
|
||||||
const { setToast } = useToasts()
|
const { setToast } = useToasts()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const [title, setTitle] = useState<string>()
|
const [title, setTitle] = useState<string>()
|
||||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null)
|
const [expiresAt, setExpiresAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
const emptyDoc = useMemo(() => [{
|
const emptyDoc = useMemo(
|
||||||
title: '',
|
() => [
|
||||||
content: '',
|
{
|
||||||
id: generateUUID()
|
title: "",
|
||||||
}], [])
|
content: "",
|
||||||
|
id: generateUUID()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
|
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
|
||||||
|
|
||||||
// the /new/from/{id} route fetches an initial post
|
// the /new/from/{id} route fetches an initial post
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPost) {
|
if (initialPost) {
|
||||||
setTitle(`Copy of ${initialPost.title}`)
|
setTitle(`Copy of ${initialPost.title}`)
|
||||||
setDocs(initialPost.files?.map(doc => ({
|
setDocs(
|
||||||
title: doc.title,
|
initialPost.files?.map((doc) => ({
|
||||||
content: doc.content,
|
title: doc.title,
|
||||||
id: doc.id
|
content: doc.content,
|
||||||
})) || emptyDoc)
|
id: doc.id
|
||||||
}
|
})) || emptyDoc
|
||||||
}, [emptyDoc, initialPost])
|
)
|
||||||
|
}
|
||||||
|
}, [emptyDoc, initialPost])
|
||||||
|
|
||||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
|
||||||
|
|
||||||
const sendRequest = useCallback(async (url: string, data:
|
const sendRequest = useCallback(
|
||||||
{
|
async (
|
||||||
expiresAt: Date | null,
|
url: string,
|
||||||
visibility?: PostVisibility,
|
data: {
|
||||||
title?: string,
|
expiresAt: Date | null
|
||||||
files?: DocumentType[],
|
visibility?: PostVisibility
|
||||||
password?: string,
|
title?: string
|
||||||
userId: string,
|
files?: DocumentType[]
|
||||||
parentId?: string
|
password?: string
|
||||||
}) => {
|
userId: string
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
files: docs,
|
||||||
|
...data
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const res = await fetch(url, {
|
if (res.ok) {
|
||||||
method: "POST",
|
const json = await res.json()
|
||||||
headers: {
|
router.push(getPostPath(json.visibility, json.id))
|
||||||
"Content-Type": "application/json",
|
} else {
|
||||||
"Authorization": `Bearer ${Cookies.get('drift-token')}`
|
const json = await res.json()
|
||||||
},
|
setToast({
|
||||||
body: JSON.stringify({
|
text: json.error.message || "Please fill out all fields",
|
||||||
title,
|
type: "error"
|
||||||
files: docs,
|
})
|
||||||
...data,
|
setPasswordModalVisible(false)
|
||||||
})
|
setSubmitting(false)
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
[docs, router, setToast, title]
|
||||||
|
)
|
||||||
|
|
||||||
if (res.ok) {
|
const [isSubmitting, setSubmitting] = useState(false)
|
||||||
const json = await res.json()
|
|
||||||
router.push(getPostPath(json.visibility, json.id))
|
|
||||||
} else {
|
|
||||||
const json = await res.json()
|
|
||||||
setToast({
|
|
||||||
text: json.error.message || 'Please fill out all fields',
|
|
||||||
type: 'error'
|
|
||||||
})
|
|
||||||
setPasswordModalVisible(false)
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}, [docs, router, setToast, title])
|
const onSubmit = useCallback(
|
||||||
|
async (visibility: PostVisibility, password?: string) => {
|
||||||
|
if (visibility === "protected" && !password) {
|
||||||
|
setPasswordModalVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
setPasswordModalVisible(false)
|
||||||
|
|
||||||
const onSubmit = useCallback(async (visibility: PostVisibility, password?: string) => {
|
setSubmitting(true)
|
||||||
if (visibility === 'protected' && !password) {
|
|
||||||
setPasswordModalVisible(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasswordModalVisible(false)
|
let hasErrored = false
|
||||||
|
|
||||||
setSubmitting(true)
|
if (!title) {
|
||||||
|
setToast({
|
||||||
|
text: "Please fill out the post title",
|
||||||
|
type: "error"
|
||||||
|
})
|
||||||
|
hasErrored = true
|
||||||
|
}
|
||||||
|
|
||||||
let hasErrored = false
|
if (!docs.length) {
|
||||||
|
setToast({
|
||||||
|
text: "Please add at least one document",
|
||||||
|
type: "error"
|
||||||
|
})
|
||||||
|
hasErrored = true
|
||||||
|
}
|
||||||
|
|
||||||
if (!title) {
|
for (const doc of docs) {
|
||||||
setToast({
|
if (!doc.title) {
|
||||||
text: 'Please fill out the post title',
|
setToast({
|
||||||
type: 'error'
|
text: "Please fill out all the document titles",
|
||||||
})
|
type: "error"
|
||||||
hasErrored = true
|
})
|
||||||
}
|
hasErrored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!docs.length) {
|
if (hasErrored) {
|
||||||
setToast({
|
setSubmitting(false)
|
||||||
text: 'Please add at least one document',
|
return
|
||||||
type: 'error'
|
}
|
||||||
})
|
|
||||||
hasErrored = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const doc of docs) {
|
await sendRequest("/server-api/posts/create", {
|
||||||
if (!doc.title) {
|
title,
|
||||||
setToast({
|
files: docs,
|
||||||
text: 'Please fill out all the document titles',
|
visibility,
|
||||||
type: 'error'
|
password,
|
||||||
})
|
userId: Cookies.get("drift-userid") || "",
|
||||||
hasErrored = true
|
expiresAt,
|
||||||
}
|
parentId: newPostParent
|
||||||
}
|
})
|
||||||
|
},
|
||||||
|
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
|
||||||
|
)
|
||||||
|
|
||||||
if (hasErrored) {
|
const onClosePasswordModal = () => {
|
||||||
setSubmitting(false)
|
setPasswordModalVisible(false)
|
||||||
return
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendRequest('/server-api/posts/create', {
|
const submitPassword = useCallback(
|
||||||
title,
|
(password) => onSubmit("protected", password),
|
||||||
files: docs,
|
[onSubmit]
|
||||||
visibility,
|
)
|
||||||
password,
|
|
||||||
userId: Cookies.get('drift-userid') || '',
|
|
||||||
expiresAt,
|
|
||||||
parentId: newPostParent
|
|
||||||
})
|
|
||||||
}, [docs, expiresAt, newPostParent, sendRequest, setToast, title])
|
|
||||||
|
|
||||||
const onClosePasswordModal = () => {
|
const onChangeExpiration = useCallback((date) => setExpiresAt(date), [])
|
||||||
setPasswordModalVisible(false)
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPassword = useCallback((password) => onSubmit('protected', password), [onSubmit])
|
const onChangeTitle = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTitle(e.target.value)
|
||||||
|
},
|
||||||
|
[setTitle]
|
||||||
|
)
|
||||||
|
|
||||||
const onChangeExpiration = useCallback((date) => setExpiresAt(date), [])
|
const updateDocTitle = useCallback(
|
||||||
|
(i: number) => (title: string) => {
|
||||||
|
setDocs((docs) =>
|
||||||
|
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setDocs]
|
||||||
|
)
|
||||||
|
|
||||||
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
const updateDocContent = useCallback(
|
||||||
setTitle(e.target.value)
|
(i: number) => (content: string) => {
|
||||||
}, [setTitle])
|
setDocs((docs) =>
|
||||||
|
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setDocs]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeDoc = useCallback(
|
||||||
|
(i: number) => () => {
|
||||||
|
setDocs((docs) => docs.filter((_, index) => i !== index))
|
||||||
|
},
|
||||||
|
[setDocs]
|
||||||
|
)
|
||||||
|
|
||||||
const updateDocTitle = useCallback((i: number) => (title: string) => {
|
const uploadDocs = useCallback(
|
||||||
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, title } : doc))
|
(files: DocumentType[]) => {
|
||||||
}, [setDocs])
|
// if no title is set and the only document is empty,
|
||||||
|
const isFirstDocEmpty =
|
||||||
|
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
|
||||||
|
const shouldSetTitle = !title && isFirstDocEmpty
|
||||||
|
if (shouldSetTitle) {
|
||||||
|
if (files.length === 1) {
|
||||||
|
setTitle(files[0].title)
|
||||||
|
} else if (files.length > 1) {
|
||||||
|
setTitle("Uploaded files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateDocContent = useCallback((i: number) => (content: string) => {
|
if (isFirstDocEmpty) setDocs(files)
|
||||||
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, content } : doc))
|
else setDocs((docs) => [...docs, ...files])
|
||||||
}, [setDocs])
|
},
|
||||||
|
[docs, title]
|
||||||
|
)
|
||||||
|
|
||||||
const removeDoc = useCallback((i: number) => () => {
|
// pasted files
|
||||||
setDocs((docs) => docs.filter((_, index) => i !== index))
|
// const files = e.clipboardData.files as File[]
|
||||||
}, [setDocs])
|
// if (files.length) {
|
||||||
|
// const docs = Array.from(files).map((file) => ({
|
||||||
|
// title: file.name,
|
||||||
|
// content: '',
|
||||||
|
// id: generateUUID()
|
||||||
|
// }))
|
||||||
|
// }
|
||||||
|
|
||||||
const uploadDocs = useCallback((files: DocumentType[]) => {
|
const onPaste = useCallback(
|
||||||
// if no title is set and the only document is empty,
|
(e: any) => {
|
||||||
const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true)
|
const pastedText = e.clipboardData.getData("text")
|
||||||
const shouldSetTitle = !title && isFirstDocEmpty
|
|
||||||
if (shouldSetTitle) {
|
|
||||||
if (files.length === 1) {
|
|
||||||
setTitle(files[0].title)
|
|
||||||
} else if (files.length > 1) {
|
|
||||||
setTitle('Uploaded files')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirstDocEmpty) setDocs(files)
|
if (pastedText) {
|
||||||
else setDocs((docs) => [...docs, ...files])
|
if (!title) {
|
||||||
}, [docs, title])
|
setTitle("Pasted text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[title]
|
||||||
|
)
|
||||||
|
|
||||||
// pasted files
|
const CustomTimeInput = ({
|
||||||
// const files = e.clipboardData.files as File[]
|
date,
|
||||||
// if (files.length) {
|
value,
|
||||||
// const docs = Array.from(files).map((file) => ({
|
onChange
|
||||||
// title: file.name,
|
}: {
|
||||||
// content: '',
|
date: Date
|
||||||
// id: generateUUID()
|
value: string
|
||||||
// }))
|
onChange: (date: string) => void
|
||||||
// }
|
}) => (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
onChange(e.target.value || date.toISOString().slice(11, 16))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg)",
|
||||||
|
border: "1px solid var(--light-gray)",
|
||||||
|
borderRadius: "var(--radius)"
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const onPaste = useCallback((e: any) => {
|
return (
|
||||||
const pastedText = (e.clipboardData).getData('text')
|
<div style={{ paddingBottom: 150 }}>
|
||||||
|
<Title title={title} onChange={onChangeTitle} />
|
||||||
if (pastedText) {
|
<FileDropzone setDocs={uploadDocs} />
|
||||||
if (!title) {
|
<EditDocumentList
|
||||||
setTitle("Pasted text")
|
onPaste={onPaste}
|
||||||
}
|
docs={docs}
|
||||||
}
|
updateDocTitle={updateDocTitle}
|
||||||
}, [title])
|
updateDocContent={updateDocContent}
|
||||||
|
removeDoc={removeDoc}
|
||||||
const CustomTimeInput = ({ date, value, onChange }: {
|
/>
|
||||||
date: Date,
|
<div className={styles.buttons}>
|
||||||
value: string,
|
<Button
|
||||||
onChange: (date: string) => void
|
className={styles.button}
|
||||||
}) => (
|
onClick={() => {
|
||||||
<input
|
setDocs([
|
||||||
type="time"
|
...docs,
|
||||||
value={value}
|
{
|
||||||
onChange={(e) => {
|
title: "",
|
||||||
if (!isNaN(date.getTime())) {
|
content: "",
|
||||||
onChange(e.target.value || date.toISOString().slice(11, 16))
|
id: generateUUID()
|
||||||
}
|
}
|
||||||
}}
|
])
|
||||||
style={{
|
}}
|
||||||
backgroundColor: 'var(--bg)',
|
type="default"
|
||||||
border: '1px solid var(--light-gray)',
|
>
|
||||||
borderRadius: 'var(--radius)'
|
Add a File
|
||||||
}}
|
</Button>
|
||||||
required
|
<div className={styles.rightButtons}>
|
||||||
/>
|
{
|
||||||
);
|
<DatePicker
|
||||||
|
onChange={onChangeExpiration}
|
||||||
return (
|
customInput={
|
||||||
<div style={{ paddingBottom: 150 }}>
|
<Input
|
||||||
<Title title={title} onChange={onChangeTitle} />
|
label="Expires at"
|
||||||
<FileDropzone setDocs={uploadDocs} />
|
clearable
|
||||||
<EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} />
|
width="100%"
|
||||||
<div className={styles.buttons}>
|
height="40px"
|
||||||
<Button
|
/>
|
||||||
className={styles.button}
|
}
|
||||||
onClick={() => {
|
placeholderText="Won't expire"
|
||||||
setDocs([...docs, {
|
selected={expiresAt}
|
||||||
title: '',
|
showTimeInput={true}
|
||||||
content: '',
|
// @ts-ignore
|
||||||
id: generateUUID()
|
customTimeInput={<CustomTimeInput />}
|
||||||
}])
|
timeInputLabel="Time:"
|
||||||
}}
|
dateFormat="MM/dd/yyyy h:mm aa"
|
||||||
type="default"
|
className={styles.datePicker}
|
||||||
>
|
clearButtonTitle={"Clear"}
|
||||||
Add a File
|
// TODO: investigate why this causes margin shift if true
|
||||||
</Button>
|
enableTabLoop={false}
|
||||||
<div className={styles.rightButtons}>
|
minDate={new Date()}
|
||||||
{<DatePicker
|
/>
|
||||||
onChange={onChangeExpiration}
|
}
|
||||||
customInput={<Input label="Expires at" clearable width="100%" height="40px" />}
|
<ButtonDropdown loading={isSubmitting} type="success">
|
||||||
placeholderText="Won't expire"
|
<ButtonDropdown.Item main onClick={() => onSubmit("private")}>
|
||||||
selected={expiresAt}
|
Create Private
|
||||||
showTimeInput={true}
|
</ButtonDropdown.Item>
|
||||||
// @ts-ignore
|
<ButtonDropdown.Item onClick={() => onSubmit("public")}>
|
||||||
customTimeInput={<CustomTimeInput />}
|
Create Public
|
||||||
timeInputLabel="Time:"
|
</ButtonDropdown.Item>
|
||||||
dateFormat="MM/dd/yyyy h:mm aa"
|
<ButtonDropdown.Item onClick={() => onSubmit("unlisted")}>
|
||||||
className={styles.datePicker}
|
Create Unlisted
|
||||||
clearButtonTitle={"Clear"}
|
</ButtonDropdown.Item>
|
||||||
// TODO: investigate why this causes margin shift if true
|
<ButtonDropdown.Item onClick={() => onSubmit("protected")}>
|
||||||
enableTabLoop={false}
|
Create with Password
|
||||||
minDate={new Date()}
|
</ButtonDropdown.Item>
|
||||||
/>}
|
</ButtonDropdown>
|
||||||
<ButtonDropdown loading={isSubmitting} type="success">
|
</div>
|
||||||
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
|
</div>
|
||||||
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
|
<PasswordModal
|
||||||
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
|
creating={true}
|
||||||
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
|
isOpen={passwordModalVisible}
|
||||||
</ButtonDropdown>
|
onClose={onClosePasswordModal}
|
||||||
</div>
|
onSubmit={submitPassword}
|
||||||
</div>
|
/>
|
||||||
<PasswordModal creating={true} isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} />
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Post
|
export default Post
|
||||||
|
|
|
@ -1,54 +1,83 @@
|
||||||
|
|
||||||
import { Modal, Note, Spacer, Input } from "@geist-ui/core"
|
import { Modal, Note, Spacer, Input } from "@geist-ui/core"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
creating: boolean
|
creating: boolean
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (password: string) => void
|
onSubmit: (password: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creating }: Props) => {
|
const PasswordModal = ({
|
||||||
const [password, setPassword] = useState<string>()
|
isOpen,
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>()
|
onClose,
|
||||||
const [error, setError] = useState<string>()
|
onSubmit: onSubmitAfterVerify,
|
||||||
|
creating
|
||||||
|
}: Props) => {
|
||||||
|
const [password, setPassword] = useState<string>()
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState<string>()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (!password || (creating && !confirmPassword)) {
|
if (!password || (creating && !confirmPassword)) {
|
||||||
setError('Please enter a password')
|
setError("Please enter a password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword && creating) {
|
if (password !== confirmPassword && creating) {
|
||||||
setError("Passwords do not match")
|
setError("Passwords do not match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmitAfterVerify(password)
|
onSubmitAfterVerify(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<>
|
return (
|
||||||
{/* TODO: investigate disableBackdropClick not updating state? */}
|
<>
|
||||||
|
{/* TODO: investigate disableBackdropClick not updating state? */}
|
||||||
|
|
||||||
{<Modal visible={isOpen} disableBackdropClick={true} >
|
{
|
||||||
<Modal.Title>Enter a password</Modal.Title>
|
<Modal visible={isOpen} disableBackdropClick={true}>
|
||||||
<Modal.Content>
|
<Modal.Title>Enter a password</Modal.Title>
|
||||||
{!error && creating && <Note type="warning" label='Warning'>
|
<Modal.Content>
|
||||||
This doesn't protect your post from the server administrator.
|
{!error && creating && (
|
||||||
</Note>}
|
<Note type="warning" label="Warning">
|
||||||
{error && <Note type="error" label='Error'>
|
This doesn't protect your post from the server
|
||||||
{error}
|
administrator.
|
||||||
</Note>}
|
</Note>
|
||||||
<Spacer />
|
)}
|
||||||
<Input width={"100%"} label="Password" marginBottom={1} htmlType="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
|
{error && (
|
||||||
{creating && <Input width={"100%"} label="Confirm" htmlType="password" placeholder="Confirm Password" onChange={(e) => setConfirmPassword(e.target.value)} />}
|
<Note type="error" label="Error">
|
||||||
</Modal.Content>
|
{error}
|
||||||
<Modal.Action passive onClick={onClose}>Cancel</Modal.Action>
|
</Note>
|
||||||
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
|
)}
|
||||||
</Modal>}
|
<Spacer />
|
||||||
</>)
|
<Input
|
||||||
|
width={"100%"}
|
||||||
|
label="Password"
|
||||||
|
marginBottom={1}
|
||||||
|
htmlType="password"
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{creating && (
|
||||||
|
<Input
|
||||||
|
width={"100%"}
|
||||||
|
label="Confirm"
|
||||||
|
htmlType="password"
|
||||||
|
placeholder="Confirm Password"
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Action passive onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Modal.Action>
|
||||||
|
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default PasswordModal
|
||||||
export default PasswordModal
|
|
||||||
|
|
|
@ -1,45 +1,51 @@
|
||||||
import { ChangeEvent, memo, useEffect, useState } from 'react'
|
import { ChangeEvent, memo, useEffect, useState } from "react"
|
||||||
import { Text } from '@geist-ui/core'
|
import { Text } from "@geist-ui/core"
|
||||||
|
|
||||||
import ShiftBy from '@components/shift-by'
|
import ShiftBy from "@components/shift-by"
|
||||||
import styles from '../post.module.css'
|
import styles from "../post.module.css"
|
||||||
import { Input } from '@geist-ui/core'
|
import { Input } from "@geist-ui/core"
|
||||||
|
|
||||||
const titlePlaceholders = [
|
const titlePlaceholders = [
|
||||||
"How to...",
|
"How to...",
|
||||||
"Status update for ...",
|
"Status update for ...",
|
||||||
"My new project",
|
"My new project",
|
||||||
"My new idea",
|
"My new idea",
|
||||||
"Let's talk about...",
|
"Let's talk about...",
|
||||||
"What's up with ...",
|
"What's up with ...",
|
||||||
"I'm thinking about ...",
|
"I'm thinking about ..."
|
||||||
]
|
]
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Title = ({ onChange, title }: props) => {
|
const Title = ({ onChange, title }: props) => {
|
||||||
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
|
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// set random placeholder on load
|
// set random placeholder on load
|
||||||
setPlaceholder(titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)])
|
setPlaceholder(
|
||||||
}, [])
|
titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]
|
||||||
return (<div className={styles.title}>
|
)
|
||||||
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
|
}, [])
|
||||||
<ShiftBy y={-3}>
|
return (
|
||||||
<Input
|
<div className={styles.title}>
|
||||||
placeholder={placeholder}
|
<Text h1 width={"150px"} className={styles.drift}>
|
||||||
value={title || ""}
|
Drift
|
||||||
onChange={onChange}
|
</Text>
|
||||||
height={"55px"}
|
<ShiftBy y={-3}>
|
||||||
font={1.5}
|
<Input
|
||||||
label="Post title"
|
placeholder={placeholder}
|
||||||
style={{ width: "100%" }}
|
value={title || ""}
|
||||||
/>
|
onChange={onChange}
|
||||||
</ShiftBy>
|
height={"55px"}
|
||||||
</div>)
|
font={1.5}
|
||||||
|
label="Post title"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</ShiftBy>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(Title)
|
export default memo(Title)
|
||||||
|
|
|
@ -1,27 +1,26 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head"
|
||||||
import React from "react";
|
import React from "react"
|
||||||
|
|
||||||
type PageSeoProps = {
|
type PageSeoProps = {
|
||||||
title?: string;
|
title?: string
|
||||||
description?: string;
|
description?: string
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
isPrivate?: boolean
|
isPrivate?: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
const PageSeo = ({
|
const PageSeo = ({
|
||||||
title = 'Drift',
|
title = "Drift",
|
||||||
description = "A self-hostable clone of GitHub Gist",
|
description = "A self-hostable clone of GitHub Gist",
|
||||||
isPrivate = false
|
isPrivate = false
|
||||||
}: PageSeoProps) => {
|
}: PageSeoProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{title}</title>
|
||||||
|
{!isPrivate && <meta name="description" content={description} />}
|
||||||
|
</Head>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export default PageSeo
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{title}</title>
|
|
||||||
{!isPrivate && <meta name="description" content={description} />}
|
|
||||||
</Head>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSeo;
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Button, Code, Dot, Input, Note, Text } from "@geist-ui/core"
|
import { Button, Code, Dot, Input, Note, Text } from "@geist-ui/core"
|
||||||
import NextLink from "next/link"
|
import NextLink from "next/link"
|
||||||
import Link from '../Link'
|
import Link from "../Link"
|
||||||
|
|
||||||
import styles from './post-list.module.css'
|
import styles from "./post-list.module.css"
|
||||||
import ListItemSkeleton from "./list-item-skeleton"
|
import ListItemSkeleton from "./list-item-skeleton"
|
||||||
import ListItem from "./list-item"
|
import ListItem from "./list-item"
|
||||||
import { Post } from "@lib/types"
|
import { Post } from "@lib/types"
|
||||||
|
@ -11,128 +11,156 @@ import debounce from "lodash.debounce"
|
||||||
import Cookies from "js-cookie"
|
import Cookies from "js-cookie"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialPosts: Post[]
|
initialPosts: Post[]
|
||||||
error: boolean
|
error: boolean
|
||||||
morePosts: boolean
|
morePosts: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostList = ({ morePosts, initialPosts, error }: Props) => {
|
const PostList = ({ morePosts, initialPosts, error }: Props) => {
|
||||||
const [search, setSearchValue] = useState('')
|
const [search, setSearchValue] = useState("")
|
||||||
const [posts, setPosts] = useState<Post[]>(initialPosts)
|
const [posts, setPosts] = useState<Post[]>(initialPosts)
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
|
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
|
||||||
const loadMoreClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
const loadMoreClick = useCallback(
|
||||||
e.preventDefault()
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (hasMorePosts) {
|
e.preventDefault()
|
||||||
async function fetchPosts() {
|
if (hasMorePosts) {
|
||||||
const res = await fetch(`/server-api/posts/mine`,
|
async function fetchPosts() {
|
||||||
{
|
const res = await fetch(`/server-api/posts/mine`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${Cookies.get('drift-token')}`,
|
Authorization: `Bearer ${Cookies.get("drift-token")}`,
|
||||||
"x-page": `${posts.length / 10 + 1}`,
|
"x-page": `${posts.length / 10 + 1}`
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
const json = await res.json()
|
||||||
const json = await res.json()
|
setPosts([...posts, ...json.posts])
|
||||||
setPosts([...posts, ...json.posts])
|
setHasMorePosts(json.morePosts)
|
||||||
setHasMorePosts(json.morePosts)
|
}
|
||||||
}
|
fetchPosts()
|
||||||
fetchPosts()
|
}
|
||||||
}
|
},
|
||||||
}, [posts, hasMorePosts])
|
[posts, hasMorePosts]
|
||||||
|
)
|
||||||
|
|
||||||
// update posts on search
|
// update posts on search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (search) {
|
if (search) {
|
||||||
// fetch results from /server-api/posts/search
|
// fetch results from /server-api/posts/search
|
||||||
const fetchResults = async () => {
|
const fetchResults = async () => {
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
//encode search
|
//encode search
|
||||||
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, {
|
const res = await fetch(
|
||||||
method: "GET",
|
`/server-api/posts/search?q=${encodeURIComponent(search)}`,
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "GET",
|
||||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
headers: {
|
||||||
// "tok": process.env.SECRET_KEY || ''
|
"Content-Type": "application/json",
|
||||||
}
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
})
|
// "tok": process.env.SECRET_KEY || ''
|
||||||
const data = await res.json()
|
}
|
||||||
setPosts(data)
|
}
|
||||||
setSearching(false)
|
)
|
||||||
}
|
const data = await res.json()
|
||||||
fetchResults()
|
setPosts(data)
|
||||||
} else {
|
setSearching(false)
|
||||||
setPosts(initialPosts)
|
}
|
||||||
}
|
fetchResults()
|
||||||
}, [initialPosts, search])
|
} else {
|
||||||
|
setPosts(initialPosts)
|
||||||
|
}
|
||||||
|
}, [initialPosts, search])
|
||||||
|
|
||||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchValue(e.target.value)
|
setSearchValue(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedSearchHandler = useMemo(
|
const debouncedSearchHandler = useMemo(
|
||||||
() => debounce(handleSearchChange, 300)
|
() => debounce(handleSearchChange, 300),
|
||||||
, []);
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
debouncedSearchHandler.cancel();
|
debouncedSearchHandler.cancel()
|
||||||
}
|
}
|
||||||
}, [debouncedSearchHandler]);
|
}, [debouncedSearchHandler])
|
||||||
|
|
||||||
const deletePost = useCallback((postId: string) => async () => {
|
const deletePost = useCallback(
|
||||||
const res = await fetch(`/server-api/posts/${postId}`, {
|
(postId: string) => async () => {
|
||||||
method: "DELETE",
|
const res = await fetch(`/server-api/posts/${postId}`, {
|
||||||
headers: {
|
method: "DELETE",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`
|
"Content-Type": "application/json",
|
||||||
},
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res)
|
console.error(res)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
setPosts((posts) => posts.filter(post => post.id !== postId))
|
setPosts((posts) => posts.filter((post) => post.id !== postId))
|
||||||
}
|
}
|
||||||
}, [])
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
<Input scale={3 / 2}
|
<Input
|
||||||
clearable
|
scale={3 / 2}
|
||||||
placeholder="Search..."
|
clearable
|
||||||
onChange={debouncedSearchHandler} />
|
placeholder="Search..."
|
||||||
</div>
|
onChange={debouncedSearchHandler}
|
||||||
{error && <Text type='error'>Failed to load.</Text>}
|
/>
|
||||||
{!posts.length && searching && <ul>
|
</div>
|
||||||
<li>
|
{error && <Text type="error">Failed to load.</Text>}
|
||||||
<ListItemSkeleton />
|
{!posts.length && searching && (
|
||||||
</li>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<ListItemSkeleton />
|
<ListItemSkeleton />
|
||||||
</li>
|
</li>
|
||||||
</ul>}
|
<li>
|
||||||
{posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
<ListItemSkeleton />
|
||||||
{
|
</li>
|
||||||
posts?.length > 0 && <div>
|
</ul>
|
||||||
<ul>
|
)}
|
||||||
{posts.map((post) => {
|
{posts?.length === 0 && !error && (
|
||||||
return <ListItem deletePost={deletePost(post.id)} post={post} key={post.id} />
|
<Text type="secondary">
|
||||||
})}
|
No posts found. Create one{" "}
|
||||||
</ul>
|
<NextLink passHref={true} href="/new">
|
||||||
</div>
|
<Link color>here</Link>
|
||||||
}
|
</NextLink>
|
||||||
{hasMorePosts && !setSearchValue && <div className={styles.moreContainer}>
|
.
|
||||||
<Button width={"100%"} onClick={loadMoreClick}>
|
</Text>
|
||||||
Load more
|
)}
|
||||||
</Button>
|
{posts?.length > 0 && (
|
||||||
</div>}
|
<div>
|
||||||
</div>
|
<ul>
|
||||||
)
|
{posts.map((post) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
deletePost={deletePost(post.id)}
|
||||||
|
post={post}
|
||||||
|
key={post.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMorePosts && !setSearchValue && (
|
||||||
|
<div className={styles.moreContainer}>
|
||||||
|
<Button width={"100%"} onClick={loadMoreClick}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostList
|
export default PostList
|
||||||
|
|
|
@ -1,21 +1,27 @@
|
||||||
|
import Skeleton from "react-loading-skeleton"
|
||||||
|
import { Card, Divider, Grid, Spacer } from "@geist-ui/core"
|
||||||
|
|
||||||
|
const ListItemSkeleton = () => (
|
||||||
|
<Card>
|
||||||
|
<Spacer height={1 / 2} />
|
||||||
|
<Grid.Container justify={"space-between"} marginBottom={1 / 2}>
|
||||||
|
<Grid xs={8} paddingLeft={1 / 2}>
|
||||||
|
<Skeleton width={150} />
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={7}>
|
||||||
|
<Skeleton width={100} />
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={4}>
|
||||||
|
<Skeleton width={70} />
|
||||||
|
</Grid>
|
||||||
|
</Grid.Container>
|
||||||
|
|
||||||
import Skeleton from "react-loading-skeleton";
|
<Divider h="1px" my={0} />
|
||||||
import { Card, Divider, Grid, Spacer } from "@geist-ui/core";
|
|
||||||
|
|
||||||
const ListItemSkeleton = () => (<Card>
|
<Card.Content>
|
||||||
<Spacer height={1 / 2} />
|
<Skeleton width={200} />
|
||||||
<Grid.Container justify={'space-between'} marginBottom={1 / 2}>
|
</Card.Content>
|
||||||
<Grid xs={8} paddingLeft={1 / 2}><Skeleton width={150} /></Grid>
|
</Card>
|
||||||
<Grid xs={7}><Skeleton width={100} /></Grid>
|
)
|
||||||
<Grid xs={4}><Skeleton width={70} /></Grid>
|
|
||||||
</Grid.Container>
|
|
||||||
|
|
||||||
<Divider h="1px" my={0} />
|
export default ListItemSkeleton
|
||||||
|
|
||||||
<Card.Content >
|
|
||||||
<Skeleton width={200} />
|
|
||||||
</Card.Content>
|
|
||||||
</Card>)
|
|
||||||
|
|
||||||
export default ListItemSkeleton
|
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
|
|
||||||
import NextLink from "next/link"
|
import NextLink from "next/link"
|
||||||
import VisibilityBadge from "../badges/visibility-badge"
|
import VisibilityBadge from "../badges/visibility-badge"
|
||||||
import getPostPath from "@lib/get-post-path"
|
import getPostPath from "@lib/get-post-path"
|
||||||
import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core"
|
import {
|
||||||
|
Link,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
Tooltip,
|
||||||
|
Divider,
|
||||||
|
Badge,
|
||||||
|
Button
|
||||||
|
} from "@geist-ui/core"
|
||||||
import { File, Post } from "@lib/types"
|
import { File, Post } from "@lib/types"
|
||||||
import FadeIn from "@components/fade-in"
|
import FadeIn from "@components/fade-in"
|
||||||
import Trash from "@geist-ui/icons/trash"
|
import Trash from "@geist-ui/icons/trash"
|
||||||
|
@ -10,65 +17,99 @@ import ExpirationBadge from "@components/badges/expiration-badge"
|
||||||
import CreatedAgoBadge from "@components/badges/created-ago-badge"
|
import CreatedAgoBadge from "@components/badges/created-ago-badge"
|
||||||
import Edit from "@geist-ui/icons/edit"
|
import Edit from "@geist-ui/icons/edit"
|
||||||
import { useRouter } from "next/router"
|
import { useRouter } from "next/router"
|
||||||
import Parent from '@geist-ui/icons/arrowUpCircle'
|
import Parent from "@geist-ui/icons/arrowUpCircle"
|
||||||
import styles from "./list-item.module.css"
|
import styles from "./list-item.module.css"
|
||||||
|
|
||||||
// TODO: isOwner should default to false so this can be used generically
|
// TODO: isOwner should default to false so this can be used generically
|
||||||
const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: boolean, deletePost: () => void }) => {
|
const ListItem = ({
|
||||||
const router = useRouter()
|
post,
|
||||||
|
isOwner = true,
|
||||||
|
deletePost
|
||||||
|
}: {
|
||||||
|
post: Post
|
||||||
|
isOwner?: boolean
|
||||||
|
deletePost: () => void
|
||||||
|
}) => {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const editACopy = () => {
|
const editACopy = () => {
|
||||||
router.push(`/new/from/${post.id}`)
|
router.push(`/new/from/${post.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<FadeIn><li key={post.id}>
|
return (
|
||||||
<Card style={{ overflowY: 'scroll' }}>
|
<FadeIn>
|
||||||
<Card.Body>
|
<li key={post.id}>
|
||||||
<Text h3 className={styles.title}>
|
<Card style={{ overflowY: "scroll" }}>
|
||||||
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
|
<Card.Body>
|
||||||
<Link color marginRight={'var(--gap)'}>
|
<Text h3 className={styles.title}>
|
||||||
{post.title}
|
<NextLink
|
||||||
</Link>
|
passHref={true}
|
||||||
</NextLink>
|
href={getPostPath(post.visibility, post.id)}
|
||||||
{isOwner && <span className={styles.buttons}>
|
>
|
||||||
{post.parent && <Tooltip text={"View parent"} hideArrow>
|
<Link color marginRight={"var(--gap)"}>
|
||||||
<Button
|
{post.title}
|
||||||
auto
|
</Link>
|
||||||
icon={<Parent />}
|
</NextLink>
|
||||||
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
|
{isOwner && (
|
||||||
/>
|
<span className={styles.buttons}>
|
||||||
</Tooltip>}
|
{post.parent && (
|
||||||
<Tooltip text={"Make a copy"} hideArrow>
|
<Tooltip text={"View parent"} hideArrow>
|
||||||
<Button
|
<Button
|
||||||
auto
|
auto
|
||||||
iconRight={<Edit />}
|
icon={<Parent />}
|
||||||
onClick={editACopy} />
|
onClick={() =>
|
||||||
</Tooltip>
|
router.push(
|
||||||
<Tooltip text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip>
|
getPostPath(
|
||||||
</span>}
|
post.parent!.visibility,
|
||||||
</Text>
|
post.parent!.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip text={"Make a copy"} hideArrow>
|
||||||
|
<Button auto iconRight={<Edit />} onClick={editACopy} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text={"Delete"} hideArrow>
|
||||||
|
<Button iconRight={<Trash />} onClick={deletePost} auto />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<div className={styles.badges}>
|
<div className={styles.badges}>
|
||||||
<VisibilityBadge visibility={post.visibility} />
|
<VisibilityBadge visibility={post.visibility} />
|
||||||
<CreatedAgoBadge createdAt={post.createdAt} />
|
<CreatedAgoBadge createdAt={post.createdAt} />
|
||||||
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
|
<Badge type="secondary">
|
||||||
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
{post.files?.length === 1
|
||||||
</div>
|
? "1 file"
|
||||||
|
: `${post.files?.length || 0} files`}
|
||||||
</Card.Body>
|
</Badge>
|
||||||
<Divider h="1px" my={0} />
|
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
||||||
<Card.Content>
|
</div>
|
||||||
{post.files?.map((file: File) => {
|
</Card.Body>
|
||||||
return <div key={file.id}>
|
<Divider h="1px" my={0} />
|
||||||
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
|
<Card.Content>
|
||||||
{file.title || 'Untitled file'}
|
{post.files?.map((file: File) => {
|
||||||
</Link></div>
|
return (
|
||||||
})}
|
<div key={file.id}>
|
||||||
</Card.Content>
|
<Link
|
||||||
|
color
|
||||||
</Card>
|
href={`${getPostPath(post.visibility, post.id)}#${
|
||||||
|
file.title
|
||||||
</li> </FadeIn>)
|
}`}
|
||||||
|
>
|
||||||
|
{file.title || "Untitled file"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</li>{" "}
|
||||||
|
</FadeIn>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ListItem
|
export default ListItem
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import PageSeo from "@components/page-seo"
|
import PageSeo from "@components/page-seo"
|
||||||
import VisibilityBadge from "@components/badges/visibility-badge"
|
import VisibilityBadge from "@components/badges/visibility-badge"
|
||||||
import DocumentComponent from '@components/view-document'
|
import DocumentComponent from "@components/view-document"
|
||||||
import styles from './post-page.module.css'
|
import styles from "./post-page.module.css"
|
||||||
import homeStyles from '@styles/Home.module.css'
|
import homeStyles from "@styles/Home.module.css"
|
||||||
|
|
||||||
import type { File, Post } from "@lib/types"
|
import type { File, Post } from "@lib/types"
|
||||||
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
|
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Archive from '@geist-ui/icons/archive'
|
import Archive from "@geist-ui/icons/archive"
|
||||||
import Edit from '@geist-ui/icons/edit'
|
import Edit from "@geist-ui/icons/edit"
|
||||||
import Parent from '@geist-ui/icons/arrowUpCircle'
|
import Parent from "@geist-ui/icons/arrowUpCircle"
|
||||||
import FileDropdown from "@components/file-dropdown"
|
import FileDropdown from "@components/file-dropdown"
|
||||||
import ScrollToTop from "@components/scroll-to-top"
|
import ScrollToTop from "@components/scroll-to-top"
|
||||||
import { useRouter } from "next/router"
|
import { useRouter } from "next/router"
|
||||||
|
@ -19,124 +19,144 @@ import Cookies from "js-cookie"
|
||||||
import getPostPath from "@lib/get-post-path"
|
import getPostPath from "@lib/get-post-path"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: Post
|
post: Post
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostPage = ({ post }: Props) => {
|
const PostPage = ({ post }: Props) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const isMobile = useMediaQuery("mobile")
|
const isMobile = useMediaQuery("mobile")
|
||||||
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null)
|
const [isExpired, setIsExpired] = useState(
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
|
||||||
useEffect(() => {
|
)
|
||||||
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
if (!isOwner && isExpired) {
|
useEffect(() => {
|
||||||
router.push("/expired")
|
const isOwner = post.users
|
||||||
}
|
? post.users[0].id === Cookies.get("drift-userid")
|
||||||
|
: false
|
||||||
|
if (!isOwner && isExpired) {
|
||||||
|
router.push("/expired")
|
||||||
|
}
|
||||||
|
|
||||||
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
||||||
if (!isOwner && expirationDate < new Date()) {
|
if (!isOwner && expirationDate < new Date()) {
|
||||||
router.push("/expired")
|
router.push("/expired")
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
let interval: NodeJS.Timer | null = null;
|
let interval: NodeJS.Timer | null = null
|
||||||
if (post.expiresAt) {
|
if (post.expiresAt) {
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
|
||||||
setIsExpired(expirationDate < new Date())
|
setIsExpired(expirationDate < new Date())
|
||||||
}, 4000)
|
}, 4000)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval)
|
if (interval) clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [isExpired, post.expiresAt, post.users, router])
|
}, [isExpired, post.expiresAt, post.users, router])
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
if (!post.files) return
|
||||||
|
const downloadZip = (await import("client-zip")).downloadZip
|
||||||
|
const blob = await downloadZip(
|
||||||
|
post.files.map((file: any) => {
|
||||||
|
return {
|
||||||
|
name: file.title,
|
||||||
|
input: file.content,
|
||||||
|
lastModified: new Date(file.updatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).blob()
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = `${post.title}.zip`
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
const download = async () => {
|
const editACopy = () => {
|
||||||
if (!post.files) return
|
router.push(`/new/from/${post.id}`)
|
||||||
const downloadZip = (await import("client-zip")).downloadZip
|
}
|
||||||
const blob = await downloadZip(post.files.map((file: any) => {
|
|
||||||
return {
|
|
||||||
name: file.title,
|
|
||||||
input: file.content,
|
|
||||||
lastModified: new Date(file.updatedAt)
|
|
||||||
}
|
|
||||||
})).blob()
|
|
||||||
const link = document.createElement("a")
|
|
||||||
link.href = URL.createObjectURL(blob)
|
|
||||||
link.download = `${post.title}.zip`
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const editACopy = () => {
|
if (isLoading) {
|
||||||
router.push(`/new/from/${post.id}`)
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
return (
|
||||||
return <></>
|
<Page width={"100%"}>
|
||||||
}
|
<PageSeo
|
||||||
|
title={`${post.title} - Drift`}
|
||||||
|
description={post.description}
|
||||||
|
isPrivate={false}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<Page.Content className={homeStyles.main}>
|
||||||
<Page width={"100%"}>
|
<div className={styles.header}>
|
||||||
<PageSeo
|
<span className={styles.buttons}>
|
||||||
title={`${post.title} - Drift`}
|
<ButtonGroup
|
||||||
description={post.description}
|
vertical={isMobile}
|
||||||
isPrivate={false}
|
marginLeft={0}
|
||||||
/>
|
marginRight={0}
|
||||||
|
marginTop={1}
|
||||||
<Page.Content className={homeStyles.main}>
|
marginBottom={1}
|
||||||
<div className={styles.header}>
|
>
|
||||||
<span className={styles.buttons}>
|
<Button
|
||||||
<ButtonGroup vertical={isMobile} marginLeft={0} marginRight={0} marginTop={1} marginBottom={1}>
|
auto
|
||||||
<Button
|
icon={<Edit />}
|
||||||
auto
|
onClick={editACopy}
|
||||||
icon={<Edit />}
|
style={{ textTransform: "none" }}
|
||||||
onClick={editACopy}
|
>
|
||||||
style={{ textTransform: 'none' }}>
|
Edit a Copy
|
||||||
Edit a Copy
|
</Button>
|
||||||
</Button>
|
{post.parent && (
|
||||||
{post.parent && <Button
|
<Button
|
||||||
auto
|
auto
|
||||||
icon={<Parent />}
|
icon={<Parent />}
|
||||||
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
|
onClick={() =>
|
||||||
>
|
router.push(
|
||||||
View Parent
|
getPostPath(post.parent!.visibility, post.parent!.id)
|
||||||
</Button>}
|
)
|
||||||
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}>
|
}
|
||||||
Download as ZIP Archive
|
>
|
||||||
</Button>
|
View Parent
|
||||||
<FileDropdown isMobile={isMobile} files={post.files || []} />
|
</Button>
|
||||||
</ButtonGroup>
|
)}
|
||||||
</span>
|
<Button
|
||||||
<span className={styles.title}>
|
auto
|
||||||
<Text h3>{post.title}</Text>
|
onClick={download}
|
||||||
<span className={styles.badges}>
|
icon={<Archive />}
|
||||||
<VisibilityBadge visibility={post.visibility} />
|
style={{ textTransform: "none" }}
|
||||||
<CreatedAgoBadge createdAt={post.createdAt} />
|
>
|
||||||
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
Download as ZIP Archive
|
||||||
</span>
|
</Button>
|
||||||
</span>
|
<FileDropdown isMobile={isMobile} files={post.files || []} />
|
||||||
|
</ButtonGroup>
|
||||||
|
</span>
|
||||||
</div>
|
<span className={styles.title}>
|
||||||
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
<Text h3>{post.title}</Text>
|
||||||
{post.files?.map(({ id, content, title }: File) => (
|
<span className={styles.badges}>
|
||||||
<DocumentComponent
|
<VisibilityBadge visibility={post.visibility} />
|
||||||
key={id}
|
<CreatedAgoBadge createdAt={post.createdAt} />
|
||||||
title={title}
|
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
||||||
initialTab={'preview'}
|
</span>
|
||||||
id={id}
|
</span>
|
||||||
content={content}
|
</div>
|
||||||
/>
|
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
||||||
))}
|
{post.files?.map(({ id, content, title }: File) => (
|
||||||
<ScrollToTop />
|
<DocumentComponent
|
||||||
|
key={id}
|
||||||
</Page.Content>
|
title={title}
|
||||||
</Page >
|
initialTab={"preview"}
|
||||||
)
|
id={id}
|
||||||
|
content={content}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ScrollToTop />
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostPage
|
export default PostPage
|
||||||
|
|
|
@ -1,58 +1,67 @@
|
||||||
import Cookies from "js-cookie"
|
import Cookies from "js-cookie"
|
||||||
import { memo, useEffect, useState } from "react"
|
import { memo, useEffect, useState } from "react"
|
||||||
import styles from './preview.module.css'
|
import styles from "./preview.module.css"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
height?: number | string
|
height?: number | string
|
||||||
fileId?: string
|
fileId?: string
|
||||||
content?: string
|
content?: string
|
||||||
title?: string
|
title?: string
|
||||||
// file extensions we can highlight
|
// file extensions we can highlight
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
|
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
|
||||||
const [preview, setPreview] = useState<string>(content || "")
|
const [preview, setPreview] = useState<string>(content || "")
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchPost() {
|
async function fetchPost() {
|
||||||
if (fileId) {
|
if (fileId) {
|
||||||
const resp = await fetch(`/api/html/${fileId}`, {
|
const resp = await fetch(`/api/html/${fileId}`, {
|
||||||
method: "GET",
|
method: "GET"
|
||||||
})
|
})
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const res = await resp.text()
|
const res = await resp.text()
|
||||||
setPreview(res)
|
setPreview(res)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} else if (content) {
|
} else if (content) {
|
||||||
const resp = await fetch("/server-api/files/html", {
|
const resp = await fetch("/server-api/files/html", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${Cookies.get("drift-token") || ""}`,
|
Authorization: `Bearer ${Cookies.get("drift-token") || ""}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
content,
|
content
|
||||||
}),
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (resp.ok) {
|
|
||||||
const res = await resp.text()
|
|
||||||
setPreview(res)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
fetchPost()
|
|
||||||
}, [content, fileId, title])
|
|
||||||
return (<>
|
|
||||||
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
|
|
||||||
height
|
|
||||||
}} />}
|
|
||||||
</>)
|
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const res = await resp.text()
|
||||||
|
setPreview(res)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
fetchPost()
|
||||||
|
}, [content, fileId, title])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : (
|
||||||
|
<article
|
||||||
|
className={styles.markdownPreview}
|
||||||
|
dangerouslySetInnerHTML={{ __html: preview }}
|
||||||
|
style={{
|
||||||
|
height
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MarkdownPreview)
|
export default memo(MarkdownPreview)
|
||||||
|
|
|
@ -1,37 +1,58 @@
|
||||||
import { Tooltip, Button, Spacer } from '@geist-ui/core'
|
import { Tooltip, Button, Spacer } from "@geist-ui/core"
|
||||||
import ChevronUp from '@geist-ui/icons/chevronUpCircleFill'
|
import ChevronUp from "@geist-ui/icons/chevronUpCircleFill"
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react"
|
||||||
import styles from './scroll.module.css'
|
import styles from "./scroll.module.css"
|
||||||
|
|
||||||
const ScrollToTop = () => {
|
const ScrollToTop = () => {
|
||||||
const [shouldShow, setShouldShow] = useState(false)
|
const [shouldShow, setShouldShow] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if user is scrolled, set visible
|
// if user is scrolled, set visible
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
setShouldShow(window.scrollY > 100)
|
setShouldShow(window.scrollY > 100)
|
||||||
}
|
}
|
||||||
window.addEventListener('scroll', handleScroll)
|
window.addEventListener("scroll", handleScroll)
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isReducedMotion = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
|
const isReducedMotion =
|
||||||
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
typeof window !== "undefined"
|
||||||
e.currentTarget.blur()
|
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
: false
|
||||||
|
const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
|
||||||
window.scrollTo({ top: 0, behavior: isReducedMotion ? 'auto' : 'smooth' })
|
window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
return (
|
||||||
|
<div
|
||||||
return (
|
style={{
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}>
|
display: "flex",
|
||||||
<Tooltip hideArrow text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}>
|
flexDirection: "row",
|
||||||
<Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto >
|
width: "100%",
|
||||||
<Spacer height={2 / 3} inline width={0} />
|
height: 24,
|
||||||
<ChevronUp />
|
justifyContent: "flex-end"
|
||||||
</Button>
|
}}
|
||||||
</Tooltip>
|
>
|
||||||
</div>
|
<Tooltip
|
||||||
)
|
hideArrow
|
||||||
|
text="Scroll to Top"
|
||||||
|
className={`${styles["scroll-up"]} ${
|
||||||
|
shouldShow ? styles["scroll-up-shown"] : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="Scroll to Top"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ background: "var(--light-gray)" }}
|
||||||
|
auto
|
||||||
|
>
|
||||||
|
<Spacer height={2 / 3} inline width={0} />
|
||||||
|
<ChevronUp />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScrollToTop
|
export default ScrollToTop
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
// https://www.joshwcomeau.com/snippets/react-components/shift-by/
|
// https://www.joshwcomeau.com/snippets/react-components/shift-by/
|
||||||
type Props = {
|
type Props = {
|
||||||
x?: number
|
x?: number
|
||||||
y?: number
|
y?: number
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftBy({ x = 0, y = 0, children }: Props) {
|
function ShiftBy({ x = 0, y = 0, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${x}px, ${y}px)`,
|
transform: `translate(${x}px, ${y}px)`,
|
||||||
display: 'inline-block'
|
display: "inline-block"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default ShiftBy
|
export default ShiftBy
|
||||||
|
|
|
@ -1,125 +1,169 @@
|
||||||
|
|
||||||
|
|
||||||
import { memo, useRef, useState } from "react"
|
import { memo, useRef, useState } from "react"
|
||||||
import styles from './document.module.css'
|
import styles from "./document.module.css"
|
||||||
import Download from '@geist-ui/icons/download'
|
import Download from "@geist-ui/icons/download"
|
||||||
import ExternalLink from '@geist-ui/icons/externalLink'
|
import ExternalLink from "@geist-ui/icons/externalLink"
|
||||||
import Skeleton from "react-loading-skeleton"
|
import Skeleton from "react-loading-skeleton"
|
||||||
|
|
||||||
import { Button, Text, ButtonGroup, Spacer, Tabs, Textarea, Tooltip, Link, Tag } from "@geist-ui/core"
|
import {
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
ButtonGroup,
|
||||||
|
Spacer,
|
||||||
|
Tabs,
|
||||||
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
|
Link,
|
||||||
|
Tag
|
||||||
|
} from "@geist-ui/core"
|
||||||
import HtmlPreview from "@components/preview"
|
import HtmlPreview from "@components/preview"
|
||||||
import FadeIn from "@components/fade-in"
|
import FadeIn from "@components/fade-in"
|
||||||
|
|
||||||
// import Link from "next/link"
|
// import Link from "next/link"
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string
|
title: string
|
||||||
initialTab?: "edit" | "preview"
|
initialTab?: "edit" | "preview"
|
||||||
skeleton?: boolean
|
skeleton?: boolean
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
|
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
|
||||||
return (<div className={styles.actionWrapper}>
|
return (
|
||||||
<ButtonGroup className={styles.actions}>
|
<div className={styles.actionWrapper}>
|
||||||
<Tooltip hideArrow text="Download">
|
<ButtonGroup className={styles.actions}>
|
||||||
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
|
<Tooltip hideArrow text="Download">
|
||||||
<Button
|
<a
|
||||||
scale={2 / 3} px={0.6}
|
href={`${rawLink}?download=true`}
|
||||||
icon={<Download />}
|
target="_blank"
|
||||||
auto
|
rel="noopener noreferrer"
|
||||||
aria-label="Download"
|
>
|
||||||
/>
|
<Button
|
||||||
</a>
|
scale={2 / 3}
|
||||||
</Tooltip>
|
px={0.6}
|
||||||
<Tooltip hideArrow text="Open raw in new tab">
|
icon={<Download />}
|
||||||
<a href={rawLink} target="_blank" rel="noopener noreferrer">
|
auto
|
||||||
<Button
|
aria-label="Download"
|
||||||
scale={2 / 3} px={0.6}
|
/>
|
||||||
icon={<ExternalLink />}
|
</a>
|
||||||
auto
|
</Tooltip>
|
||||||
aria-label="Open raw file in new tab"
|
<Tooltip hideArrow text="Open raw in new tab">
|
||||||
/>
|
<a href={rawLink} target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
<Button
|
||||||
</Tooltip>
|
scale={2 / 3}
|
||||||
</ButtonGroup>
|
px={0.6}
|
||||||
</div>)
|
icon={<ExternalLink />}
|
||||||
|
auto
|
||||||
|
aria-label="Open raw file in new tab"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Document = ({
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
initialTab = "edit",
|
||||||
|
skeleton,
|
||||||
|
id
|
||||||
|
}: Props) => {
|
||||||
|
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [tab, setTab] = useState(initialTab)
|
||||||
|
// const height = editable ? "500px" : '100%'
|
||||||
|
const height = "100%"
|
||||||
|
|
||||||
const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props) => {
|
const handleTabChange = (newTab: string) => {
|
||||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
if (newTab === "edit") {
|
||||||
const [tab, setTab] = useState(initialTab)
|
codeEditorRef.current?.focus()
|
||||||
// const height = editable ? "500px" : '100%'
|
}
|
||||||
const height = "100%";
|
setTab(newTab as "edit" | "preview")
|
||||||
|
}
|
||||||
|
|
||||||
const handleTabChange = (newTab: string) => {
|
const rawLink = () => {
|
||||||
if (newTab === 'edit') {
|
if (id) {
|
||||||
codeEditorRef.current?.focus()
|
return `/file/raw/${id}`
|
||||||
}
|
}
|
||||||
setTab(newTab as 'edit' | 'preview')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const rawLink = () => {
|
if (skeleton) {
|
||||||
if (id) {
|
return (
|
||||||
return `/file/raw/${id}`
|
<>
|
||||||
}
|
<Spacer height={1} />
|
||||||
}
|
<div className={styles.card}>
|
||||||
|
<div className={styles.fileNameContainer}>
|
||||||
|
<Skeleton width={275} height={36} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.descriptionContainer}>
|
||||||
|
<div style={{ flexDirection: "row", display: "flex" }}>
|
||||||
|
<Skeleton width={125} height={36} />
|
||||||
|
</div>
|
||||||
|
<Skeleton width={"100%"} height={350} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (skeleton) {
|
return (
|
||||||
return <>
|
<FadeIn>
|
||||||
<Spacer height={1} />
|
<Spacer height={1} />
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.fileNameContainer}>
|
<Link href={`#${title}`} className={styles.fileNameContainer}>
|
||||||
<Skeleton width={275} height={36} />
|
<Tag
|
||||||
</div>
|
height={"100%"}
|
||||||
<div className={styles.descriptionContainer}>
|
id={`${title}`}
|
||||||
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
width={"100%"}
|
||||||
<Skeleton width={'100%'} height={350} />
|
style={{ borderRadius: 0 }}
|
||||||
</div >
|
>
|
||||||
</div>
|
{title || "Untitled"}
|
||||||
</>
|
</Tag>
|
||||||
}
|
</Link>
|
||||||
|
<div className={styles.descriptionContainer}>
|
||||||
|
<DownloadButton rawLink={rawLink()} />
|
||||||
return (
|
<Tabs
|
||||||
<FadeIn>
|
onChange={handleTabChange}
|
||||||
<Spacer height={1} />
|
initialValue={initialTab}
|
||||||
<div className={styles.card}>
|
hideDivider
|
||||||
<Link href={`#${title}`} className={styles.fileNameContainer}>
|
leftSpace={0}
|
||||||
<Tag height={"100%"} id={`${title}`} width={"100%"} style={{ borderRadius: 0 }}>
|
>
|
||||||
{title || 'Untitled'}
|
<Tabs.Item label={"Raw"} value="edit">
|
||||||
</Tag>
|
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||||
</Link>
|
<div
|
||||||
<div className={styles.descriptionContainer}>
|
style={{
|
||||||
<DownloadButton rawLink={rawLink()} />
|
marginTop: "var(--gap-half)",
|
||||||
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
display: "flex",
|
||||||
<Tabs.Item label={"Raw"} value="edit">
|
flexDirection: "column"
|
||||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
}}
|
||||||
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
readOnly
|
readOnly
|
||||||
ref={codeEditorRef}
|
ref={codeEditorRef}
|
||||||
value={content}
|
value={content}
|
||||||
width="100%"
|
width="100%"
|
||||||
// TODO: Textarea should grow to fill parent if height == 100%
|
// TODO: Textarea should grow to fill parent if height == 100%
|
||||||
style={{ flex: 1, minHeight: 350 }}
|
style={{ flex: 1, minHeight: 350 }}
|
||||||
resize="vertical"
|
resize="vertical"
|
||||||
className={styles.textarea}
|
className={styles.textarea}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
<Tabs.Item label="Preview" value="preview">
|
<Tabs.Item label="Preview" value="preview">
|
||||||
<div style={{ marginTop: 'var(--gap-half)', }}>
|
<div style={{ marginTop: "var(--gap-half)" }}>
|
||||||
<HtmlPreview height={height} fileId={id} content={content} title={title} />
|
<HtmlPreview
|
||||||
</div>
|
height={height}
|
||||||
</Tabs.Item>
|
fileId={id}
|
||||||
</Tabs>
|
content={content}
|
||||||
</div>
|
title={title}
|
||||||
</div>
|
/>
|
||||||
</FadeIn>
|
</div>
|
||||||
)
|
</Tabs.Item>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(Document)
|
||||||
export default memo(Document)
|
|
||||||
|
|
|
@ -1,74 +1,74 @@
|
||||||
{
|
{
|
||||||
"name": "drift",
|
"name": "drift",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3001",
|
"dev": "next dev --port 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint && prettier --config .prettierrc '{components,lib,pages}/**/*.ts' --write",
|
"lint": "next lint && prettier --list-different --config .prettierrc '{components,lib,pages}/**/*.{ts,tsx}' --write",
|
||||||
"analyze": "cross-env ANALYZE=true next build",
|
"analyze": "cross-env ANALYZE=true next build",
|
||||||
"find:unused": "next-unused"
|
"find:unused": "next-unused"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@geist-ui/core": "^2.3.5",
|
"@geist-ui/core": "^2.3.5",
|
||||||
"@geist-ui/icons": "^1.0.1",
|
"@geist-ui/icons": "^1.0.1",
|
||||||
"@types/cookie": "^0.4.1",
|
"@types/cookie": "^0.4.1",
|
||||||
"@types/js-cookie": "^3.0.1",
|
"@types/js-cookie": "^3.0.1",
|
||||||
"client-zip": "^2.0.0",
|
"client-zip": "^2.0.0",
|
||||||
"cookie": "^0.4.2",
|
"cookie": "^0.4.2",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"marked": "^4.0.12",
|
"marked": "^4.0.12",
|
||||||
"next": "^12.1.1-canary.15",
|
"next": "^12.1.1-canary.15",
|
||||||
"next-themes": "^0.1.1",
|
"next-themes": "^0.1.1",
|
||||||
"postcss": "^8.4.12",
|
"postcss": "^8.4.12",
|
||||||
"postcss-flexbugs-fixes": "^5.0.2",
|
"postcss-flexbugs-fixes": "^5.0.2",
|
||||||
"postcss-hover-media-feature": "^1.0.2",
|
"postcss-hover-media-feature": "^1.0.2",
|
||||||
"postcss-preset-env": "^7.4.3",
|
"postcss-preset-env": "^7.4.3",
|
||||||
"preact": "^10.6.6",
|
"preact": "^10.6.6",
|
||||||
"prism-react-renderer": "^1.3.1",
|
"prism-react-renderer": "^1.3.1",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-datepicker": "^4.7.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-dropzone": "^12.0.4",
|
"react-dropzone": "^12.0.4",
|
||||||
"react-loading-skeleton": "^3.0.3",
|
"react-loading-skeleton": "^3.0.3",
|
||||||
"react-markdown": "^8.0.0",
|
"react-markdown": "^8.0.0",
|
||||||
"react-syntax-highlighter": "^15.4.5",
|
"react-syntax-highlighter": "^15.4.5",
|
||||||
"rehype-autolink-headings": "^6.1.1",
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
"rehype-raw": "^6.1.1",
|
"rehype-raw": "^6.1.1",
|
||||||
"rehype-slug": "^5.0.1",
|
"rehype-slug": "^5.0.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"swr": "^1.2.2"
|
"swr": "^1.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "^12.1.0",
|
"@next/bundle-analyzer": "^12.1.0",
|
||||||
"@types/lodash.debounce": "^4.0.6",
|
"@types/lodash.debounce": "^4.0.6",
|
||||||
"@types/marked": "^4.0.3",
|
"@types/marked": "^4.0.3",
|
||||||
"@types/node": "17.0.21",
|
"@types/node": "17.0.21",
|
||||||
"@types/nprogress": "^0.2.0",
|
"@types/nprogress": "^0.2.0",
|
||||||
"@types/react": "17.0.39",
|
"@types/react": "17.0.39",
|
||||||
"@types/react-datepicker": "^4.3.4",
|
"@types/react-datepicker": "^4.3.4",
|
||||||
"@types/react-dom": "^17.0.14",
|
"@types/react-dom": "^17.0.14",
|
||||||
"@types/react-syntax-highlighter": "^13.5.2",
|
"@types/react-syntax-highlighter": "^13.5.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "8.10.0",
|
"eslint": "8.10.0",
|
||||||
"eslint-config-next": "^12.1.1-canary.16",
|
"eslint-config-next": "^12.1.1-canary.16",
|
||||||
"next-unused": "^0.0.6",
|
"next-unused": "^0.0.6",
|
||||||
"prettier": "^2.6.0",
|
"prettier": "^2.6.0",
|
||||||
"typescript": "4.6.2",
|
"typescript": "4.6.2",
|
||||||
"typescript-plugin-css-modules": "^3.4.0"
|
"typescript-plugin-css-modules": "^3.4.0"
|
||||||
},
|
},
|
||||||
"next-unused": {
|
"next-unused": {
|
||||||
"alias": {
|
"alias": {
|
||||||
"@components": "components/",
|
"@components": "components/",
|
||||||
"@lib": "lib/",
|
"@lib": "lib/",
|
||||||
"@styles": "styles/"
|
"@styles": "styles/"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"components",
|
"components",
|
||||||
"lib"
|
"lib"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,58 @@
|
||||||
import '@styles/globals.css'
|
import "@styles/globals.css"
|
||||||
import type { AppProps as NextAppProps } from "next/app";
|
import type { AppProps as NextAppProps } from "next/app"
|
||||||
|
|
||||||
import 'react-loading-skeleton/dist/skeleton.css'
|
import "react-loading-skeleton/dist/skeleton.css"
|
||||||
import Head from 'next/head';
|
import Head from "next/head"
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from "next-themes"
|
||||||
import App from '@components/app';
|
import App from "@components/app"
|
||||||
|
|
||||||
type AppProps<P = any> = {
|
type AppProps<P = any> = {
|
||||||
pageProps: P;
|
pageProps: P
|
||||||
} & Omit<NextAppProps<P>, "pageProps">;
|
} & Omit<NextAppProps<P>, "pageProps">
|
||||||
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }: AppProps) {
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
<meta
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
|
name="viewport"
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
|
/>
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link
|
||||||
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5" />
|
rel="apple-touch-icon"
|
||||||
<meta name="apple-mobile-web-app-title" content="Drift" />
|
sizes="180x180"
|
||||||
<meta name="application-name" content="Drift" />
|
href="/assets/apple-touch-icon.png"
|
||||||
<meta name="msapplication-TileColor" content="#da532c" />
|
/>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<link
|
||||||
<title>Drift</title>
|
rel="icon"
|
||||||
</Head>
|
type="image/png"
|
||||||
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
|
sizes="32x32"
|
||||||
<App Component={Component} pageProps={pageProps} />
|
href="/assets/favicon-32x32.png"
|
||||||
</ThemeProvider>
|
/>
|
||||||
</div>
|
<link
|
||||||
)
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/assets/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link
|
||||||
|
rel="mask-icon"
|
||||||
|
href="/assets/safari-pinned-tab.svg"
|
||||||
|
color="#5bbad5"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Drift" />
|
||||||
|
<meta name="application-name" content="Drift" />
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<title>Drift</title>
|
||||||
|
</Head>
|
||||||
|
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
|
||||||
|
<App Component={Component} pageProps={pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyApp
|
export default MyApp
|
||||||
|
|
|
@ -1,31 +1,39 @@
|
||||||
import { CssBaseline } from '@geist-ui/core'
|
import { CssBaseline } from "@geist-ui/core"
|
||||||
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
|
import Document, {
|
||||||
|
Html,
|
||||||
|
Head,
|
||||||
|
Main,
|
||||||
|
NextScript,
|
||||||
|
DocumentContext
|
||||||
|
} from "next/document"
|
||||||
|
|
||||||
class MyDocument extends Document {
|
class MyDocument extends Document {
|
||||||
static async getInitialProps(ctx: DocumentContext) {
|
static async getInitialProps(ctx: DocumentContext) {
|
||||||
const initialProps = await Document.getInitialProps(ctx)
|
const initialProps = await Document.getInitialProps(ctx)
|
||||||
const styles = CssBaseline.flush()
|
const styles = CssBaseline.flush()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
styles: (
|
styles: (
|
||||||
<>
|
<>
|
||||||
{initialProps.styles}
|
{initialProps.styles}
|
||||||
{styles}
|
{styles}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (<Html lang="en">
|
return (
|
||||||
<Head />
|
<Html lang="en">
|
||||||
<body>
|
<Head />
|
||||||
<Main />
|
<body>
|
||||||
<NextScript />
|
<Main />
|
||||||
</body>
|
<NextScript />
|
||||||
</Html>)
|
</body>
|
||||||
}
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyDocument
|
export default MyDocument
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
import ErrorComponent from '@components/error'
|
import ErrorComponent from "@components/error"
|
||||||
|
|
||||||
function Error({ statusCode }: {
|
function Error({ statusCode }: { statusCode: number }) {
|
||||||
statusCode: number
|
return <ErrorComponent status={statusCode} />
|
||||||
}) {
|
|
||||||
return <ErrorComponent status={statusCode} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Error.getInitialProps = ({ res, err }: {
|
Error.getInitialProps = ({ res, err }: { res: any; err: any }) => {
|
||||||
res: any
|
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
|
||||||
err: any
|
return { statusCode }
|
||||||
}) => {
|
|
||||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
|
|
||||||
return { statusCode }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Error
|
export default Error
|
||||||
|
|
|
@ -1,53 +1,55 @@
|
||||||
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'
|
import { NextFetchEvent, NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
const PUBLIC_FILE = /\.(.*)$/
|
const PUBLIC_FILE = /\.(.*)$/
|
||||||
|
|
||||||
export function middleware(req: NextRequest, event: NextFetchEvent) {
|
export function middleware(req: NextRequest, event: NextFetchEvent) {
|
||||||
const pathname = req.nextUrl.pathname
|
const pathname = req.nextUrl.pathname
|
||||||
const signedIn = req.cookies['drift-token']
|
const signedIn = req.cookies["drift-token"]
|
||||||
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
|
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
|
||||||
const isPageRequest =
|
const isPageRequest =
|
||||||
!PUBLIC_FILE.test(pathname) &&
|
!PUBLIC_FILE.test(pathname) &&
|
||||||
!pathname.startsWith('/api') &&
|
!pathname.startsWith("/api") &&
|
||||||
// header added when next/link pre-fetches a route
|
// header added when next/link pre-fetches a route
|
||||||
!req.headers.get('x-middleware-preflight')
|
!req.headers.get("x-middleware-preflight")
|
||||||
|
|
||||||
if (!req.headers.get('x-middleware-preflight') && pathname === '/signout') {
|
if (!req.headers.get("x-middleware-preflight") && pathname === "/signout") {
|
||||||
// If you're signed in we remove the cookie and redirect to the home page
|
// If you're signed in we remove the cookie and redirect to the home page
|
||||||
// If you're not signed in we redirect to the home page
|
// If you're not signed in we redirect to the home page
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
const resp = NextResponse.redirect(getURL(''));
|
const resp = NextResponse.redirect(getURL(""))
|
||||||
resp.clearCookie('drift-token');
|
resp.clearCookie("drift-token")
|
||||||
resp.clearCookie('drift-userid');
|
resp.clearCookie("drift-userid")
|
||||||
const signoutPromise = new Promise((resolve) => {
|
const signoutPromise = new Promise((resolve) => {
|
||||||
fetch(`${process.env.API_URL}/auth/signout`, {
|
fetch(`${process.env.API_URL}/auth/signout`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${signedIn}`,
|
Authorization: `Bearer ${signedIn}`,
|
||||||
'x-secret-key': process.env.SECRET_KEY || '',
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
},
|
}
|
||||||
})
|
}).then(() => {
|
||||||
.then(() => {
|
resolve(true)
|
||||||
resolve(true)
|
})
|
||||||
})
|
})
|
||||||
})
|
event.waitUntil(signoutPromise)
|
||||||
event.waitUntil(signoutPromise)
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
} else if (isPageRequest) {
|
} else if (isPageRequest) {
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
if (pathname === '/' || pathname === '/signin' || pathname === '/signup') {
|
if (
|
||||||
return NextResponse.redirect(getURL('new'))
|
pathname === "/" ||
|
||||||
}
|
pathname === "/signin" ||
|
||||||
} else if (!signedIn) {
|
pathname === "/signup"
|
||||||
if (pathname === '/new') {
|
) {
|
||||||
return NextResponse.redirect(getURL('signin'))
|
return NextResponse.redirect(getURL("new"))
|
||||||
}
|
}
|
||||||
}
|
} else if (!signedIn) {
|
||||||
|
if (pathname === "/new") {
|
||||||
|
return NextResponse.redirect(getURL("signin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
return NextResponse.next()
|
||||||
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +1,55 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
|
|
||||||
import Header from '@components/header'
|
import Header from "@components/header"
|
||||||
import { Page } from '@geist-ui/core';
|
import { Page } from "@geist-ui/core"
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react"
|
||||||
import Admin from '@components/admin';
|
import Admin from "@components/admin"
|
||||||
import useSignedIn from '@lib/hooks/use-signed-in';
|
import useSignedIn from "@lib/hooks/use-signed-in"
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from "next/router"
|
||||||
import { GetServerSideProps } from 'next';
|
import { GetServerSideProps } from "next"
|
||||||
import cookie from "cookie";
|
import cookie from "cookie"
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
const { signedIn } = useSignedIn()
|
const { signedIn } = useSignedIn()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === "undefined") return
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
router.push('/')
|
router.push("/")
|
||||||
}
|
}
|
||||||
}, [router, signedIn])
|
}, [router, signedIn])
|
||||||
return (
|
return (
|
||||||
<Page className={styles.wrapper}>
|
<Page className={styles.wrapper}>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<Admin />
|
<Admin />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
||||||
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
|
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
|
||||||
const res = await fetch(`${process.env.API_URL}/admin/is-admin`, {
|
const res = await fetch(`${process.env.API_URL}/admin/is-admin`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${driftToken}`,
|
Authorization: `Bearer ${driftToken}`,
|
||||||
'x-secret-key': process.env.SECRET_KEY || ''
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
signedIn: true
|
signedIn: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/',
|
destination: "/",
|
||||||
permanent: false
|
permanent: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminPage
|
export default AdminPage
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import Header from "@components/header"
|
import Header from "@components/header"
|
||||||
import { Note, Page, Text } from "@geist-ui/core"
|
import { Note, Page, Text } from "@geist-ui/core"
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
|
|
||||||
const Expired = () => {
|
const Expired = () => {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<Note type="error" label={false}>
|
<Note type="error" label={false}>
|
||||||
<Text h4>Error: The Drift you're trying to view has expired.</Text>
|
<Text h4>
|
||||||
</Note>
|
Error: The Drift you're trying to view has expired.
|
||||||
|
</Text>
|
||||||
</Page.Content>
|
</Note>
|
||||||
</Page>
|
</Page.Content>
|
||||||
)
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Expired
|
export default Expired
|
||||||
|
|
|
@ -1,60 +1,65 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
import PageSeo from '@components/page-seo'
|
import PageSeo from "@components/page-seo"
|
||||||
import HomeComponent from '@components/home'
|
import HomeComponent from "@components/home"
|
||||||
import { Page, Text } from '@geist-ui/core'
|
import { Page, Text } from "@geist-ui/core"
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from "next"
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ res }) => {
|
export const getServerSideProps: GetServerSideProps = async ({ res }) => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(process.env.API_URL + `/welcome`,
|
const resp = await fetch(process.env.API_URL + `/welcome`, {
|
||||||
{
|
method: "GET",
|
||||||
method: "GET",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
"x-secret-key": process.env.SECRET_KEY || ''
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const { title, content, rendered } = await resp.json()
|
const { title, content, rendered } = await resp.json()
|
||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Cache-Control',
|
"Cache-Control",
|
||||||
`public, s-maxage=${60 * 60 * 24 * 360}, max-age=${60 * 60 * 24 * 360}`
|
`public, s-maxage=${60 * 60 * 24 * 360}, max-age=${60 * 60 * 24 * 360}`
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
introContent: content || null,
|
introContent: content || null,
|
||||||
rendered: rendered || null,
|
rendered: rendered || null,
|
||||||
introTitle: title || null,
|
introTitle: title || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
error: true
|
error: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
introContent: string
|
introContent: string
|
||||||
introTitle: string
|
introTitle: string
|
||||||
rendered: string
|
rendered: string
|
||||||
error?: boolean
|
error?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ rendered, introContent, introTitle, error }: Props) => {
|
const Home = ({ rendered, introContent, introTitle, error }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.wrapper}>
|
<Page className={styles.wrapper}>
|
||||||
<PageSeo />
|
<PageSeo />
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
{error && <Text>Something went wrong. Is the server running?</Text>}
|
{error && <Text>Something went wrong. Is the server running?</Text>}
|
||||||
{!error && <HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />}
|
{!error && (
|
||||||
</Page.Content>
|
<HomeComponent
|
||||||
</Page>
|
rendered={rendered}
|
||||||
)
|
introContent={introContent}
|
||||||
|
introTitle={introTitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default Home
|
||||||
|
|
|
@ -1,59 +1,67 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
|
|
||||||
import Header from '@components/header'
|
import Header from "@components/header"
|
||||||
import MyPosts from '@components/my-posts'
|
import MyPosts from "@components/my-posts"
|
||||||
import cookie from "cookie";
|
import cookie from "cookie"
|
||||||
import type { GetServerSideProps } from 'next';
|
import type { GetServerSideProps } from "next"
|
||||||
import { Post } from '@lib/types';
|
import { Post } from "@lib/types"
|
||||||
import { Page } from '@geist-ui/core';
|
import { Page } from "@geist-ui/core"
|
||||||
|
|
||||||
const Home = ({ morePosts, posts, error }: { morePosts: boolean, posts: Post[]; error: boolean; }) => {
|
const Home = ({
|
||||||
return (
|
morePosts,
|
||||||
<Page className={styles.wrapper}>
|
posts,
|
||||||
<Page.Content className={styles.main}>
|
error
|
||||||
<MyPosts morePosts={morePosts} error={error} posts={posts} />
|
}: {
|
||||||
</Page.Content>
|
morePosts: boolean
|
||||||
</Page >
|
posts: Post[]
|
||||||
)
|
error: boolean
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Page className={styles.wrapper}>
|
||||||
|
<Page.Content className={styles.main}>
|
||||||
|
<MyPosts morePosts={morePosts} error={error} posts={posts} />
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// get server side props
|
// get server side props
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
||||||
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
|
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
|
||||||
if (!driftToken) {
|
if (!driftToken) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/',
|
destination: "/",
|
||||||
permanent: false,
|
permanent: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
|
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${driftToken}`,
|
Authorization: `Bearer ${driftToken}`,
|
||||||
"x-secret-key": process.env.SECRET_KEY || ''
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!posts.ok) {
|
if (!posts.ok) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/',
|
destination: "/",
|
||||||
permanent: false,
|
permanent: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await posts.json()
|
const data = await posts.json()
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
posts: data.posts,
|
posts: data.posts,
|
||||||
error: posts.status !== 200,
|
error: posts.status !== 200,
|
||||||
morePosts: data.hasMore,
|
morePosts: data.hasMore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default Home
|
||||||
|
|
|
@ -1,76 +1,78 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
import NewPost from '@components/new-post'
|
import NewPost from "@components/new-post"
|
||||||
import Header from '@components/header'
|
import Header from "@components/header"
|
||||||
import PageSeo from '@components/page-seo'
|
import PageSeo from "@components/page-seo"
|
||||||
import { Page } from '@geist-ui/core'
|
import { Page } from "@geist-ui/core"
|
||||||
import Head from 'next/head'
|
import Head from "next/head"
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from "next"
|
||||||
import { Post } from '@lib/types'
|
import { Post } from "@lib/types"
|
||||||
import cookie from 'cookie'
|
import cookie from "cookie"
|
||||||
|
|
||||||
const NewFromExisting = ({
|
const NewFromExisting = ({
|
||||||
post,
|
post,
|
||||||
parentId
|
parentId
|
||||||
}: {
|
}: {
|
||||||
post: Post,
|
post: Post
|
||||||
parentId: string
|
parentId: string
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.wrapper}>
|
<Page className={styles.wrapper}>
|
||||||
<PageSeo title="Create a new Drift" />
|
<PageSeo title="Create a new Drift" />
|
||||||
<Head>
|
<Head>
|
||||||
{/* TODO: solve this. */}
|
{/* TODO: solve this. */}
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||||
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
||||||
</Head>
|
</Head>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<NewPost initialPost={post} newPostParent={parentId} />
|
<NewPost initialPost={post} newPostParent={parentId} />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page >
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => {
|
export const getServerSideProps: GetServerSideProps = async ({
|
||||||
const id = params?.id
|
req,
|
||||||
const redirect = {
|
params
|
||||||
redirect: {
|
}) => {
|
||||||
destination: '/new',
|
const id = params?.id
|
||||||
permanent: false,
|
const redirect = {
|
||||||
}
|
redirect: {
|
||||||
}
|
destination: "/new",
|
||||||
|
permanent: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return redirect
|
return redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
|
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
|
||||||
|
|
||||||
const post = await fetch(`${process.env.API_URL}/posts/${id}`,
|
const post = await fetch(`${process.env.API_URL}/posts/${id}`, {
|
||||||
{
|
method: "GET",
|
||||||
method: "GET",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
Authorization: `Bearer ${driftToken}`,
|
||||||
"Authorization": `Bearer ${driftToken}`,
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
"x-secret-key": process.env.SECRET_KEY || ""
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
if (!post.ok) {
|
if (!post.ok) {
|
||||||
return redirect
|
return redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await post.json()
|
const data = await post.json()
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return redirect
|
return redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
post: data,
|
post: data,
|
||||||
parentId: id
|
parentId: id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NewFromExisting
|
export default NewFromExisting
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
import NewPost from '@components/new-post'
|
import NewPost from "@components/new-post"
|
||||||
import Header from '@components/header'
|
import Header from "@components/header"
|
||||||
import PageSeo from '@components/page-seo'
|
import PageSeo from "@components/page-seo"
|
||||||
import { Page } from '@geist-ui/core'
|
import { Page } from "@geist-ui/core"
|
||||||
import Head from 'next/head'
|
import Head from "next/head"
|
||||||
|
|
||||||
const New = () => {
|
const New = () => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.wrapper}>
|
<Page className={styles.wrapper}>
|
||||||
<PageSeo title="Create a new Drift" />
|
<PageSeo title="Create a new Drift" />
|
||||||
<Head>
|
<Head>
|
||||||
{/* TODO: solve this. */}
|
{/* TODO: solve this. */}
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||||
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
||||||
</Head>
|
</Head>
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<NewPost />
|
<NewPost />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page >
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default New
|
export default New
|
||||||
|
|
|
@ -1,50 +1,51 @@
|
||||||
import type { GetServerSideProps, GetStaticPaths, GetStaticProps } from "next";
|
import type { GetServerSideProps, GetStaticPaths, GetStaticProps } from "next"
|
||||||
|
|
||||||
import type { Post } from "@lib/types";
|
import type { Post } from "@lib/types"
|
||||||
import PostPage from "@components/post-page";
|
import PostPage from "@components/post-page"
|
||||||
|
|
||||||
export type PostProps = {
|
export type PostProps = {
|
||||||
post: Post
|
post: Post
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostView = ({ post }: PostProps) => {
|
const PostView = ({ post }: PostProps) => {
|
||||||
return <PostPage post={post} />
|
return <PostPage post={post} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ params, res }) => {
|
export const getServerSideProps: GetServerSideProps = async ({
|
||||||
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
|
params,
|
||||||
method: "GET",
|
res
|
||||||
headers: {
|
}) => {
|
||||||
"Content-Type": "application/json",
|
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
|
||||||
"x-secret-key": process.env.SECRET_KEY || "",
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
})
|
"Content-Type": "application/json",
|
||||||
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const sMaxAge = 60 * 60 * 24
|
const sMaxAge = 60 * 60 * 24
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Cache-Control',
|
"Cache-Control",
|
||||||
`public, s-maxage=${sMaxAge}, max-age=${sMaxAge}`
|
`public, s-maxage=${sMaxAge}, max-age=${sMaxAge}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!post.ok || post.status !== 200) {
|
if (!post.ok || post.status !== 200) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/404',
|
destination: "/404",
|
||||||
permanent: false,
|
permanent: false
|
||||||
},
|
},
|
||||||
props: {}
|
props: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const json = await post.json()
|
||||||
|
|
||||||
const json = await post.json();
|
return {
|
||||||
|
props: {
|
||||||
return {
|
post: json
|
||||||
props: {
|
}
|
||||||
post: json
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostView
|
export default PostView
|
||||||
|
|
||||||
|
|
|
@ -1,58 +1,60 @@
|
||||||
import cookie from "cookie";
|
import cookie from "cookie"
|
||||||
import type { GetServerSideProps } from "next";
|
import type { GetServerSideProps } from "next"
|
||||||
import { Post } from "@lib/types";
|
import { Post } from "@lib/types"
|
||||||
import PostPage from "@components/post-page";
|
import PostPage from "@components/post-page"
|
||||||
|
|
||||||
export type PostProps = {
|
export type PostProps = {
|
||||||
post: Post
|
post: Post
|
||||||
}
|
}
|
||||||
|
|
||||||
const Post = ({ post, }: PostProps) => {
|
const Post = ({ post }: PostProps) => {
|
||||||
return (<PostPage post={post} />)
|
return <PostPage post={post} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
const headers = context.req.headers
|
const headers = context.req.headers
|
||||||
const host = headers.host
|
const host = headers.host
|
||||||
const driftToken = cookie.parse(headers.cookie || '')[`drift-token`]
|
const driftToken = cookie.parse(headers.cookie || "")[`drift-token`]
|
||||||
|
|
||||||
if (context.query.id) {
|
if (context.query.id) {
|
||||||
const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, {
|
const post = await fetch(
|
||||||
method: "GET",
|
"http://" + host + `/server-api/posts/${context.query.id}`,
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "GET",
|
||||||
"Authorization": `Bearer ${driftToken}`,
|
headers: {
|
||||||
"x-secret-key": process.env.SECRET_KEY || "",
|
"Content-Type": "application/json",
|
||||||
}
|
Authorization: `Bearer ${driftToken}`,
|
||||||
})
|
"x-secret-key": process.env.SECRET_KEY || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!post.ok || post.status !== 200) {
|
if (!post.ok || post.status !== 200) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: '/',
|
destination: "/",
|
||||||
permanent: false,
|
permanent: false
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const json = await post.json();
|
const json = await post.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
post: json
|
post: json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
post: null
|
post: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Post
|
export default Post
|
||||||
|
|
||||||
|
|
|
@ -1,83 +1,92 @@
|
||||||
import { Page, useToasts } from '@geist-ui/core';
|
import { Page, useToasts } from "@geist-ui/core"
|
||||||
|
|
||||||
import type { Post } from "@lib/types";
|
import type { Post } from "@lib/types"
|
||||||
import PasswordModal from "@components/new-post/password-modal";
|
import PasswordModal from "@components/new-post/password-modal"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router"
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie"
|
||||||
import PostPage from "@components/post-page";
|
import PostPage from "@components/post-page"
|
||||||
|
|
||||||
const Post = () => {
|
const Post = () => {
|
||||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true);
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true)
|
||||||
const [post, setPost] = useState<Post>()
|
const [post, setPost] = useState<Post>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { setToast } = useToasts()
|
const { setToast } = useToasts()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.isReady) {
|
if (router.isReady) {
|
||||||
const fetchPostWithAuth = async () => {
|
const fetchPostWithAuth = async () => {
|
||||||
const resp = await fetch(`/server-api/posts/${router.query.id}`, {
|
const resp = await fetch(`/server-api/posts/${router.query.id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${Cookies.get('drift-token')}`
|
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!resp.ok) return
|
if (!resp.ok) return
|
||||||
const post = await resp.json()
|
const post = await resp.json()
|
||||||
|
|
||||||
if (!post) return
|
if (!post) return
|
||||||
setPost(post)
|
setPost(post)
|
||||||
}
|
}
|
||||||
fetchPostWithAuth()
|
fetchPostWithAuth()
|
||||||
}
|
}
|
||||||
}, [router.isReady, router.query.id])
|
}, [router.isReady, router.query.id])
|
||||||
|
|
||||||
const onSubmit = async (password: string) => {
|
const onSubmit = async (password: string) => {
|
||||||
const res = await fetch(`/server-api/posts/${router.query.id}?password=${password}`, {
|
const res = await fetch(
|
||||||
method: "GET",
|
`/server-api/posts/${router.query.id}?password=${password}`,
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "GET",
|
||||||
}
|
headers: {
|
||||||
})
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setToast({
|
setToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: "Wrong password"
|
text: "Wrong password"
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setToast({
|
setToast({
|
||||||
text: data.error,
|
text: data.error,
|
||||||
type: "error"
|
type: "error"
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setPost(data)
|
setPost(data)
|
||||||
setIsPasswordModalOpen(false)
|
setIsPasswordModalOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setIsPasswordModalOpen(false);
|
setIsPasswordModalOpen(false)
|
||||||
router.push("/");
|
router.push("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!router.isReady) {
|
if (!router.isReady) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return <Page>
|
return (
|
||||||
<PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} />
|
<Page>
|
||||||
</Page>
|
<PasswordModal
|
||||||
}
|
creating={false}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isOpen={isPasswordModalOpen}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (<PostPage post={post} />)
|
return <PostPage post={post} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Post
|
export default Post
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { Page } from '@geist-ui/core';
|
import { Page } from "@geist-ui/core"
|
||||||
import PageSeo from "@components/page-seo";
|
import PageSeo from "@components/page-seo"
|
||||||
import Auth from "@components/auth";
|
import Auth from "@components/auth"
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
const SignIn = () => (
|
const SignIn = () => (
|
||||||
<Page width={"100%"}>
|
<Page width={"100%"}>
|
||||||
<PageSeo title="Drift - Sign In" />
|
<PageSeo title="Drift - Sign In" />
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<Auth page="signin" />
|
<Auth page="signin" />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default SignIn
|
export default SignIn
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { Page } from '@geist-ui/core';
|
import { Page } from "@geist-ui/core"
|
||||||
import Auth from "@components/auth";
|
import Auth from "@components/auth"
|
||||||
import PageSeo from '@components/page-seo';
|
import PageSeo from "@components/page-seo"
|
||||||
import styles from '@styles/Home.module.css'
|
import styles from "@styles/Home.module.css"
|
||||||
|
|
||||||
const SignUp = () => (
|
const SignUp = () => (
|
||||||
<Page width="100%">
|
<Page width="100%">
|
||||||
<PageSeo title="Drift - Sign Up" />
|
<PageSeo title="Drift - Sign Up" />
|
||||||
<Page.Content className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<Auth page="signup" />
|
<Auth page="signup" />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default SignUp
|
export default SignUp
|
||||||
|
|
Loading…
Reference in a new issue