rename client to src

This commit is contained in:
Max Leiter 2022-12-09 19:30:19 -08:00
parent aee2330e21
commit 70212232a0
177 changed files with 0 additions and 0 deletions

8
src/.dockerignore Normal file
View file

@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env.*

3
src/.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

35
src/.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# production env
.env
# vercel
.vercel
# typescript
*.tsbuildinfo

7
src/.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 80,
"useTabs": true
}

4
src/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/.pnpm/typescript@4.6.4/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

50
src/Dockerfile Normal file
View file

@ -0,0 +1,50 @@
FROM node:17-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
FROM node:17-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG API_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV API_URL=${API_URL:-http://localhost:3000}
RUN yarn build
FROM node:17-alpine AS runner
WORKDIR /app
ARG NODE_ENV
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=${NODE_ENV:-production}
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
ENV PORT=3001
EXPOSE 3001
CMD ["node", "server.js"]

34
src/README.md Normal file
View file

@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -0,0 +1,24 @@
.container {
padding: 2rem 2rem;
border-radius: var(--radius);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
}
.form {
display: grid;
place-items: center;
}
.formGroup {
display: flex;
flex-direction: column;
place-items: center;
gap: 10px;
max-width: 300px;
width: 100%;
}
.formContentSpace {
margin-bottom: 1rem;
text-align: center;
}

View file

@ -0,0 +1,112 @@
"use client"
import { useState } from "react"
import styles from "./auth.module.css"
import Link from "../../components/link"
import { signIn } from "next-auth/react"
import Input from "@components/input"
import Button from "@components/button"
import Note from "@components/note"
import { GitHub } from "react-feather"
const Auth = ({
page,
requiresServerPassword
}: {
page: "signup" | "signin"
requiresServerPassword?: boolean
}) => {
const [serverPassword, setServerPassword] = useState("")
const [errorMsg, setErrorMsg] = useState("")
const signingIn = page === "signin"
return (
<div className={styles.container}>
<div className={styles.form}>
<div className={styles.formContentSpace}>
<h1>{signingIn ? "Sign In" : "Sign Up"}</h1>
</div>
{/* <form onSubmit={handleSubmit}> */}
<form>
<div className={styles.formGroup}>
{/* <Input
htmlType="text"
id="username"
value={username}
onChange={(event) => setUsername(event.currentTarget.value)}
placeholder="Username"
required
minLength={3}
width="100%"
/>
<Input
htmlType="password"
id="password"
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
placeholder="Password"
required
minLength={6}
width="100%"
/> */}
{/* sign in with github */}
{requiresServerPassword && (
<Input
type="password"
id="server-password"
value={serverPassword}
onChange={(event) =>
setServerPassword(event.currentTarget.value)
}
placeholder="Server Password"
required
width="100%"
aria-label="Server Password"
/>
)}
<Button
type="submit"
buttonType="primary"
width="100%"
style={{
color: "var(--fg)"
}}
iconLeft={<GitHub />}
onClick={(e) => {
e.preventDefault()
signIn("github", {
callbackUrl: "/"
})
}}
>
Sign in with GitHub
</Button>
{/* <Button width={"100%"} htmlType="submit">
{signingIn ? "Sign In" : "Sign Up"}
</Button> */}
</div>
<div className={styles.formContentSpace}>
{signingIn ? (
<p>
Don&apos;t have an account?{" "}
<Link colored href="/signup">
Sign up
</Link>
</p>
) : (
<p>
Have an account?{" "}
<Link colored href="/signin">
Sign in
</Link>
</p>
)}
</div>
{errorMsg && <Note type="error">{errorMsg}</Note>}
</form>
</div>
</div>
)
}
export default Auth

View file

@ -0,0 +1,5 @@
import PageSeo from "@components/page-seo"
export default function AuthHead() {
return <PageSeo title="Sign In" />
}

View file

@ -0,0 +1,5 @@
import Auth from "../components"
export default function SignInPage() {
return <Auth page="signin" />
}

View file

@ -0,0 +1,5 @@
import PageSeo from "@components/page-seo"
export default function AuthHead() {
return <PageSeo title="Sign Up" />
}

View file

@ -0,0 +1,11 @@
import Auth from "../components"
import { getRequiresPasscode } from "pages/api/auth/requires-passcode"
const getPasscode = async () => {
return await getRequiresPasscode()
}
export default async function SignUpPage() {
const requiresPasscode = await getPasscode()
return <Auth page="signup" requiresServerPassword={requiresPasscode} />
}

View file

@ -0,0 +1,76 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.dropdownButton {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
}
.contentWrapper {
z-index: 1000;
}
.content {
list-style: none;
width: 100%;
padding: var(--gap-quarter) var(--gap);
margin: 0;
}
.content li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: 0 0;
}
.contenthover,
.content li:focus {
background-color: var(--lighter-gray);
}
.content .listItem {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--dark-gray);
text-decoration: none;
/* vertical alignment */
padding: var(--gap-quarter) 0;
}
.button {
border-radius: none !important;
}
.content li .fileIcon {
display: inline-block;
margin-right: var(--gap-half);
}
.content li .fileTitle {
/* from Geist */
font-size: calc(0.875 * 16px);
}
.content li::before {
content: "";
padding: 0;
margin: 0;
}
.chevron {
transition: transform 0.1s ease-in-out;
}
[data-state="open"] .chevron {
transform: rotate(180deg);
}

View file

@ -0,0 +1,85 @@
import { Popover } from "@components/popover"
import { codeFileExtensions } from "@lib/constants"
import clsx from "clsx"
import type { File } from "lib/server/prisma"
import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner"
type Item = File & {
icon: JSX.Element
}
const FileDropdown = ({
files,
loading
}: {
files: File[]
loading?: boolean
}) => {
if (loading) {
return (
<Popover>
<Popover.Trigger className={buttonStyles.button}>
<div
style={{ minWidth: 125 }}
>
<Spinner />
</div>
</Popover.Trigger>
</Popover>
)
}
const items = files.map((file) => {
const extension = file.title.split(".").pop()
if (codeFileExtensions.includes(extension || "")) {
return {
...file,
icon: <Code />
}
} else {
return {
...file,
icon: <FileIcon />
}
}
})
const content = (
<ul className={styles.content}>
{items.map((item) => (
<li key={item.id}>
<a href={`#${item.title}`} className={styles.listItem}>
<span className={styles.fileIcon}>{item.icon}</span>
<span className={styles.fileTitle}>
{item.title ? item.title : "Untitled"}
</span>
</a>
</li>
))}
</ul>
)
return (
<Popover>
<Popover.Trigger className={buttonStyles.button}>
<div
className={clsx(buttonStyles.icon, styles.chevron)}
style={{ marginRight: 6 }}
>
<ChevronDown />
</div>
<span>
Jump to {files.length} {files.length === 1 ? "file" : "files"}
</span>
</Popover.Trigger>
<Popover.Content className={styles.contentWrapper}>
{content}
</Popover.Content>
</Popover>
)
}
export default FileDropdown

View file

@ -0,0 +1,82 @@
import { memo, useEffect, useState } from "react"
import styles from "./preview.module.css"
import "@styles/markdown.css"
import "@styles/syntax.css"
import Skeleton from "@components/skeleton"
import { Spinner } from "@components/spinner"
type Props = {
height?: number | string
fileId?: string
content?: string
title?: string
}
const MarkdownPreview = ({
height = 500,
fileId,
content = "",
title
}: Props) => {
const [preview, setPreview] = useState<string>(content)
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
async function fetchPost() {
// POST to avoid query string length limit
const method = fileId ? "GET" : "POST"
const path = fileId ? `/api/file/html/${fileId}` : "/api/file/get-html"
const body = fileId
? undefined
: JSON.stringify({
title: title || "",
content: content
})
const resp = await fetch(path, {
method: method,
headers: {
"Content-Type": "application/json"
},
body
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
}
setIsLoading(false)
}
fetchPost()
}, [content, fileId, title])
return (
<>
{isLoading ? (
<Spinner />
) : (
<StaticPreview preview={preview} height={height} />
)}
</>
)
}
export default memo(MarkdownPreview)
export const StaticPreview = ({
preview,
height = 500
}: {
preview: string
height: string | number
}) => {
return (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}}
/>
)
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,122 @@
.markdownPreview pre {
border-radius: 3px;
font-family: "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.42857143;
margin: 0;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
margin-top: var(--gap);
margin-bottom: var(--gap-half);
}
.markdownPreview h1 {
color: var(--fg);
}
.markdownPreview h2 {
color: var(--darkest-gray);
}
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
color: var(--darker-gray);
}
.markdownPreview a {
color: #0070f3;
}
/* Auto-linked headers */
.markdownPreview h1 a,
.markdownPreview h2 a,
.markdownPreview h3 a,
.markdownPreview h4 a,
.markdownPreview h5 a,
.markdownPreview h6 a {
color: inherit;
}
/* Auto-linked headers */
.markdownPreview h1 a:hover::after,
.markdownPreview h2 a:hover::after,
.markdownPreview h3 a:hover::after,
.markdownPreview h4 a:hover::after,
.markdownPreview h5 a:hover::after,
.markdownPreview h6 a:hover::after {
content: "#";
font-size: 0.7em;
margin-left: 0.25em;
font-weight: normal;
filter: opacity(0.5);
}
.markdownPreview h1 {
font-size: 2rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1rem;
}
.markdownPreview h5 {
font-size: 1rem;
}
.markdownPreview h6 {
font-size: 0.875rem;
}
.markdownPreview ul {
list-style: inside;
}
.markdownPreview ul li::before {
content: "";
}
.markdownPreview code {
border-radius: 3px;
white-space: pre-wrap;
word-wrap: break-word;
color: inherit !important;
}
.markdownPreview code::before,
.markdownPreview code::after {
content: "";
}
@media screen and (max-width: 800px) {
.markdownPreview h1 a::after,
.markdownPreview h2 a::after,
.markdownPreview h3 a::after,
.markdownPreview h4 a::after,
.markdownPreview h5 a::after,
.markdownPreview h6 a::after {
content: "#";
font-size: 0.7em;
margin-left: 0.25em;
font-weight: normal;
filter: opacity(0.5);
}
}

View file

