client: lint tsx files with prettier

This commit is contained in:
Max Leiter 2022-04-09 17:48:19 -07:00
parent c44ab907bb
commit 36e255ad2b
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
52 changed files with 3306 additions and 2705 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
import { Badge, Tooltip } from "@geist-ui/core";
import { timeAgo } from "@lib/time-ago";
import { useMemo, useState, useEffect } from "react";
import { Badge, Tooltip } from "@geist-ui/core"
import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"
const CreatedAgoBadge = ({ createdAt }: {
createdAt: string | Date;
}) => {
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
@ -16,7 +14,14 @@ const CreatedAgoBadge = ({ createdAt }: {
}, [createdDate])
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

View file

@ -1,19 +1,24 @@
import { Badge, Tooltip } from "@geist-ui/core";
import { timeUntil } from "@lib/time-ago";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge, Tooltip } from "@geist-ui/core"
import { timeUntil } from "@lib/time-ago"
import { useCallback, useEffect, useMemo, useState } from "react"
const ExpirationBadge = ({
postExpirationDate,
// onExpires
}: {
postExpirationDate
}: // onExpires
{
postExpirationDate: Date | string | null
onExpires?: () => void
}) => {
const expirationDate = useMemo(() => postExpirationDate ? new Date(postExpirationDate) : null, [postExpirationDate])
const [timeUntilString, setTimeUntil] = useState<string | null>(expirationDate ? timeUntil(expirationDate) : null);
const expirationDate = useMemo(
() => (postExpirationDate ? new Date(postExpirationDate) : null),
[postExpirationDate]
)
const [timeUntilString, setTimeUntil] = useState<string | null>(
expirationDate ? timeUntil(expirationDate) : null
)
useEffect(() => {
let interval: NodeJS.Timer | null = null;
let interval: NodeJS.Timer | null = null
if (expirationDate) {
interval = setInterval(() => {
if (expirationDate) {
@ -43,14 +48,15 @@ const ExpirationBadge = ({
// }, [isExpired, onExpires])
if (!expirationDate) {
return null;
return null
}
return (
<Badge type={isExpired ? "error" : "warning"}>
<Tooltip
hideArrow
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
>
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
</Tooltip>
</Badge>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,25 @@
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import styles from './document.module.css'
import Trash from '@geist-ui/icons/trash'
import {
ChangeEvent,
memo,
useCallback,
useMemo,
useRef,
useState
} from "react"
import styles from "./document.module.css"
import Trash from "@geist-ui/icons/trash"
import FormattingIcons from "./formatting-icons"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import {
Button,
ButtonGroup,
Card,
Input,
Spacer,
Tabs,
Textarea,
Tooltip
} from "@geist-ui/core"
import Preview from "@components/preview"
// import Link from "next/link"
@ -20,25 +34,41 @@ type Props = {
onPaste?: (e: any) => void
}
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => {
const Document = ({
onPaste,
remove,
title,
content,
setTitle,
setContent,
initialTab = "edit",
handleOnContentChange
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%";
const height = "100%"
const handleTabChange = (newTab: string) => {
if (newTab === 'edit') {
if (newTab === "edit") {
codeEditorRef.current?.focus()
}
setTab(newTab as 'edit' | 'preview')
setTab(newTab as "edit" | "preview")
}
const onTitleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null, [setTitle])
const onTitleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) =>
setTitle ? setTitle(event.target.value) : null,
[setTitle]
)
const removeFile = useCallback((remove?: () => void) => {
const removeFile = useCallback(
(remove?: () => void) => {
if (remove) {
if (content && content.trim().length > 0) {
const confirmed = window.confirm("Are you sure you want to remove this file?")
const confirmed = window.confirm(
"Are you sure you want to remove this file?"
)
if (confirmed) {
remove()
}
@ -46,7 +76,9 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
remove()
}
}
}, [content])
},
[content]
)
// if (skeleton) {
// return <>
@ -80,14 +112,37 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
width={"100%"}
id={title}
/>
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
{remove && (
<Button
type="abort"
ghost
icon={<Trash />}
auto
height={"36px"}
width={"36px"}
onClick={() => removeFile(remove)}
/>
)}
</div>
<div className={styles.descriptionContainer}>
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
{tab === "edit" && (
<FormattingIcons setText={setContent} textareaRef={codeEditorRef} />
)}
<Tabs
onChange={handleTabChange}
initialValue={initialTab}
hideDivider
leftSpace={0}
>
<Tabs.Item label={"Edit"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<Textarea
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
@ -103,16 +158,15 @@ const Document = ({ onPaste, remove, title, content, setTitle, setContent, initi
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}>
<div style={{ marginTop: "var(--gap-half)" }}>
<Preview height={height} title={title} content={content} />
</div>
</Tabs.Item>
</Tabs>
</div >
</div >
</div>
</div>
</>
)
}
export default memo(Document)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'
import MoonIcon from '@geist-ui/icons/moon'
import SunIcon from '@geist-ui/icons/sun'
import React, { useEffect, useState } from "react"
import MoonIcon from "@geist-ui/icons/moon"
import SunIcon from "@geist-ui/icons/sun"
// import { useAllThemes, useTheme } from '@geist-ui/core'
import styles from './header.module.css'
import { Select } from '@geist-ui/core'
import { useTheme } from 'next-themes'
import styles from "./header.module.css"
import { Select } from "@geist-ui/core"
import { useTheme } from "next-themes"
const Controls = () => {
const [mounted, setMounted] = useState(false)
@ -12,10 +12,10 @@ const Controls = () => {
useEffect(() => setMounted(true), [])
if (!mounted) return null
const switchThemes = () => {
if (resolvedTheme === 'dark') {
setTheme('light')
if (resolvedTheme === "dark") {
setTheme("light")
} else {
setTheme('dark')
setTheme("dark")
}
}
@ -39,8 +39,8 @@ const Controls = () => {
</span>
</Select.Option>
</Select>
</div >
</div>
)
}
export default React.memo(Controls);
export default React.memo(Controls)

View file

@ -1,25 +1,31 @@
import {
ButtonGroup,
Button,
Page,
Spacer,
useBodyScroll,
useMediaQuery
} from "@geist-ui/core"
import { ButtonGroup, Button, Page, Spacer, useBodyScroll, useMediaQuery, } from "@geist-ui/core";
import { useCallback, useEffect, useMemo, useState } from "react"
import styles from "./header.module.css"
import useSignedIn from "../../lib/hooks/use-signed-in"
import { useCallback, useEffect, useMemo, useState } from "react";
import styles from './header.module.css';
import useSignedIn from "../../lib/hooks/use-signed-in";
import HomeIcon from '@geist-ui/icons/home';
import MenuIcon from '@geist-ui/icons/menu';
import GitHubIcon from '@geist-ui/icons/github';
import SignOutIcon from '@geist-ui/icons/userX';
import SignInIcon from '@geist-ui/icons/user';
import SignUpIcon from '@geist-ui/icons/userPlus';
import NewIcon from '@geist-ui/icons/plusCircle';
import YourIcon from '@geist-ui/icons/list'
import MoonIcon from '@geist-ui/icons/moon';
import SettingsIcon from '@geist-ui/icons/settings';
import SunIcon from '@geist-ui/icons/sun';
import HomeIcon from "@geist-ui/icons/home"
import MenuIcon from "@geist-ui/icons/menu"
import GitHubIcon from "@geist-ui/icons/github"
import SignOutIcon from "@geist-ui/icons/userX"
import SignInIcon from "@geist-ui/icons/user"
import SignUpIcon from "@geist-ui/icons/userPlus"
import NewIcon from "@geist-ui/icons/plusCircle"
import YourIcon from "@geist-ui/icons/list"
import MoonIcon from "@geist-ui/icons/moon"
import SettingsIcon from "@geist-ui/icons/settings"
import SunIcon from "@geist-ui/icons/sun"
import { useTheme } from "next-themes"
import useUserData from "@lib/hooks/use-user-data";
import Link from "next/link";
import { useRouter } from "next/router";
import useUserData from "@lib/hooks/use-user-data"
import Link from "next/link"
import { useRouter } from "next/router"
type Tab = {
name: string
@ -29,14 +35,13 @@ type Tab = {
href?: string
}
const Header = () => {
const router = useRouter()
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery('xs', { match: 'down' })
const isMobile = useMediaQuery("xs", { match: "down" })
const { signedIn: isSignedIn } = useSignedIn()
const userData = useUserData();
const userData = useUserData()
const [pages, setPages] = useState<Tab[]>([])
const { setTheme, resolvedTheme } = useTheme()
@ -61,27 +66,27 @@ const Header = () => {
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== 'undefined')
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
if (typeof window !== "undefined")
setTheme(resolvedTheme === "light" ? "dark" : "light")
},
icon: resolvedTheme === 'light' ? <MoonIcon /> : <SunIcon />,
value: "theme",
icon: resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
value: "theme"
}
]
if (isSignedIn)
setPages([
{
name: 'new',
name: "new",
icon: <NewIcon />,
value: 'new',
href: '/new'
value: "new",
href: "/new"
},
{
name: 'yours',
name: "yours",
icon: <YourIcon />,
value: 'yours',
href: '/mine'
value: "yours",
href: "/mine"
},
// {
// name: 'settings',
@ -90,32 +95,32 @@ const Header = () => {
// href: '/settings'
// },
{
name: 'sign out',
name: "sign out",
icon: <SignOutIcon />,
value: 'signout',
href: '/signout'
value: "signout",
href: "/signout"
},
...defaultPages
])
else
setPages([
{
name: 'home',
name: "home",
icon: <HomeIcon />,
value: 'home',
href: '/'
value: "home",
href: "/"
},
{
name: 'Sign in',
name: "Sign in",
icon: <SignInIcon />,
value: 'signin',
href: '/signin'
value: "signin",
href: "/signin"
},
{
name: 'Sign up',
name: "Sign up",
icon: <SignUpIcon />,
value: 'signup',
href: '/signup'
value: "signup",
href: "/signup"
},
...defaultPages
])
@ -123,10 +128,10 @@ const Header = () => {
setPages((pages) => [
...pages,
{
name: 'admin',
name: "admin",
icon: <SettingsIcon />,
value: 'admin',
href: '/admin'
value: "admin",
href: "/admin"
}
])
}
@ -134,18 +139,23 @@ const Header = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isSignedIn, resolvedTheme, userData])
const onTabChange = useCallback((tab: string) => {
if (typeof window === 'undefined') return
const match = pages.find(page => page.value === tab)
const onTabChange = useCallback(
(tab: string) => {
if (typeof window === "undefined") return
const match = pages.find((page) => page.value === tab)
if (match?.onClick) {
match.onClick()
}
}, [pages])
},
[pages]
)
const getButton = useCallback((tab: Tab) => {
const getButton = useCallback(
(tab: Tab) => {
const activeStyle = router.pathname === tab.href ? styles.active : ""
if (tab.onClick) {
return <Button
return (
<Button
auto={isMobile ? false : true}
key={tab.value}
icon={tab.icon}
@ -155,8 +165,10 @@ const Header = () => {
>
{tab.name ? tab.name : undefined}
</Button>
)
} else if (tab.href) {
return <Link key={tab.value} href={tab.href}>
return (
<Link key={tab.value} href={tab.href}>
<a className={styles.tab}>
<Button
className={activeStyle}
@ -168,17 +180,18 @@ const Header = () => {
</Button>
</a>
</Link>
)
}
}, [isMobile, onTabChange, router.pathname])
},
[isMobile, onTabChange, router.pathname]
)
const buttons = useMemo(() => pages.map(getButton), [pages, getButton])
return (
<Page.Header>
<div className={styles.tabs}>
<div className={styles.buttons}>
{buttons}
</div>
<div className={styles.buttons}>{buttons}</div>
</div>
<div className={styles.controls}>
<Button
@ -193,14 +206,19 @@ const Header = () => {
</Button>
</div>
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
{isMobile && expanded && (<div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
<ButtonGroup vertical style={{
background: "var(--bg)",
}}>
{isMobile && expanded && (
<div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
<ButtonGroup
vertical
style={{
background: "var(--bg)"
}}
>
{buttons}
</ButtonGroup>
</div>)}
</Page.Header >
</div>
)}
</Page.Header>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { ChangeEvent, memo, useEffect, useState } from 'react'
import { Text } from '@geist-ui/core'
import { ChangeEvent, memo, useEffect, useState } from "react"
import { Text } from "@geist-ui/core"
import ShiftBy from '@components/shift-by'
import styles from '../post.module.css'
import { Input } from '@geist-ui/core'
import ShiftBy from "@components/shift-by"
import styles from "../post.module.css"
import { Input } from "@geist-ui/core"
const titlePlaceholders = [
"How to...",
@ -12,7 +12,7 @@ const titlePlaceholders = [
"My new idea",
"Let's talk about...",
"What's up with ...",
"I'm thinking about ...",
"I'm thinking about ..."
]
type props = {
@ -24,10 +24,15 @@ const Title = ({ onChange, title }: props) => {
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
useEffect(() => {
// set random placeholder on load
setPlaceholder(titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)])
setPlaceholder(
titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]
)
}, [])
return (<div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
return (
<div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>
Drift
</Text>
<ShiftBy y={-3}>
<Input
placeholder={placeholder}
@ -39,7 +44,8 @@ const Title = ({ onChange, title }: props) => {
style={{ width: "100%" }}
/>
</ShiftBy>
</div>)
</div>
)
}
export default memo(Title)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
import PageSeo from "@components/page-seo"
import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from '@components/view-document'
import styles from './post-page.module.css'
import homeStyles from '@styles/Home.module.css'
import DocumentComponent from "@components/view-document"
import styles from "./post-page.module.css"
import homeStyles from "@styles/Home.module.css"
import type { File, Post } from "@lib/types"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react"
import Archive from '@geist-ui/icons/archive'
import Edit from '@geist-ui/icons/edit'
import Parent from '@geist-ui/icons/arrowUpCircle'
import Archive from "@geist-ui/icons/archive"
import Edit from "@geist-ui/icons/edit"
import Parent from "@geist-ui/icons/arrowUpCircle"
import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/router"
@ -26,10 +26,14 @@ const PostPage = ({ post }: Props) => {
const router = useRouter()
const isMobile = useMediaQuery("mobile")
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null)
const [isExpired, setIsExpired] = useState(
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
const isOwner = post.users
? post.users[0].id === Cookies.get("drift-userid")
: false
if (!isOwner && isExpired) {
router.push("/expired")
}
@ -41,7 +45,7 @@ const PostPage = ({ post }: Props) => {
setIsLoading(false)
}
let interval: NodeJS.Timer | null = null;
let interval: NodeJS.Timer | null = null
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
@ -53,17 +57,18 @@ const PostPage = ({ post }: Props) => {
}
}, [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) => {
const blob = await downloadZip(
post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
@ -90,22 +95,40 @@ const PostPage = ({ post }: Props) => {
<Page.Content className={homeStyles.main}>
<div className={styles.header}>
<span className={styles.buttons}>
<ButtonGroup vertical={isMobile} marginLeft={0} marginRight={0} marginTop={1} marginBottom={1}>
<ButtonGroup
vertical={isMobile}
marginLeft={0}
marginRight={0}
marginTop={1}
marginBottom={1}
>
<Button
auto
icon={<Edit />}
onClick={editACopy}
style={{ textTransform: 'none' }}>
style={{ textTransform: "none" }}
>
Edit a Copy
</Button>
{post.parent && <Button
{post.parent && (
<Button
auto
icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
onClick={() =>
router.push(
getPostPath(post.parent!.visibility, post.parent!.id)
)
}
>
View Parent
</Button>}
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}>
</Button>
)}
<Button
auto
onClick={download}
icon={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive
</Button>
<FileDropdown isMobile={isMobile} files={post.files || []} />
@ -119,23 +142,20 @@ const PostPage = ({ post }: Props) => {
<ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
</span>
</div>
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={'preview'}
initialTab={"preview"}
id={id}
content={content}
/>
))}
<ScrollToTop />
</Page.Content>
</Page >
</Page>
)
}

View file

@ -1,6 +1,6 @@
import Cookies from "js-cookie"
import { memo, useEffect, useState } from "react"
import styles from './preview.module.css'
import styles from "./preview.module.css"
type Props = {
height?: number | string
@ -17,7 +17,7 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
async function fetchPost() {
if (fileId) {
const resp = await fetch(`/api/html/${fileId}`, {
method: "GET",
method: "GET"
})
if (resp.ok) {
const res = await resp.text()
@ -29,12 +29,12 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token") || ""}`,
Authorization: `Bearer ${Cookies.get("drift-token") || ""}`
},
body: JSON.stringify({
title,
content,
}),
content
})
})
if (resp.ok) {
@ -47,12 +47,21 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
}
fetchPost()
}, [content, fileId, title])
return (<>
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
return (
<>
{isLoading ? (
<div>Loading...</div>
) : (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}} />}
</>)
}}
/>
)}
</>
)
}
export default memo(MarkdownPreview)

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
"dev": "next dev --port 3001",
"build": "next build",
"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",
"find:unused": "next-unused"
},

View file

@ -1,27 +1,47 @@
import '@styles/globals.css'
import type { AppProps as NextAppProps } from "next/app";
import "@styles/globals.css"
import type { AppProps as NextAppProps } from "next/app"
import 'react-loading-skeleton/dist/skeleton.css'
import Head from 'next/head';
import { ThemeProvider } from 'next-themes'
import App from '@components/app';
import "react-loading-skeleton/dist/skeleton.css"
import Head from "next/head"
import { ThemeProvider } from "next-themes"
import App from "@components/app"
type AppProps<P = any> = {
pageProps: P;
} & Omit<NextAppProps<P>, "pageProps">;
pageProps: P
} & Omit<NextAppProps<P>, "pageProps">
function MyApp({ Component, pageProps }: AppProps) {
return (
<div>
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/assets/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5" />
<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" />

View file

@ -1,5 +1,11 @@
import { CssBaseline } from '@geist-ui/core'
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
import { CssBaseline } from "@geist-ui/core"
import Document, {
Html,
Head,
Main,
NextScript,
DocumentContext
} from "next/document"
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
@ -18,13 +24,15 @@ class MyDocument extends Document {
}
render() {
return (<Html lang="en">
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>)
</Html>
)
}
}

View file

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

View file

@ -1,34 +1,33 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'
import { NextFetchEvent, NextRequest, NextResponse } from "next/server"
const PUBLIC_FILE = /\.(.*)$/
export function middleware(req: NextRequest, event: NextFetchEvent) {
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 isPageRequest =
!PUBLIC_FILE.test(pathname) &&
!pathname.startsWith('/api') &&
!pathname.startsWith("/api") &&
// 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 not signed in we redirect to the home page
if (signedIn) {
const resp = NextResponse.redirect(getURL(''));
resp.clearCookie('drift-token');
resp.clearCookie('drift-userid');
const resp = NextResponse.redirect(getURL(""))
resp.clearCookie("drift-token")
resp.clearCookie("drift-userid")
const signoutPromise = new Promise((resolve) => {
fetch(`${process.env.API_URL}/auth/signout`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${signedIn}`,
'x-secret-key': process.env.SECRET_KEY || '',
},
})
.then(() => {
"Content-Type": "application/json",
Authorization: `Bearer ${signedIn}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
}).then(() => {
resolve(true)
})
})
@ -38,15 +37,18 @@ export function middleware(req: NextRequest, event: NextFetchEvent) {
}
} else if (isPageRequest) {
if (signedIn) {
if (pathname === '/' || pathname === '/signin' || pathname === '/signup') {
return NextResponse.redirect(getURL('new'))
if (
pathname === "/" ||
pathname === "/signin" ||
pathname === "/signup"
) {
return NextResponse.redirect(getURL("new"))
}
} else if (!signedIn) {
if (pathname === '/new') {
return NextResponse.redirect(getURL('signin'))
if (pathname === "/new") {
return NextResponse.redirect(getURL("signin"))
}
}
}
return NextResponse.next()

View file

@ -1,21 +1,21 @@
import styles from '@styles/Home.module.css'
import styles from "@styles/Home.module.css"
import Header from '@components/header'
import { Page } from '@geist-ui/core';
import { useEffect } from 'react';
import Admin from '@components/admin';
import useSignedIn from '@lib/hooks/use-signed-in';
import { useRouter } from 'next/router';
import { GetServerSideProps } from 'next';
import cookie from "cookie";
import Header from "@components/header"
import { Page } from "@geist-ui/core"
import { useEffect } from "react"
import Admin from "@components/admin"
import useSignedIn from "@lib/hooks/use-signed-in"
import { useRouter } from "next/router"
import { GetServerSideProps } from "next"
import cookie from "cookie"
const AdminPage = () => {
const { signedIn } = useSignedIn()
const router = useRouter()
useEffect(() => {
if (typeof window === 'undefined') return
if (typeof window === "undefined") return
if (!signedIn) {
router.push('/')
router.push("/")
}
}, [router, signedIn])
return (
@ -28,11 +28,11 @@ const AdminPage = () => {
}
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`, {
headers: {
'Authorization': `Bearer ${driftToken}`,
'x-secret-key': process.env.SECRET_KEY || ''
Authorization: `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
})
@ -45,7 +45,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => {
} else {
return {
redirect: {
destination: '/',
destination: "/",
permanent: false
}
}

View file

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

View file

@ -1,24 +1,23 @@
import styles from '@styles/Home.module.css'
import PageSeo from '@components/page-seo'
import HomeComponent from '@components/home'
import { Page, Text } from '@geist-ui/core'
import { GetServerSideProps } from 'next'
import styles from "@styles/Home.module.css"
import PageSeo from "@components/page-seo"
import HomeComponent from "@components/home"
import { Page, Text } from "@geist-ui/core"
import { GetServerSideProps } from "next"
export const getServerSideProps: GetServerSideProps = async ({ res }) => {
try {
const resp = await fetch(process.env.API_URL + `/welcome`,
{
const resp = await fetch(process.env.API_URL + `/welcome`, {
method: "GET",
headers: {
"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()
res.setHeader(
'Cache-Control',
"Cache-Control",
`public, s-maxage=${60 * 60 * 24 * 360}, max-age=${60 * 60 * 24 * 360}`
)
@ -26,7 +25,7 @@ export const getServerSideProps: GetServerSideProps = async ({ res }) => {
props: {
introContent: content || null,
rendered: rendered || null,
introTitle: title || null,
introTitle: title || null
}
}
} catch (error) {
@ -51,7 +50,13 @@ const Home = ({ rendered, introContent, introTitle, error }: Props) => {
<PageSeo />
<Page.Content className={styles.main}>
{error && <Text>Something went wrong. Is the server running?</Text>}
{!error && <HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />}
{!error && (
<HomeComponent
rendered={rendered}
introContent={introContent}
introTitle={introTitle}
/>
)}
</Page.Content>
</Page>
)

View file

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

View file

@ -1,18 +1,18 @@
import styles from '@styles/Home.module.css'
import NewPost from '@components/new-post'
import Header from '@components/header'
import PageSeo from '@components/page-seo'
import { Page } from '@geist-ui/core'
import Head from 'next/head'
import { GetServerSideProps } from 'next'
import { Post } from '@lib/types'
import cookie from 'cookie'
import styles from "@styles/Home.module.css"
import NewPost from "@components/new-post"
import Header from "@components/header"
import PageSeo from "@components/page-seo"
import { Page } from "@geist-ui/core"
import Head from "next/head"
import { GetServerSideProps } from "next"
import { Post } from "@lib/types"
import cookie from "cookie"
const NewFromExisting = ({
post,
parentId
}: {
post: Post,
post: Post
parentId: string
}) => {
return (
@ -26,16 +26,19 @@ const NewFromExisting = ({
<Page.Content className={styles.main}>
<NewPost initialPost={post} newPostParent={parentId} />
</Page.Content>
</Page >
</Page>
)
}
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => {
export const getServerSideProps: GetServerSideProps = async ({
req,
params
}) => {
const id = params?.id
const redirect = {
redirect: {
destination: '/new',
permanent: false,
destination: "/new",
permanent: false
}
}
@ -43,14 +46,13 @@ export const getServerSideProps: GetServerSideProps = async ({ req, params }) =>
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",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
Authorization: `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
})

View file

@ -1,9 +1,9 @@
import styles from '@styles/Home.module.css'
import NewPost from '@components/new-post'
import Header from '@components/header'
import PageSeo from '@components/page-seo'
import { Page } from '@geist-ui/core'
import Head from 'next/head'
import styles from "@styles/Home.module.css"
import NewPost from "@components/new-post"
import Header from "@components/header"
import PageSeo from "@components/page-seo"
import { Page } from "@geist-ui/core"
import Head from "next/head"
const New = () => {
return (
@ -17,7 +17,7 @@ const New = () => {
<Page.Content className={styles.main}>
<NewPost />
</Page.Content>
</Page >
</Page>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { Page } from '@geist-ui/core';
import Auth from "@components/auth";
import PageSeo from '@components/page-seo';
import styles from '@styles/Home.module.css'
import { Page } from "@geist-ui/core"
import Auth from "@components/auth"
import PageSeo from "@components/page-seo"
import styles from "@styles/Home.module.css"
const SignUp = () => (
<Page width="100%">