@ -0,0 +1,86 @@
"use client"
import * as RadixTabs from "@radix-ui/react-tabs"
import FormattingIcons from "app/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import { ChangeEvent, useRef } from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview"
import styles from "./tabs.module.css"
type Props = RadixTabs.TabsProps & {
isEditing: boolean
defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onPaste?: (e: any) => void
title?: string
content?: string
preview?: string
}
export default function DocumentTabs({
isEditing,
defaultTab,
handleOnContentChange,
onPaste,
title,
content,
preview,
...props
}: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
const handleTabChange = (newTab: string) => {
if (newTab === "preview") {
codeEditorRef.current?.focus()
}
}
return (
<RadixTabs.Root
{...props}
onValueChange={handleTabChange}
className={styles.root}
defaultValue={defaultTab}
>
<RadixTabs.List className={styles.list}>
<RadixTabs.Trigger value="edit" className={styles.trigger}>
{isEditing ? "Edit" : "Raw"}
</RadixTabs.Trigger>
<RadixTabs.Trigger value="preview" className={styles.trigger}>
{isEditing ? "Preview" : "Rendered"}
</RadixTabs.Trigger>
</RadixTabs.List>
<RadixTabs.Content value="edit">
{isEditing && <FormattingIcons textareaRef={codeEditorRef} />}
<div
style={{
marginTop: 6,
display: "flex",
flexDirection: "column"
}}
>
<TextareaMarkdown.Wrapper ref={codeEditorRef}>
<textarea
readOnly={!isEditing}
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
placeholder=""
value={content}
onChange={handleOnContentChange}
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
// className={styles.textarea}
/>
</TextareaMarkdown.Wrapper>
</div>
</RadixTabs.Content>
<RadixTabs.Content value="preview">
{isEditing ? (
<Preview height={"100%"} title={title} content={content} />
) : (
<StaticPreview height={"100%"} preview={preview || ""} />
)}
</RadixTabs.Content>
</RadixTabs.Root>
)
}

View file

@ -0,0 +1,43 @@
.root {
display: flex;
flex-direction: column;
}
.list {
flex-shrink: 0;
display: flex;
}
.trigger {
width: 80px;
height: 30px;
margin: 4px 0;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
user-select: none;
cursor: pointer;
transition: color 0.1s ease;
margin-bottom: var(--gap-half);
}
.trigger:hover {
background-color: var(--lighter-gray);
color: var(--fg);
}
.trigger:first-child {
border-top-left-radius: 4px;
}
.trigger:last-child {
border-top-right-radius: 4px;
}
.trigger[data-state="active"] {
color: var(--darkest-gray);
box-shadow: inset 0 -1px 0 0 currentColor, 0 1px 0 0 currentColor;
}

View file

@ -0,0 +1,11 @@
"use client"
import Note from "@components/note"
export default function ExpiredPage() {
return (
<Note type="error">
<strong>Error:</strong> The Drift you&apos;re trying to view has expired.
</Note>
)
}

View file

@ -0,0 +1,26 @@
import Input from "@components/input"
import { ChangeEvent, memo } from "react"
import styles from "../post.module.css"
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
description: string
}
const Description = ({ onChange, description }: props) => {
return (
<div className={styles.description}>
<Input
value={description || ""}
onChange={onChange}
label="Description"
maxLength={256}
width="100%"
placeholder="An optional description of your post"
/>
</div>
)
}
export default Description

View file

@ -0,0 +1,54 @@
.container {
display: flex;
flex-direction: column;
}
.container ul {
margin: 0;
margin-top: var(--gap-double);
}
.dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-radius: 2px;
border: 2px dashed var(--border) !important;
outline: none;
transition: all 0.14s ease-in-out;
cursor: pointer;
}
.dropzone:hover {
border-color: var(--gray) !important;
}
.dropzone:focus {
box-shadow: 0 0 4px 1px rgba(124, 124, 124, 0.5);
}
.error {
color: red;
font-size: 0.8rem;
transition: border 0.14s ease-in-out;
border: 2px solid red;
border-radius: 2px;
padding: 20px;
}
.error ul {
margin: 0;
padding-left: var(--gap-double);
}
.verb:after {
content: "click";
}
@media (hover: none) {
.verb:after {
content: "tap";
}
}

View file

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

View file

@ -0,0 +1,29 @@
.card {
padding: var(--gap);
border: 1px solid var(--lighter-gray);
border-radius: var(--radius);
}
.input {
background: #efefef;
}
.descriptionContainer {
display: flex;
flex-direction: column;
min-height: 400px;
overflow: auto;
}
.fileNameContainer {
display: flex;
}
.fileNameContainer > div {
/* Override geist-ui styling */
margin: 0 !important;
}
.textarea {
height: 100%;
}

View file

@ -0,0 +1,25 @@
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
top: -40px;
}
/* small screens, top: 0 */
@media (max-width: 767px) {
.actionWrapper .actions {
top: 0;
}
}
@media (max-width: 768px) {
.actionWrapper .actions {
position: relative;
margin-left: 0 !important;
}
}

View file

@ -0,0 +1,85 @@
import {
Bold,
Code,
Image as ImageIcon,
Italic,
Link,
List
} from "react-feather"
import { RefObject, useMemo } from "react"
import styles from "./formatting-icons.module.css"
import { TextareaMarkdownRef } from "textarea-markdown-editor"
import Tooltip from "@components/tooltip"
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
// TODO: clean up
const FormattingIcons = ({
textareaRef
}: {
textareaRef?: RefObject<TextareaMarkdownRef>
}) => {
const formattingActions = useMemo(() => {
const handleBoldClick = () => textareaRef?.current?.trigger("bold")
const handleItalicClick = () => textareaRef?.current?.trigger("italic")
const handleLinkClick = () => textareaRef?.current?.trigger("link")
const handleImageClick = () => textareaRef?.current?.trigger("image")
const handleCodeClick = () => textareaRef?.current?.trigger("code")
const handleListClick = () =>
textareaRef?.current?.trigger("unordered-list")
return [
{
icon: <Bold />,
name: "bold",
action: handleBoldClick
},
{
icon: <Italic />,
name: "italic",
action: handleItalicClick
},
{
icon: <Link />,
name: "hyperlink",
action: handleLinkClick
},
{
icon: <ImageIcon />,
name: "image",
action: handleImageClick
},
{
icon: <Code />,
name: "code",
action: handleCodeClick
},
{
icon: <List />,
name: "unordered-list",
action: handleListClick
}
]
}, [textareaRef])
return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => (
<Tooltip
content={name[0].toUpperCase() + name.slice(1).replace("-", " ")}
key={name}
>
<Button
aria-label={name}
iconRight={icon}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
/>
</Tooltip>
))}
</ButtonGroup>
</div>
)
}
export default FormattingIcons

View file

@ -0,0 +1,100 @@
import { ChangeEvent, useCallback } from "react"
import styles from "./document.module.css"
import Button from "@components/button"
import Input from "@components/input"
import DocumentTabs from "app/(posts)/components/tabs"
import { Trash } from "react-feather"
type Props = {
title?: string
content?: string
setTitle?: (title: string) => void
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
defaultTab?: "edit" | "preview"
remove?: () => void
onPaste?: (e: any) => void
}
const Document = ({
onPaste,
remove,
title,
content,
setTitle,
defaultTab = "edit",
handleOnContentChange
}: Props) => {
// const height = editable ? "500px" : '100%'
const height = "100%"
const onTitleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) =>
setTitle ? setTitle(event.target.value) : null,
[setTitle]
)
const removeFile = useCallback(
(remove?: () => void) => {
if (remove) {
if (content && content.trim().length > 0) {
const confirmed = window.confirm(
"Are you sure you want to remove this file?"
)
if (confirmed) {
remove()
}
} else {
remove()
}
}
},
[content]
)
return (
<>
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
value={title}
onChange={onTitleChange}
label="Filename"
width={"100%"}
id={title}
style={{
borderTopRightRadius: remove ? 0 : "var(--radius)",
borderBottomRightRadius: remove ? 0 : "var(--radius)"
}}
/>
{remove && (
<Button
iconLeft={<Trash />}
height={"39px"}
width={"48px"}
padding={0}
margin={0}
onClick={() => removeFile(remove)}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
/>
)}
</div>
<div className={styles.descriptionContainer}>
<DocumentTabs
isEditing={true}
defaultTab={defaultTab}
handleOnContentChange={handleOnContentChange}
onPaste={onPaste}
title={title}
content={content}
/>
</div>
</div>
</>
)
}
export default Document

View file

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

View file

@ -0,0 +1,340 @@
"use client"
import { useRouter } from "next/navigation"
import { useCallback, useState } from "react"
import generateUUID from "@lib/generate-uuid"
import styles from "./post.module.css"
import EditDocumentList from "./edit-document-list"
import { ChangeEvent } from "react"
import DatePicker from "react-datepicker"
import getTitleForPostCopy from "@lib/get-title-for-post-copy"
import Description from "./description"
import { PostWithFiles } from "@lib/server/prisma"
import PasswordModal from "../../../components/password-modal"
import Title from "./title"
import FileDropzone from "./drag-and-drop"
import Button from "@components/button"
import Input from "@components/input"
import ButtonDropdown from "@components/button-dropdown"
import { useToasts } from "@components/toasts"
const emptyDoc = {
title: "",
content: "",
id: generateUUID()
}
export type Document = {
title: string
content: string
id: string
}
const Post = ({
initialPost: stringifiedInitialPost,
newPostParent
}: {
initialPost?: string
newPostParent?: string
}) => {
const parsedPost = JSON.parse(stringifiedInitialPost || "{}")
const initialPost = parsedPost?.id ? parsedPost : null
const { setToast } = useToasts()
const router = useRouter()
const [title, setTitle] = useState(
getTitleForPostCopy(initialPost?.title) || ""
)
const [description, setDescription] = useState(initialPost?.description || "")
const [expiresAt, setExpiresAt] = useState<Date>()
const defaultDocs: Document[] = initialPost
? initialPost.files?.map((doc: PostWithFiles["files"][0]) => ({
title: doc.title,
content: doc.content,
id: doc.id
}))
: [emptyDoc]
const [docs, setDocs] = useState(defaultDocs)
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(
async (
url: string,
data: {
expiresAt: Date | null
visibility?: string
title?: string
files?: Document[]
password?: string
parentId?: string
}
) => {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
title,
description,
files: docs,
...data
})
})
if (res.ok) {
const json = await res.json()
router.push(`/post/${json.id}`)
} else {
const json = await res.json()
console.error(json)
setToast({
id: "error",
message: "Please fill out all fields",
type: "error"
})
setPasswordModalVisible(false)
setSubmitting(false)
}
},
[description, docs, router, setToast, title]
)
const [isSubmitting, setSubmitting] = useState(false)
const onSubmit = useCallback(
async (visibility: string, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false)
setSubmitting(true)
let hasErrored = false
if (!title) {
setToast({
message: "Please fill out the post title",
type: "error"
})
hasErrored = true
}
if (!docs.length) {
setToast({
message: "Please add at least one document",
type: "error"
})
hasErrored = true
}
for (const doc of docs) {
if (!doc.title) {
setToast({
message: "Please fill out all the document titles",
type: "error"
})
hasErrored = true
}
}
if (hasErrored) {
setSubmitting(false)
return
}
await sendRequest("/api/post", {
title,
files: docs,
visibility,
password,
expiresAt: expiresAt || null,
parentId: newPostParent
})
},
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
)
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = (password: string) => onSubmit("protected", password)
const onChangeExpiration = (date: Date) => setExpiresAt(date)
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setTitle(e.target.value)
}, [])
const onChangeDescription = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setDescription(e.target.value)
},
[]
)
const updateDocTitle = (i: number) => (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
}
const updateDocContent = (i: number) => (content: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
}
const removeDoc = (i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
}
const uploadDocs = (files: Document[]) => {
// if no title is set and the only document is empty,
const isFirstDocEmpty =
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle("Uploaded files")
}
}
if (isFirstDocEmpty) setDocs(files)
else setDocs((docs) => [...docs, ...files])
}
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData("text")
if (pastedText) {
if (!title) {
setTitle("Pasted text")
}
}
}
const CustomTimeInput = ({
date,
value,
onChange
}: {
date: Date
value: string
onChange: (date: string) => void
}) => (
<input
type="time"
value={value}
onChange={(e) => {
if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16))
}
}}
style={{
backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)"
}}
required
/>
)
return (
<div className={styles.root}>
<Title title={title} onChange={onChangeTitle} />
<Description description={description} onChange={onChangeDescription} />
<FileDropzone setDocs={uploadDocs} />
<EditDocumentList
onPaste={onPaste}
docs={docs}
updateDocTitle={updateDocTitle}
updateDocContent={updateDocContent}
removeDoc={removeDoc}
/>
<div className={styles.buttons}>
<Button
className={styles.button}
onClick={() => {
setDocs([
...docs,
{
title: "",
content: "",
id: generateUUID()
}
])
}}
type="default"
style={{
flex: 1
}}
>
Add a File
</Button>
<div className={styles.rightButtons}>
<DatePicker
onChange={onChangeExpiration}
customInput={
<Input label="Expires at" width="100%" height="40px" />
}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-ignore
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
dateFormat="MM/dd/yyyy h:mm aa"
className={styles.datePicker}
clearButtonTitle={"Clear"}
// TODO: investigate why this causes margin shift if true
enableTabLoop={false}
minDate={new Date()}
/>
<ButtonDropdown iconHeight={40}>
<Button
height={40}
width={251}
onClick={() => onSubmit("unlisted")}
loading={isSubmitting}
>
Create Unlisted
</Button>
<Button height={40} width={300} onClick={() => onSubmit("private")}>
Create Private
</Button>
<Button height={40} width={300} onClick={() => onSubmit("public")}>
Create Public
</Button>
<Button
height={40}
width={300}
onClick={() => onSubmit("protected")}
>
Create with Password
</Button>
</ButtonDropdown>
</div>
</div>
<PasswordModal
creating={true}
isOpen={passwordModalVisible}
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</div>
)
}
export default Post

View file

@ -0,0 +1,47 @@
.root {
padding-bottom: 200px;
display: flex;
flex-direction: column;
gap: var(--gap);
}
.buttons {
position: relative;
display: flex;
justify-content: space-between;
width: 100%;
margin-top: var(--gap-double);
gap: var(--gap);
}
.buttons .rightButtons {
display: flex;
gap: var(--gap);
align-items: center;
}
.datePicker {
flex: 1;
}
.description {
width: 100%;
}
@media screen and (max-width: 650px) {
.buttons {
flex-direction: column;
margin: 0;
justify-content: space-between;
min-height: 95px;
}
.buttons .rightButtons {
flex-direction: column;
align-items: flex-end;
}
.buttons .rightButtons > * {
width: min(100%, 350px);
}
}

View file

@ -0,0 +1,40 @@
import { ChangeEvent, memo } from "react"
import Input from "@components/input"
import styles from "./title.module.css"
const titlePlaceholders = [
"How to...",
"Status update for ...",
"My new project",
"My new idea",
"Let's talk about...",
"What's up with ...",
"I'm thinking about ..."
]
const placeholder = titlePlaceholders[3]
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string
}
const Title = ({ onChange, title }: props) => {
return (
<div className={styles.title}>
<h1 style={{ margin: 0, padding: 0 }}>Drift</h1>
<Input
placeholder={placeholder}
value={title}
onChange={onChange}
label="Title"
className={styles.labelAndInput}
style={{ width: "100%" }}
labelClassName={styles.labelAndInput}
/>
</div>
)
}
export default memo(Title)

View file

@ -0,0 +1,18 @@
.title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: inherit;
}
@media screen and (max-width: 650px) {
.title {
align-items: flex-start;
flex-direction: column;
}
}
.labelAndInput {
font-size: 1.2rem;
}

View file

@ -0,0 +1,34 @@
import NewPost from "../../components/new"
import { notFound, redirect } from "next/navigation"
import { getPostById } from "@lib/server/prisma"
import { getSession } from "@lib/server/session"
const NewFromExisting = async ({
params
}: {
params: {
id: string
}
}) => {
const session = await getSession()
if (!session?.user) {
return redirect("/signin")
}
const { id } = params
if (!id) {
return notFound()
}
const post = await getPostById(id, {
withFiles: true,
withAuthor: false
})
const serialized = JSON.stringify(post)
return <NewPost initialPost={serialized} newPostParent={id} />
}
export default NewFromExisting

View file

@ -0,0 +1,5 @@
import PageSeo from "@components/page-seo"
export default function NewPostHead() {
return <PageSeo title="New" />
}

View file

@ -0,0 +1,11 @@
import { getCurrentUser } from "@lib/server/session"
import { redirect } from "next/navigation"
export default function NewLayout({ children }: { children: React.ReactNode }) {
const user = getCurrentUser()
if (!user) {
return redirect("/new")
}
return <>{children}</>
}

View file

@ -0,0 +1,6 @@
import NewPost from "app/(posts)/new/components/new"
import "./react-datepicker.css"
const New = () => <NewPost />
export default New

View file

@ -0,0 +1,372 @@
.react-datepicker__year-read-view--down-arrow,
.react-datepicker__month-read-view--down-arrow,
.react-datepicker__month-year-read-view--down-arrow,
.react-datepicker__navigation-icon::before {
border-color: var(--light-gray);
border-style: solid;
border-width: 3px 3px 0 0;
content: "";
display: block;
height: 9px;
position: absolute;
top: 6px;
width: 9px;
}
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
margin-left: -4px;
position: absolute;
width: 0;
}
.react-datepicker-wrapper {
display: inline-block;
padding: 0;
border: 0;
}
.react-datepicker {
font-family: var(--font-sans);
font-size: 0.8rem;
background-color: var(--bg);
color: var(--fg);
border: 1px solid var(--gray);
border-radius: var(--radius);
display: inline-block;
position: relative;
}
.react-datepicker--time-only .react-datepicker__triangle {
left: 35px;
}
.react-datepicker--time-only .react-datepicker__time-container {
border-left: 0;
}
.react-datepicker--time-only .react-datepicker__time,
.react-datepicker--time-only .react-datepicker__time-box {
border-radius: var(--radius);
border-radius: var(--radius);
}
.react-datepicker__triangle {
position: absolute;
left: 50px;
}
.react-datepicker-popper {
z-index: 1;
}
.react-datepicker-popper[data-placement^="bottom"] {
padding-top: 10px;
}
.react-datepicker-popper[data-placement="bottom-end"]
.react-datepicker__triangle,
.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
left: auto;
right: 50px;
}
.react-datepicker-popper[data-placement^="top"] {
padding-bottom: 10px;
}
.react-datepicker-popper[data-placement^="right"] {
padding-left: 8px;
}
.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
left: auto;
right: 42px;
}
.react-datepicker-popper[data-placement^="left"] {
padding-right: 8px;
}
.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
left: 42px;
right: auto;
}
.react-datepicker__header {
text-align: center;
background-color: var(--bg);
border-bottom: 1px solid var(--gray);
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
padding: 8px 0;
position: relative;
}
.react-datepicker__header--time {
padding-bottom: 8px;
padding-left: 5px;
padding-right: 5px;
}
.react-datepicker__year-dropdown-container--select,
.react-datepicker__month-dropdown-container--select,
.react-datepicker__month-year-dropdown-container--select,
.react-datepicker__year-dropdown-container--scroll,
.react-datepicker__month-dropdown-container--scroll,
.react-datepicker__month-year-dropdown-container--scroll {
display: inline-block;
margin: 0 2px;
}
.react-datepicker__current-month,
.react-datepicker-time__header,
.react-datepicker-year-header {
margin-top: 0;
font-weight: bold;
font-size: 0.944rem;
}
.react-datepicker-time__header {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.react-datepicker__navigation {
align-items: center;
background: none;
display: flex;
justify-content: center;
text-align: center;
cursor: pointer;
position: absolute;
top: 2px;
padding: 0;
border: none;
z-index: 1;
height: 32px;
width: 32px;
text-indent: -999em;
overflow: hidden;
}
.react-datepicker__navigation--previous {
left: 2px;
}
.react-datepicker__navigation--next {
right: 2px;
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 85px;
}
.react-datepicker__navigation--years {
position: relative;
top: 0;
display: block;
margin-left: auto;
margin-right: auto;
}
.react-datepicker__navigation--years-previous {
top: 4px;
}
.react-datepicker__navigation--years-upcoming {
top: -4px;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--lighter-gray);
}
.react-datepicker__navigation-icon {
position: relative;
top: -1px;
font-size: 20px;
width: 0;
}
.react-datepicker__navigation-icon--next {
left: -2px;
}
.react-datepicker__navigation-icon--next::before {
transform: rotate(45deg);
left: -7px;
}
.react-datepicker__navigation-icon--previous {
right: -2px;
}
.react-datepicker__navigation-icon--previous::before {
transform: rotate(225deg);
right: -7px;
}
.react-datepicker__month-container {
float: left;
}
.react-datepicker__year {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__year-wrapper {
display: flex;
flex-wrap: wrap;
max-width: 180px;
}
.react-datepicker__year .react-datepicker__year-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__month {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__month .react-datepicker__month-text,
.react-datepicker__month .react-datepicker__quarter-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__input-time-container {
clear: both;
width: 100%;
float: left;
margin: 5px 0 10px 15px;
text-align: left;
}
.react-datepicker__input-time-container .react-datepicker-time__caption {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input {
display: inline-block;
margin-left: 10px;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input {
width: auto;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-inner-spin-button,
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"] {
-moz-appearance: textfield;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__delimiter {
margin-left: 5px;
display: inline-block;
}
.react-datepicker__day-names,
.react-datepicker__week {
white-space: nowrap;
}
.react-datepicker__day-names {
margin-bottom: -8px;
}
.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker__time-name {
color: var(--fg);
display: inline-block;
width: 1.7rem;
line-height: 1.7rem;
text-align: center;
margin: 0.166rem;
}
.react-datepicker__day,
.react-datepicker__month-text,
.react-datepicker__quarter-text,
.react-datepicker__year-text {
cursor: pointer;
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover,
.react-datepicker__quarter-text:hover,
.react-datepicker__year-text:hover {
border-radius: 0.3rem;
background-color: var(--light-gray);
}
.react-datepicker__day--today,
.react-datepicker__month-text--today,
.react-datepicker__quarter-text--today,
.react-datepicker__year-text--today {
font-weight: bold;
}
.react-datepicker__day--highlighted,
.react-datepicker__month-text--highlighted,
.react-datepicker__quarter-text--highlighted,
.react-datepicker__year-text--highlighted {
border-radius: 0.3rem;
background-color: #3dcc4a;
color: var(--fg);
}
.react-datepicker__day--highlighted:hover,
.react-datepicker__month-text--highlighted:hover,
.react-datepicker__quarter-text--highlighted:hover,
.react-datepicker__year-text--highlighted:hover {
background-color: #32be3f;
}
.react-datepicker__day--selected,
.react-datepicker__day--in-selecting-range,
.react-datepicker__day--in-range,
.react-datepicker__month-text--selected,
.react-datepicker__month-text--in-selecting-range,
.react-datepicker__month-text--in-range,
.react-datepicker__quarter-text--selected,
.react-datepicker__quarter-text--in-selecting-range,
.react-datepicker__quarter-text--in-range,
.react-datepicker__year-text--selected,
.react-datepicker__year-text--in-selecting-range,
.react-datepicker__year-text--in-range {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--selected:hover {
background-color: var(--gray);
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__quarter-text--keyboard-selected,
.react-datepicker__year-text--keyboard-selected {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--keyboard-selected:hover {
background-color: var(--gray);
}
.react-datepicker__month--selecting-range
.react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) {
background-color: var(--bg);
color: var(--fg);
}
.react-datepicker {
transform: scale(1.15) translateY(-12px);
}
.react-datepicker__day--disabled {
color: var(--darker-gray);
}
.react-datepicker__day--disabled:hover {
background-color: transparent;
cursor: not-allowed;
}

View file

@ -0,0 +1,78 @@
"use client"
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import FileDropdown from "app/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css"
import { File } from "@prisma/client"
import { useRouter } from "next/navigation"
export const PostButtons = ({
title,
files,
loading,
postId,
parentId
}: {
title: string
files?: File[]
loading?: boolean
postId?: string
parentId?: string
}) => {
const router = useRouter()
const downloadClick = async () => {
if (!files?.length) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(
files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${title}.zip`
link.click()
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${postId}`)
}
const viewParentClick = () => {
router.push(`/post/${parentId}`)
}
return (
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button
iconLeft={<Edit />}
onClick={editACopy}
style={{ textTransform: "none" }}
>
Edit a Copy
</Button>
{viewParentClick && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button
onClick={downloadClick}
iconLeft={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive
</Button>
<FileDropdown loading={loading} files={files || []} />
</ButtonGroup>
</span>
)
}

View file

@ -0,0 +1,12 @@
.buttons {
display: flex;
justify-content: flex-end;
margin-bottom: var(--gap);
}
@media screen and (max-width: 768px) {
.buttons {
display: flex;
justify-content: center;
}
}

View file

@ -0,0 +1,54 @@
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import ExpirationBadge from "@components/badges/expiration-badge"
import VisibilityBadge from "@components/badges/visibility-badge"
import Link from "@components/link"
import Skeleton from "@components/skeleton"
import styles from "./title.module.css"
type TitleProps = {
title: string
loading?: boolean
displayName?: string
visibility?: string
createdAt?: string
expiresAt?: string
authorId?: string
}
export const PostTitle = ({
title,
displayName,
visibility,
createdAt,
expiresAt,
loading,
authorId
}: TitleProps) => {
return (
<span className={styles.title}>
<h1 style={{
fontSize: "1.175rem"
}}>
{title}{" "}
<span style={{ color: "var(--gray)" }}>
by{" "}
<Link href={`/author/${authorId}`}>{displayName || "anonymous"}</Link>
</span>
</h1>
{!loading && (
<span className={styles.badges}>
{visibility && <VisibilityBadge visibility={visibility} />}
{createdAt && <CreatedAgoBadge createdAt={createdAt} />}
{expiresAt && <ExpirationBadge postExpirationDate={expiresAt} />}
</span>
)}
{loading && (
<span className={styles.badges}>
<Skeleton width={100} height={20} />
<Skeleton width={100} height={20} />
<Skeleton width={100} height={20} />
</span>
)}
</span>
)
}

View file

@ -0,0 +1,35 @@
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title .badges {
display: flex;
gap: var(--gap-half);
flex-wrap: wrap;
}
.title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
@media screen and (max-width: 768px) {
.title {
flex-direction: column;
gap: var(--gap-half);
}
.title .badges {
align-items: center;
justify-content: center;
}
.buttons {
display: flex;
justify-content: center;
}
}

View file

@ -0,0 +1,76 @@
"use client"
import DocumentComponent from "./view-document"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import PasswordModalPage from "./password-modal-wrapper"
import { File, PostWithFilesAndAuthor } from "@lib/server/prisma"
type Props = {
post: string | PostWithFilesAndAuthor
isProtected?: boolean
isAuthor?: boolean
}
const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
const [post, setPost] = useState<PostWithFilesAndAuthor>(
typeof initialPost === "string" ? JSON.parse(initialPost) : initialPost
)
const router = useRouter()
useEffect(() => {
if (post.expiresAt) {
if (new Date(post.expiresAt) < new Date()) {
if (!isAuthor) {
router.push("/expired")
}
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (!isAuthor && expirationDate < new Date()) {
router.push("/expired")
}
let interval: NodeJS.Timer | null = null
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(
post.expiresAt ? post.expiresAt : ""
)
if (expirationDate < new Date()) {
if (!isAuthor) {
router.push("/expired")
}
clearInterval(interval!)
}
}, 4000)
}
return () => {
if (interval) clearInterval(interval)
}
}
}
}, [isAuthor, post.expiresAt, router])
if (isProtected) {
return <PasswordModalPage setPost={setPost} postId={post.id} />
}
return (
<>
{post.files?.map(({ id, content, title, html }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={"preview"}
id={id}
content={content}
preview={html}
/>
))}
</>
)
}
export default PostPage

View file

@ -0,0 +1,64 @@
"use client"
import { Post, PostWithFilesAndAuthor } from "@lib/server/prisma"
import PasswordModal from "@components/password-modal"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useToasts } from "@components/toasts"
type Props = {
setPost: (post: PostWithFilesAndAuthor) => void
postId: Post["id"]
}
const PasswordModalPage = ({ setPost, postId }: Props) => {
const router = useRouter()
const { setToast } = useToasts()
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true)
const onSubmit = async (password: string) => {
const res = await fetch(`/api/post/${postId}?password=${password}`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
if (!res.ok) {
setToast({
type: "error",
message: "Wrong password"
})
return
}
const data = await res.json()
if (data) {
if (data.error) {
setToast({
message: data.error,
type: "error"
})
} else {
setIsPasswordModalOpen(false)
setPost(data)
}
}
}
const onClose = () => {
setIsPasswordModalOpen(false)
router.push("/")
}
return (
<PasswordModal
creating={false}
onClose={onClose}
onSubmit={onSubmit}
isOpen={isPasswordModalOpen}
/>
)
}
export default PasswordModalPage

View file

@ -0,0 +1,49 @@
.card {
padding: var(--gap);
border: 1px solid var(--light-gray);
border-radius: var(--radius);
}
.descriptionContainer {
display: flex;
flex-direction: column;
min-height: 400px;
overflow: auto;
}
.fileNameContainer {
font-family: var(--font-mono) !important;
border-radius: var(--radius) !important;
margin-bottom: var(--gap-half) !important;
width: 100% !important;
}
.fileNameContainer span {
transition: background-color var(--transition) !important;
border-color: var(--light-gray) !important;
}
.fileNameContainer span:target,
.fileNameContainer span:hover {
background-color: var(--lighter-gray) !important;
}
.fileNameContainer > div {
/* Override geist-ui styling */
margin: 0 !important;
}
.actionWrapper {
position: relative;
z-index: 1;
padding-top: 4px;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}
.textarea {
height: 100%;
}

View file

@ -0,0 +1,110 @@
import { memo } from "react"
import styles from "./document.module.css"
import Skeleton from "@components/skeleton"
import Link from "next/link"
import Tooltip from "@components/tooltip"
import Button from "@components/button"
import ButtonGroup from "@components/button-group"
import DocumentTabs from "app/(posts)/components/tabs"
import Input from "@components/input"
import { Download, ExternalLink } from "react-feather"
// import Link from "next/link"
type Props = {
title: string
initialTab?: "edit" | "preview"
skeleton?: boolean
id: string
content: string
preview: string
}
const DownloadButtons = ({ rawLink }: { rawLink?: string }) => {
return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
<Tooltip content="Download">
<Link
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button
iconRight={<Download />}
aria-label="Download"
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
/>
</Link>
</Tooltip>
<Tooltip content="Open raw in new tab">
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<ExternalLink />}
aria-label="Open raw file in new tab"
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
/>
</Link>
</Tooltip>
</ButtonGroup>
</div>
)
}
const Document = ({
content,
preview,
title,
initialTab = "edit",
skeleton,
id
}: Props) => {
if (skeleton) {
return (
<>
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
</div>
<div className={styles.descriptionContainer}>
<div style={{ flexDirection: "row", display: "flex" }}>
<Skeleton width={125} height={36} />
</div>
<Skeleton width={"100%"} height={350} />
</div>
</div>
</>
)
}
return (
<>
<div className={styles.card}>
<Link href={`#${title}`} className={styles.fileNameContainer}>
<Input
id={`${title}`}
width={"100%"}
height={"2rem"}
style={{ borderRadius: 0 }}
value={title || "Untitled"}
onChange={() => {}}
disabled
aria-label="Document title"
/>
</Link>
<div className={styles.descriptionContainer}>
{/* Not /api/ because of rewrites defined in next.config.mjs */}
<DownloadButtons rawLink={`/file/raw/${id}`} />
<DocumentTabs
defaultTab={initialTab}
preview={preview}
content={content}
isEditing={false}
/>
</div>
</div>
</>
)
}
export default memo(Document)

View file

@ -0,0 +1,24 @@
import PageSeo from "@components/page-seo"
import { getPostById } from "@lib/server/prisma"
export default async function Head({
params
}: {
params: {
id: string
}
}) {
const post = await getPostById(params.id)
if (!post) {
return null
}
return (
<PageSeo
title={post.title}
description={post.description || undefined}
isPrivate={false}
/>
)
}

View file

@ -0,0 +1,14 @@
import { PostButtons } from "./components/header/post-buttons"
import { PostTitle } from "./components/header/title"
import styles from "./styles.module.css"
export default function PostLoading() {
return (
<>
<div className={styles.header}>
<PostButtons loading title="" />
<PostTitle title="" loading />
</div>
</>
)
}

View file

@ -0,0 +1,140 @@
import PostPage from "./components/post-page"
import { notFound, redirect } from "next/navigation"
import { getPostById, Post, PostWithFilesAndAuthor } from "@lib/server/prisma"
import { getCurrentUser } from "@lib/server/session"
import ScrollToTop from "@components/scroll-to-top"
import { title } from "process"
import { PostButtons } from "./components/header/post-buttons"
import styles from "./styles.module.css"
import { PostTitle } from "./components/header/title"
import VisibilityControl from "@components/badges/visibility-control"
export type PostProps = {
post: Post
isProtected?: boolean
}
// export async function generateStaticParams() {
// const posts = await getAllPosts({
// where: {
// visibility: {
// equals: "public"
// }
// }
// })
// return posts.map((post) => ({
// id: post.id
// }))
// }
const fetchOptions = {
withFiles: true,
withAuthor: true
}
const getPost = async (id: string) => {
const post = (await getPostById(id, fetchOptions)) as PostWithFilesAndAuthor
if (!post) {
return notFound()
}
const user = await getCurrentUser()
const isAuthorOrAdmin = user?.id === post?.authorId || user?.role === "admin"
if (post.visibility === "public") {
return { post, isAuthor: isAuthorOrAdmin }
}
if (post.visibility === "private" && !isAuthorOrAdmin) {
return notFound()
}
if (post.visibility === "private" && !isAuthorOrAdmin) {
return notFound()
}
if (post.visibility === "protected" && !isAuthorOrAdmin) {
return {
// TODO: remove this. It's temporary to appease typescript
post: {
visibility: "protected",
id: post.id,
files: [],
parentId: "",
title: "",
createdAt: new Date("1970-01-01"),
expiresAt: new Date("1970-01-01"),
author: {
displayName: ""
},
description: "",
authorId: ""
},
isProtected: true,
isAuthor: isAuthorOrAdmin
}
}
// if expired
if (post.expiresAt && !isAuthorOrAdmin) {
const expirationDate = new Date(post.expiresAt)
if (expirationDate < new Date()) {
return redirect("/expired")
}
}
return { post, isAuthor: isAuthorOrAdmin }
}
const PostView = async ({
params
}: {
params: {
id: string
}
}) => {
const { post, isProtected, isAuthor } = await getPost(params.id)
// TODO: serialize dates in prisma middleware instead of passing as JSON
const stringifiedPost = JSON.stringify(post)
return (
<div className={styles.root}>
<div className={styles.header}>
<PostButtons
parentId={post.parentId || undefined}
postId={post.id}
files={post.files}
title={title}
/>
<PostTitle
title={post.title}
createdAt={post.createdAt.toString()}
expiresAt={post.expiresAt?.toString()}
displayName={post.author?.displayName || ""}
visibility={post.visibility}
authorId={post.authorId}
/>
</div>
{post.description && (
<div>
<p>{post.description}</p>
</div>
)}
<PostPage
isAuthor={isAuthor}
isProtected={isProtected}
post={stringifiedPost}
/>
{isAuthor && (
<span className={styles.controls}>
<VisibilityControl postId={post.id} visibility={post.visibility} />
</span>
)}
<ScrollToTop />
</div>
)
}
export default PostView

View file

@ -0,0 +1,24 @@
.root {
padding-bottom: 200px;
display: flex;
flex-direction: column;
gap: var(--gap);
}
@media screen and (max-width: 900px) {
.header {
flex-direction: column;
gap: var(--gap);
}
}
.controls {
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 768px) {
.controls {
justify-content: center;
}
}

View file

@ -0,0 +1,9 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
}
.table thead th {
font-weight: bold;
}

17
src/app/admin/layout.tsx Normal file
View file

@ -0,0 +1,17 @@
import { getCurrentUser } from "@lib/server/session"
import { redirect } from "next/navigation"
export default async function AdminLayout({
children
}: {
children: React.ReactNode
}) {
const user = await getCurrentUser()
const isAdmin = user?.role === "admin"
if (!isAdmin) {
return redirect("/")
}
return children
}

13
src/app/admin/loading.tsx Normal file
View file

@ -0,0 +1,13 @@
import { PostTable, UserTable } from "./page"
export default function AdminLoading() {
return (
<div>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable />
<h2>Posts</h2>
<PostTable />
</div>
)
}

100
src/app/admin/page.tsx Normal file
View file

@ -0,0 +1,100 @@
import { getAllPosts, getAllUsers } from "@lib/server/prisma"
import { Spinner } from "@components/spinner"
import styles from "./admin.module.css"
export function UserTable({
users
}: {
users?: Awaited<ReturnType<typeof getAllUsers>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>User ID</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.name ? user.name : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id}>{user.id}</td>
</tr>
))}
{!users && (
<tr>
<td colSpan={4}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export function PostTable({
posts
}: {
posts?: Awaited<ReturnType<typeof getAllPosts>>
}) {
return (
<table className={styles.table}>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
</tr>
</thead>
<tbody>
{posts?.map((post) => (
<tr key={post.id}>
<td>
<a href={`/post/${post.id}`} target="_blank" rel="noreferrer">
{post.title}
</a>
</td>
<td>{"author" in post ? post.author.name : "no author"}</td>
<td>{post.createdAt.toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
</tr>
))}
{!posts && (
<tr>
<td colSpan={5}>
<Spinner />
</td>
</tr>
)}
</tbody>
</table>
)
}
export default async function AdminPage() {
const usersPromise = getAllUsers()
const postsPromise = getAllPosts({
withAuthor: true
})
const [users, posts] = await Promise.all([usersPromise, postsPromise])
return (
<div className={styles.wrapper}>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable users={users} />
<h2>Posts</h2>
<PostTable posts={posts} />
</div>
)
}

View file

@ -0,0 +1,44 @@
import PostList from "@components/post-list"
import { getPostsByUser, getUserById } from "@lib/server/prisma"
import { Suspense } from "react"
async function PostListWrapper({
posts,
userId
}: {
posts: ReturnType<typeof getPostsByUser>
userId: string
}) {
const data = (await posts).filter((post) => post.visibility === "public")
return (
<PostList
morePosts={false}
userId={userId}
initialPosts={JSON.stringify(data)}
/>
)
}
export default async function UserPage({
params
}: {
params: {
username: string
}
}) {
// TODO: the route should be user.name, not id
const id = params.username
const user = await getUserById(id)
const posts = getPostsByUser(id, true)
return (
<>
<h1>Public posts by {user?.displayName || "Anonymous"}</h1>
<Suspense fallback={<PostList initialPosts={JSON.stringify({})} />}>
{/* @ts-ignore because TS async JSX support is iffy */}
<PostListWrapper posts={posts} userId={id} />
</Suspense>
</>
)
}

View file

@ -0,0 +1,46 @@
.container {
display: inline-block;
}
.badge {
display: inline-block;
padding: .25em .5em;
border-radius: var(--radius);
background-color: var(--light-gray);
font-size: .85em;
line-height: 1;
}
.badgeText {
font-size: var(--font-size-1);
font-weight: var(--font-weight-bold);
line-height: .5;
}
.primary {
background-color: var(--fg);
color: var(--bg);
}
.primary::selection {
background-color: var(--bg);
color: var(--fg);
}
.secondary {
background-color: var(--light-gray);
}
.warning {
background-color: var(--warning);
color: var(--fg);
}
[data-theme="light"] .error,
[data-theme="light"] .warning {
color: var(--bg);
}
.error {
background-color: red;
}

View file

@ -0,0 +1,17 @@
import styles from "./badge.module.css"
type BadgeProps = {
type: "primary" | "secondary" | "error" | "warning"
children: React.ReactNode
}
const Badge = ({ type, children }: BadgeProps) => {
return (
<div className={styles.container}>
<div className={`${styles.badge} ${styles[type]}`}>
<span className={styles.badgeText}>{children}</span>
</div>
</div>
)
}
export default Badge

View file

@ -0,0 +1,30 @@
"use client"
import Tooltip from "@components/tooltip"
import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"
import Badge from "../badge"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate))
}, 1000)
return () => clearInterval(interval)
}, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (
// TODO: investigate tooltip not showing
<Tooltip content={formattedTime}>
<Badge type="secondary">
{" "}
<>{time}</>
</Badge>
</Tooltip>
)
}
export default CreatedAgoBadge

View file

@ -0,0 +1,58 @@
"use client"
import Tooltip from "@components/tooltip"
import { timeUntil } from "@lib/time-ago"
import { useEffect, useMemo, useState } from "react"
import Badge from "../badge"
const ExpirationBadge = ({
postExpirationDate
}: {
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
)
useEffect(() => {
let interval: NodeJS.Timer | null = null
if (expirationDate) {
interval = setInterval(() => {
if (expirationDate) {
setTimeUntil(timeUntil(expirationDate))
}
}, 1000)
}
return () => {
if (interval) {
clearInterval(interval)
}
}
}, [expirationDate])
const isExpired = useMemo(() => {
return timeUntilString === "in 0 seconds"
}, [timeUntilString])
if (!expirationDate) {
return null
}
return (
<Badge type={isExpired ? "error" : "warning"}>
<Tooltip
content={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
>
<>{isExpired ? "Expired" : `Expires ${timeUntilString}`}</>
</Tooltip>
</Badge>
)
}
export default ExpirationBadge

View file

@ -0,0 +1,11 @@
import Badge from "../badge"
type Props = {
visibility: string
}
const VisibilityBadge = ({ visibility }: Props) => {
return <Badge type={"primary"}>{visibility}</Badge>
}
export default VisibilityBadge

View file

@ -0,0 +1,110 @@
"use client"
import PasswordModal from "@components/password-modal"
import { useCallback, useState } from "react"
import ButtonGroup from "@components/button-group"
import Button from "@components/button"
import { useToasts } from "@components/toasts"
import { Spinner } from "@components/spinner"
type Props = {
postId: string
visibility: string
}
const VisibilityControl = ({ postId, visibility: postVisibility }: Props) => {
const [visibility, setVisibility] = useState<string>(postVisibility)
const [isSubmitting, setSubmitting] = useState<string | null>()
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const { setToast } = useToasts()
const sendRequest = useCallback(
async (visibility: string, password?: string) => {
const res = await fetch(`/api/post/${postId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ visibility, password })
})
if (res.ok) {
const json = await res.json()
setVisibility(json.visibility)
} else {
setToast({
message: "An error occurred",
type: "error"
})
setPasswordModalVisible(false)
}
},
[postId, setToast, setVisibility]
)
const onSubmit = useCallback(
async (visibility: string, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false)
const timeout = setTimeout(() => setSubmitting(visibility), 100)
await sendRequest(visibility, password)
clearTimeout(timeout)
setSubmitting(null)
},
[sendRequest]
)
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(null)
}
const submitPassword = (password: string) => onSubmit("protected", password)
return (
<>
<ButtonGroup verticalIfMobile>
<Button
disabled={visibility === "private"}
onClick={() => onSubmit("private")}
>
{isSubmitting === "private" ? <Spinner /> : "Make Private"}
</Button>
<Button
disabled={visibility === "public"}
onClick={() => onSubmit("public")}
>
{isSubmitting === "public" ? <Spinner /> : "Make Public"}
</Button>
<Button
disabled={visibility === "unlisted"}
onClick={() => onSubmit("unlisted")}
>
{isSubmitting === "unlisted" ? <Spinner /> : "Make Unlisted"}
</Button>
<Button onClick={() => onSubmit("protected")}>
{isSubmitting === "protected" ? (
<Spinner />
) : visibility === "protected" ? (
"Change Password"
) : (
"Protect with Password"
)}
</Button>
</ButtonGroup>
<PasswordModal
creating={true}
isOpen={passwordModalVisible}
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</>
)
}
export default VisibilityControl

View file

@ -0,0 +1,27 @@
.dropdown {
display: flex;
align-items: stretch;
}
.dropdown > button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dropdown > button:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
/* if we draw bother borders it will be 2 thick */
border-left: 0;
}
.dropdown > button:last-child:hover {
/* because we removed the left border we need to mock it */
outline: 1px solid var(--darker-gray);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,54 @@
import Button from "@components/button"
import React from "react"
import styles from "./dropdown.module.css"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { ArrowDown } from "react-feather"
type Props = {
type?: "primary" | "secondary"
loading?: boolean
disabled?: boolean
className?: string
iconHeight?: number
}
type Attrs = Omit<React.HTMLAttributes<any>, keyof Props>
type ButtonDropdownProps = Props & Attrs
const ButtonDropdown: React.FC<
React.PropsWithChildren<ButtonDropdownProps>
> = ({ type, className, disabled, loading, iconHeight = 24, ...props }) => {
if (!Array.isArray(props.children)) {
return null
}
return (
<DropdownMenu.Root>
<div className={styles.dropdown}>
{props.children[0]}
<DropdownMenu.Trigger
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-end"
}}
asChild
>
<Button
iconLeft={<ArrowDown />}
type={type}
className={styles.icon}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end">
{props.children.slice(1).map((child, index) => (
<DropdownMenu.Item key={index}>{child}</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
)
}
export default ButtonDropdown

View file

@ -0,0 +1,45 @@
.button-group {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
}
.button-group > * {
flex: 1 1 auto;
margin: 0;
}
.button-group > * {
border-radius: 0 !important;
}
.button-group > button:first-of-type {
border-top-left-radius: var(--radius) !important;
border-bottom-left-radius: var(--radius) !important;
}
.button-group > button:last-of-type {
border-top-right-radius: var(--radius) !important;
border-bottom-right-radius: var(--radius) !important;
}
@media screen and (max-width: 768px) {
.verticalIfMobile {
flex-direction: column !important;
}
.verticalIfMobile.button-group > button:first-of-type {
border-top-left-radius: var(--radius) !important;
border-top-right-radius: var(--radius) !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.verticalIfMobile.button-group > button:last-of-type {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-bottom-left-radius: var(--radius) !important;
border-bottom-right-radius: var(--radius) !important;
}
}

View file

@ -0,0 +1,23 @@
import styles from "./button-group.module.css"
import clsx from "clsx"
export default function ButtonGroup({
children,
verticalIfMobile,
...props
}: {
children: React.ReactNode | React.ReactNode[]
verticalIfMobile?: boolean
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx(
styles["button-group"],
verticalIfMobile && styles.verticalIfMobile,
props.className
)}
{...props}
>
{children}
</div>
)
}

View file

@ -0,0 +1,58 @@
.button {
user-select: none;
cursor: pointer;
border-radius: var(--radius);
border: 1px solid var(--border);
padding: var(--gap-half) var(--gap);
color: var(--darker-gray);
}
.button:hover,
.button:focus {
color: var(--fg);
background: var(--bg);
border: 1px solid var(--darker-gray);
}
.button[disabled] {
cursor: not-allowed;
background: var(--lighter-gray);
color: var(--gray);
}
.button[disabled]:hover,
.button[disabled]:focus {
border: 1px solid currentColor;
}
.secondary {
background: var(--bg);
color: var(--fg);
}
.primary {
background: var(--fg);
color: var(--bg);
}
.icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: middle;
}
.iconRight {
margin-left: var(--gap-half);
}
.iconLeft {
margin-right: var(--gap-half);
}
.icon svg {
display: block;
width: 100%;
height: 100%;
transform: scale(1.2) translateY(-0.05em);
}

View file

@ -0,0 +1,80 @@
import styles from "./button.module.css"
import { forwardRef, Ref } from "react"
import clsx from "clsx"
import { Spinner } from "@components/spinner"
type Props = React.HTMLProps<HTMLButtonElement> & {
children?: React.ReactNode
buttonType?: "primary" | "secondary"
className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
iconRight?: React.ReactNode
iconLeft?: React.ReactNode
height?: string | number
width?: string | number
padding?: string | number
margin?: string | number
loading?: boolean
}
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
(
{
children,
onClick,
className,
buttonType = "primary",
type = "button",
disabled = false,
iconRight,
iconLeft,
height,
width,
padding,
margin,
loading,
...props
},
ref
) => {
return (
<button
ref={ref}
className={`${styles.button} ${styles[type]} ${className || ""}`}
disabled={disabled || loading}
onClick={onClick}
style={{ height, width, margin, padding }}
{...props}
>
{children && iconLeft && (
<span className={clsx(styles.icon, styles.iconLeft)}>{iconLeft}</span>
)}
{!loading &&
(children ? (
children
) : (
<span className={styles.icon}>{iconLeft || iconRight}</span>
))}
{loading && (
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<Spinner />
</span>
)}
{children && iconRight && (
<span className={clsx(styles.icon, styles.iconRight)}>
{iconRight}
</span>
)}
</button>
)
}
)
export default Button

View file

@ -0,0 +1,15 @@
.card {
background: var(--bg);
border-radius: var(--radius);
color: var(--fg);
border: 1px solid var(--light-gray);
width: auto;
height: auto;
}
.card .content {
padding: var(--gap);
width: 100%;
height: auto;
}

View file

@ -0,0 +1,16 @@
import styles from "./card.module.css"
export default function Card({
children,
className,
...props
}: {
children: React.ReactNode
className?: string
} & React.ComponentProps<"div">) {
return (
<div className={`${styles.card} ${className || ""}`} {...props}>
<div className={styles.content}>{children}</div>
</div>
)
}

View file

@ -0,0 +1,15 @@
@media (prefers-reduced-motion: no-preference) {
.fadeIn {
animation-name: fadeInAnimation;
animation-fill-mode: backwards;
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

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

View file

@ -0,0 +1,111 @@
.tabs {
justify-content: center;
display: flex;
margin: var(--gap) 0;
height: 40px;
}
.tabs .buttons {
display: flex;
justify-content: center;
align-items: center;
}
.tabs .buttons button {
border: none;
border-radius: 0;
cursor: pointer;
background: var(--bg);
}
.tabs button,
.tabs a {
color: var(--darker-gray);
transition: color, box-shadow 0.2s ease-in-out;
}
.tabs .buttons a:hover,
.tabs .buttons button:hover {
color: var(--fg);
box-shadow: inset 0 -1px 0 var(--fg);
}
.wrapper {
display: flex;
align-items: center;
width: min-content;
}
.header {
transition: opacity 0.2s ease-in-out;
opacity: 0;
}
.header:not(.loading) {
opacity: 1;
}
.selectContent {
width: auto;
height: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.active a,
.active button {
color: var(--fg);
}
.buttonGroup,
.mobile {
display: none;
}
.contentWrapper {
background: var(--bg);
}
@media only screen and (max-width: 768px) {
.wrapper [data-tab="github"] {
display: none;
}
.mobile {
margin-top: var(--gap);
margin-bottom: var(--gap);
display: flex;
}
.buttonGroup {
display: flex;
flex-direction: column;
}
.dropdownItem:not(:first-child):not(:last-child) :global(button) {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dropdownItem:first-child :global(button) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dropdownItem:last-child :global(button) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dropdownItem a,
.dropdownItem button {
width: 100%;
}
.tabs {
display: none;
}
}

View file

@ -0,0 +1,201 @@
"use client"
import styles from "./header.module.css"
// import useUserData from "@lib/hooks/use-user-data"
import Link from "@components/link"
import { usePathname } from "next/navigation"
import { signOut, useSession } from "next-auth/react"
import Button from "@components/button"
import clsx from "clsx"
import { useTheme } from "@wits/next-themes"
import {
GitHub,
Home,
Menu,
Moon,
PlusCircle,
Settings,
Sun,
User,
UserPlus,
UserX
} from "react-feather"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import buttonStyles from "@components/button/button.module.css"
import { useMemo } from "react"
type Tab = {
name: string
icon: JSX.Element
value: string
onClick?: () => void
href?: string
}
const Header = () => {
const session = useSession()
const isSignedIn = session?.status === "authenticated"
const isAdmin = session?.data?.user?.role === "admin"
const isLoading = session?.status === "loading"
const pathname = usePathname()
const { setTheme, resolvedTheme } = useTheme()
const getButton = (tab: Tab) => {
const isActive = pathname === tab.href
const activeStyle = isActive ? styles.active : ""
if (tab.onClick) {
return (
<Button
key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick}
className={clsx(styles.tab, activeStyle)}
aria-label={tab.name}
aria-current={isActive ? "page" : undefined}
data-tab={tab.value}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else if (tab.href) {
return (
<Link
key={tab.value}
href={tab.href}
className={clsx(styles.tab, activeStyle)}
data-tab={tab.value}
>
<Button iconLeft={tab.icon}>{tab.name ? tab.name : undefined}</Button>
</Link>
)
}
}
const pages = useMemo(() => {
const defaultPages: Tab[] = [
{
name: "GitHub",
href: "https://github.com/maxleiter/drift",
icon: <GitHub />,
value: "github"
}
]
if (isAdmin) {
defaultPages.push({
name: "Admin",
icon: <Settings />,
value: "admin",
href: "/admin"
})
}
defaultPages.push({
name: "Theme",
onClick: function () {
setTheme(resolvedTheme === "light" ? "dark" : "light")
},
icon: resolvedTheme === "light" ? <Moon /> : <Sun />,
value: "theme"
})
if (isSignedIn)
return [
{
name: "New",
icon: <PlusCircle />,
value: "new",
href: "/new"
},
{
name: "Yours",
icon: <User />,
value: "yours",
href: "/mine"
},
{
name: "Settings",
icon: <Settings />,
value: "settings",
href: "/settings"
},
...defaultPages,
{
name: "Sign Out",
icon: <UserX />,
value: "signout",
onClick: () =>
signOut({
callbackUrl: "/"
})
}
]
else
return [
{
name: "Home",
icon: <Home />,
value: "home",
href: "/"
},
...defaultPages,
{
name: "Sign in",
icon: <User />,
value: "signin",
href: "/signin"
},
{
name: "Sign up",
icon: <UserPlus />,
value: "signup",
href: "/signup"
}
]
}, [isAdmin, resolvedTheme, isSignedIn, setTheme])
const buttons = pages.map(getButton)
// TODO: this is a hack to close the radix ui menu when a next link is clicked
const onClick = () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
}
return (
<header
className={clsx(styles.header, {
[styles.loading]: isLoading
})}
>
<div className={styles.tabs}>
<div className={styles.buttons}>{buttons}</div>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger
className={clsx(buttonStyles.button, styles.mobile)}
asChild
>
<Button aria-label="Menu">
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className={styles.contentWrapper}>
{buttons.map((button) => (
<DropdownMenu.Item
key={button?.key}
className={styles.dropdownItem}
onClick={onClick}
>
{button}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</header>
)
}
export default Header

View file

@ -0,0 +1,76 @@
import clsx from "clsx"
import React from "react"
import styles from "./input.module.css"
type Props = React.HTMLProps<HTMLInputElement> & {
label?: string
width?: number | string
height?: number | string
labelClassName?: string
}
// we have two special rules on top of the props:
// if onChange or value is passed, we require both, unless `disabled`
// if label is passed, we forbid aria-label and vice versa
type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
(
| {
onChange: Props["onChange"]
value: Props["value"]
} // if onChange or value is passed, we require both
| {
onChange?: never
value?: never
}
| {
value: Props["value"]
disabled: true
onChange?: never
}
) &
(
| {
label: Props["label"]
"aria-label"?: never
} // if label is passed, we forbid aria-label and vice versa
| {
label?: never
"aria-label": Props["aria-label"]
}
)
// eslint-disable-next-line react/display-name
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, className, width, height, labelClassName, ...props }, ref) => {
return (
<div
className={styles.wrapper}
style={{
width,
height
}}
>
{label && (
<label
aria-labelledby={label}
className={clsx(styles.label, labelClassName)}
>
{label}
</label>
)}
<input
ref={ref}
id={label}
className={clsx(styles.input, label && styles.withLabel, className)}
{...props}
style={{
width,
height,
...(props.style || {})
}}
/>
</div>
)
}
)
export default Input

View file

@ -0,0 +1,58 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
/* font-size: 1rem; */
height: 2.5rem;
}
.input {
height: 2.5rem;
border-radius: var(--inline-radius);
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
padding: 0 var(--gap-half);
outline: none;
transition: border-color var(--transition);
display: flex;
justify-content: center;
margin: 0;
width: 100%;
}
.withLabel {
/* if with label, then left border should be flat */
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input:focus {
border-color: var(--light-gray);
}
.label {
display: inline-flex;
width: initial;
height: 100%;
align-items: center;
pointer-events: none;
margin: 0;
padding: 0 var(--gap-half);
color: var(--darker-gray);
background-color: var(--lighter-gray);
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
border-top: 1px solid var(--input-border);
border-left: 1px solid var(--input-border);
border-bottom: 1px solid var(--input-border);
line-height: 1;
white-space: nowrap;
}
.input:disabled {
background-color: var(--lighter-gray);
color: var(--fg);
cursor: not-allowed;
}

View file

@ -0,0 +1,18 @@
import NextLink from "next/link"
import styles from "./link.module.css"
type LinkProps = {
colored?: boolean
children: React.ReactNode
} & React.ComponentProps<typeof NextLink>
const Link = ({ colored, children, ...props }: LinkProps) => {
const className = colored ? `${styles.link} ${styles.color}` : styles.link
return (
<NextLink {...props} className={className}>
{children}
</NextLink>
)
}
export default Link

View file

@ -0,0 +1,12 @@
.link {
text-decoration: none;
color: var(--fg);
}
.color {
color: var(--link);
}
.color:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,18 @@
import clsx from "clsx"
import styles from "./note.module.css"
const Note = ({
type = "info",
children,
className,
...props
}: {
type: "info" | "warning" | "error"
children: React.ReactNode
} & React.ComponentProps<"div">) => (
<div className={clsx(className, styles.note, styles[type])} {...props}>
{children}
</div>
)
export default Note

View file

@ -0,0 +1,31 @@
.note {
color: var(--fg);
margin: 0;
padding: var(--gap);
border-radius: var(--radius);
}
.info {
background: var(--gray);
}
.warning {
background: var(--warning);
color: var(--bg);
}
.error {
background: #f33;
}
[data-theme="light"] .warning,
[data-theme="light"] .error {
color: var(--fg);
}
.type {
color: var(--fg);
margin-right: 0.5em;
font-size: initial;
text-transform: capitalize;
}

View file

@ -0,0 +1,89 @@
import config from "@lib/config"
import React from "react"
type PageSeoProps = {
title?: string
description?: string
isLoading?: boolean
isPrivate?: boolean
}
const PageSeo = ({
title: pageTitle,
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
}: PageSeoProps) => {
const title = `Drift${pageTitle ? ` - ${pageTitle}` : ""}`
return (
<>
<title>{title}</title>
<meta charSet="utf-8" />
{!isPrivate && <meta name="description" content={description} />}
{isPrivate && <meta name="robots" content="noindex" />}
{/* TODO: verify the correct meta tags */}
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<ThemeAndIcons />
<URLs />
</>
)
}
export default PageSeo
const ThemeAndIcons = () => (
<>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/assets/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link
rel="mask-icon"
href="/assets/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="apple-mobile-web-app-title" content="Drift" />
<meta name="application-name" content="Drift" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta
name="theme-color"
content="#ffffff"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#000"
media="(prefers-color-scheme: dark)"
/>
</>
)
const URLs = () => (
<>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={config.url} />
{/* TODO: OG image */}
<meta property="twitter:image" content={`${config.url}/assets/og.png`} />
<meta property="twitter:site" content="@" />
<meta property="twitter:creator" content="@drift" />
<meta property="og:type" content="website" />
<meta property="og:url" content={config.url} />
</>
)

View file

@ -0,0 +1,5 @@
import styles from "./page.module.css"
export default function Page({ children }: { children: React.ReactNode }) {
return <div className={styles.page}>{children}</div>
}

View file

@ -0,0 +1,10 @@
.page {
max-width: var(--main-content);
min-height: 100vh;
box-sizing: border-box;
position: relative;
width: 100%;
height: auto;
padding: 0 calc(1.34 * 16px) 0 calc(1.34 * 16px);
margin: 0 auto;
}

View file

@ -0,0 +1,99 @@
import Button from "@components/button"
import Input from "@components/input"
import Note from "@components/note"
import * as Dialog from "@radix-ui/react-dialog"
import { useState } from "react"
import styles from "./modal.module.css"
type Props = {
creating: boolean
isOpen: boolean
onClose: () => void
onSubmit: (password: string) => void
}
const PasswordModal = ({
isOpen,
onClose,
onSubmit: onSubmitAfterVerify,
creating
}: Props) => {
const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>()
const [error, setError] = useState<string>()
const onSubmit = () => {
if (!password || (creating && !confirmPassword)) {
setError("Please enter a password")
return
}
if (password !== confirmPassword && creating) {
setError("Passwords do not match")
return
}
onSubmitAfterVerify(password)
}
return (
<>
{
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content
className={styles.content}
onEscapeKeyDown={onClose}
>
<Dialog.Title>
{creating ? "Add a password" : "Enter password"}
</Dialog.Title>
<Dialog.Description>
{creating
? "Enter a password to protect your post"
: "Enter the password to access the post"}
</Dialog.Description>
<fieldset className={styles.fieldset}>
{error && <Note type="error">{error}</Note>}
<Input
width={"100%"}
label="Password"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
{creating && (
<Input
width={"100%"}
label="Confirm"
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
)}
{!error && creating && (
<Note type="warning">
This doesn&apos;t protect your post from the server
administrator.
</Note>
)}
</fieldset>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onSubmit}>Submit</Button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
}
</>
)
}
export default PasswordModal

View file

@ -0,0 +1,69 @@
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
background: var(--gray-alpha);
z-index: 1;
}
.content {
background-color: var(--bg);
border-radius: var(--radius);
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px,
hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 450px;
max-height: 85vh;
padding: 25px;
animation: contentShow 100ms cubic-bezier(0.16, 1, 0.3, 1);
z-index: 2;
border: 1px solid var(--border);
}
.fieldset {
border: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--gap);
margin-bottom: var(--gap-half);
}
.content:focus {
outline: none;
}
.close {
display: flex;
justify-content: flex-end;
width: 100%;
gap: var(--gap-half);
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View file

@ -0,0 +1,35 @@
// largely from https://github.com/shadcn/taxonomy
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import clsx from "clsx"
import styles from "./popover.module.css"
type PopoverProps = PopoverPrimitive.PopoverProps
export function Popover({ ...props }: PopoverProps) {
return <PopoverPrimitive.Root {...props} />
}
Popover.Trigger = React.forwardRef<
HTMLButtonElement,
PopoverPrimitive.PopoverTriggerProps
>(function PopoverTrigger({ ...props }, ref) {
return <PopoverPrimitive.Trigger {...props} ref={ref} />
})
Popover.Portal = PopoverPrimitive.Portal
Popover.Content = React.forwardRef<
HTMLDivElement,
PopoverPrimitive.PopoverContentProps
>(function PopoverContent({ className, ...props }, ref) {
return (
<PopoverPrimitive.Content
ref={ref}
align="end"
className={clsx(styles.root, className)}
{...props}
/>
)
})

View file

@ -0,0 +1,25 @@
.root {
overflow: hidden;
border-radius: 0.375rem;
border: 1px solid var(--border);
background-color: var(--bg);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
animation: slide-in-from-top 0.1s cubic-bezier(0.4, 0, 1, 1) 0.1s;
animation-fill-mode: both;
width: 100%;
}
@keyframes slide-in-from-top {
0% {
transform: translateY(-10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}

View file

@ -0,0 +1,163 @@
"use client"
import styles from "./post-list.module.css"
import ListItem from "./list-item"
import {
ChangeEvent,
useCallback,
useState
} from "react"
import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma"
import Input from "@components/input"
import Button from "@components/button"
import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton"
import debounce from "lodash.debounce"
type Props = {
initialPosts: string | PostWithFiles[]
morePosts?: boolean
userId?: string
hideSearch?: boolean
}
const PostList = ({
morePosts,
initialPosts: initialPostsMaybeJSON,
userId,
hideSearch
}: Props) => {
const initialPosts =
typeof initialPostsMaybeJSON === "string"
? JSON.parse(initialPostsMaybeJSON)
: initialPostsMaybeJSON
const [search, setSearchValue] = useState("")
const [posts, setPosts] = useState<PostWithFiles[]>(initialPosts)
const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
const { setToast } = useToasts()
const loadMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (hasMorePosts) {
async function fetchPosts() {
const res = await fetch(`/server-api/posts/mine`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-page": `${posts.length / 10 + 1}`
}
})
const json = await res.json()
setPosts([...posts, ...json.posts])
setHasMorePosts(json.morePosts)
}
fetchPosts()
}
},
[posts, hasMorePosts]
)
const onSearch = (query: string) => {
setSearching(true)
async function fetchPosts() {
const res = await fetch(
`/api/post/search?q=${encodeURIComponent(query)}&userId=${userId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
}
}
)
const json = await res.json()
setPosts(json)
setSearching(false)
}
fetchPosts()
}
const debouncedSearch = debounce(onSearch, 500)
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
debouncedSearch(e.target.value)
}
const deletePost = useCallback(
(postId: string) => async () => {
const res = await fetch(`/api/post/${postId}`, {
method: "DELETE"
})
if (!res.ok) {
console.error(res)
return
} else {
setPosts((posts) => posts.filter((post) => post.id !== postId))
setToast({
message: "Post deleted",
type: "success"
})
}
},
[setToast]
)
return (
<div className={styles.container}>
{!hideSearch && <div className={styles.searchContainer}>
<Input
placeholder="Search..."
onChange={handleSearchChange}
disabled={!posts}
style={{ maxWidth: 300 }}
aria-label="Search"
value={search}
/>
</div>}
{!posts && <p style={{ color: "var(--warning)" }}>Failed to load.</p>}
{searching && (
<ul>
<ListItemSkeleton />
<ListItemSkeleton />
</ul>
)}
{posts?.length === 0 && posts && (
<p>
No posts found. Create one{" "}
<Link colored href="/new">
here
</Link>
.
</p>
)}
{posts?.length > 0 && (
<div>
<ul>
{posts.map((post) => {
return (
<ListItem
deletePost={deletePost(post.id)}
post={post}
key={post.id}
/>
)
})}
</ul>
</div>
)}
{hasMorePosts && !setSearchValue && (
<div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>
)}
</div>
)
}
export default PostList

View file

@ -0,0 +1,22 @@
import styles from "./list-item.module.css"
import Card from "@components/card"
import Skeleton from "@components/skeleton"
export const ListItemSkeleton = () => (
<li>
<Card style={{ overflowY: "scroll" }}>
<>
<div className={styles.title}>
{/* title */}
<Skeleton width={80} height={32} />
</div>
<div className={styles.badges}>
<Skeleton width={30} height={32} />
</div>
</>
<hr />
<Skeleton width={100} height={32} />
</Card>
</li>
)

View file

@ -0,0 +1,36 @@
.title {
display: flex;
justify-content: space-between;
}
.badges {
display: flex;
gap: var(--gap-half);
flex-wrap: wrap;
}
.buttons {
display: flex;
gap: var(--gap-half);
}
.oneline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: var(--gap-quarter) 0;
}
@media screen and (max-width: 700px) {
.badges {
margin-top: var(--gap-half);
}
.title {
gap: var(--gap);
}
.title h3 {
margin: 0;
}
}

View file

@ -0,0 +1,113 @@
import VisibilityBadge from "../badges/visibility-badge"
import FadeIn from "@components/fade-in"
import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import { useRouter } from "next/navigation"
import styles from "./list-item.module.css"
import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma"
import type { File } from "@lib/server/prisma"
import Tooltip from "@components/tooltip"
import Badge from "@components/badges/badge"
import Card from "@components/card"
import Button from "@components/button"
import { ArrowUpCircle, Edit, Trash } from "react-feather"
// TODO: isOwner should default to false so this can be used generically
const ListItem = ({
post,
isOwner = true,
deletePost
}: {
post: PostWithFiles
isOwner?: boolean
deletePost: () => void
}) => {
const router = useRouter()
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
const viewParentClick = () => {
router.push(`/post/${post.parentId}`)
}
return (
<FadeIn>
<li key={post.id}>
<Card style={{ overflowY: "scroll" }}>
<>
<div className={styles.title}>
<h3 style={{ display: "inline-block", margin: 0 }}>
<Link
colored
style={{ marginRight: "var(--gap)" }}
href={`/post/${post.id}`}
>
{post.title}
</Link>
</h3>
{isOwner && (
<span className={styles.buttons}>
{post.parentId && (
<Tooltip content={"View parent"}>
<Button
iconRight={<ArrowUpCircle />}
onClick={viewParentClick}
height={38}
/>
</Tooltip>
)}
<Tooltip content={"Make a copy"}>
<Button
iconRight={<Edit />}
onClick={editACopy}
height={38}
/>
</Tooltip>
<Tooltip content={"Delete"}>
<Button
iconRight={<Trash />}
onClick={deletePost}
height={38}
/>
</Tooltip>
</span>
)}
</div>
{post.description && (
<p className={styles.oneline}>{post.description}</p>
)}
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<Badge type="secondary">
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</>
<hr />
<>
{post?.files?.map((file: File) => {
return (
<div key={file.id}>
<Link colored href={`/post/${post.id}#${file.title}`}>
{file.title || "Untitled file"}
</Link>
</div>
)
})}
</>
</Card>
</li>
</FadeIn>
)
}
export default ListItem

View file

@ -0,0 +1,35 @@
.container ul {
list-style: none;
padding: 0;
margin: 0;
}
.container ul li {
padding: 0.5rem 0;
}
.container ul li::before {
content: "";
padding: 0;
margin: 0;
}
.postHeader {
display: flex;
justify-content: space-between;
padding: var(--gap);
align-items: center;
position: sticky;
top: 0;
z-index: 1;
background: inherit;
}
.searchContainer {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
gap: var(--gap-half);
margin-bottom: var(--gap);
}

View file

@ -0,0 +1,48 @@
"use client"
import Button from "@components/button"
import Tooltip from "@components/tooltip"
import { useEffect, useState } from "react"
import { ChevronUp } from "react-feather"
import styles from "./scroll.module.css"
const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false)
useEffect(() => {
// if user is scrolled, set visible
const handleScroll = () => {
setShouldShow(window.scrollY > 100)
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const 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" })
}
return (
<div className={styles.root}>
<Tooltip
content="Scroll to Top"
className={`${styles["scroll-up"]} ${
shouldShow ? styles["scroll-up-shown"] : ""
}`}
>
<Button
aria-label="Scroll to Top"
onClick={onClick}
iconLeft={<ChevronUp />}
/>
</Tooltip>
</div>
)
}
export default ScrollToTop

View file

@ -0,0 +1,25 @@
.root {
display: flex;
flex-direction: row;
width: 100%;
height: 24px;
justify-content: flex-end;
}
.scroll-up {
position: fixed;
z-index: 2;
pointer-events: none;
opacity: 0;
transform: translateY(16px);
transition: transform 0.2s, opacity 0.2s;
cursor: pointer;
bottom: var(--gap-double);
will-change: transform, opacity;
}
.scroll-up-shown {
opacity: 0.8;
transform: none;
pointer-events: auto;
}

View file

@ -0,0 +1,20 @@
"use client"
import Card from "@components/card"
import styles from "./settings-group.module.css"
type Props = {
title: string
children: React.ReactNode | React.ReactNode[]
}
const SettingsGroup = ({ title, children }: Props) => {
return (
<Card>
<h4>{title}</h4>
<hr />
<div className={styles.content}>{children}</div>
</Card>
)
}
export default SettingsGroup

View file

@ -0,0 +1,4 @@
.content form label {
display: block;
margin-bottom: 5px;
}

View file

@ -0,0 +1,11 @@
import styles from "./skeleton.module.css"
export default function Skeleton({
width = 100,
height = 24
}: {
width?: number | string
height?: number | string
}) {
return <div className={styles.skeleton} style={{ width, height }} />
}

View file

@ -0,0 +1,4 @@
.skeleton {
background-color: var(--lighter-gray);
border-radius: var(--radius);
}

View file

@ -0,0 +1,3 @@
import styles from "./spinner.module.css"
export const Spinner = () => <div className={styles.spinner} />

View file

@ -0,0 +1,17 @@
.spinner {
border: 4px solid var(--light-gray);
border-top: 4px solid var(--gray);
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,69 @@
"use client"
import Toast, { Toaster } from "react-hot-toast"
export type ToastType = "success" | "error" | "loading" | "default"
export type ToastProps = {
id?: string
type: ToastType
message: string
duration?: number
icon?: string
style?: React.CSSProperties
className?: string
loading?: boolean
loadingProgress?: number
}
export const useToasts = () => {
const setToast = (toast: ToastProps) => {
const { type, message, ...rest } = toast
if (toast.id) {
Toast.dismiss(toast.id)
}
switch (type) {
case "success":
Toast.success(message, rest)
break
case "error":
Toast.error(message, rest)
break
case "loading":
Toast.loading(message, rest)
break
default:
Toast(message, rest)
break
}
}
return { setToast }
}
export const Toasts = () => {
return (
<Toaster
position="bottom-right"
toastOptions={{
error: {
style: {
background: "var(--warning)",
color: "#fff"
}
},
success: {
style: {
background: "var(--light-gray)",
color: "var(--fg)"
}
},
iconTheme: {
primary: "var(--fg)",
secondary: "var(--bg)"
}
}}
/>
)
}

Some files were not shown because too many files have changed in this diff Show more