rename client to src
This commit is contained in:
parent
aee2330e21
commit
70212232a0
177 changed files with 0 additions and 0 deletions
8
src/.dockerignore
Normal file
8
src/.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env.*
|
3
src/.eslintrc.json
Normal file
3
src/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
35
src/.gitignore
vendored
Normal file
35
src/.gitignore
vendored
Normal 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
7
src/.prettierrc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"useTabs": true
|
||||
}
|
4
src/.vscode/settings.json
vendored
Normal file
4
src/.vscode/settings.json
vendored
Normal 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
50
src/Dockerfile
Normal 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
34
src/README.md
Normal 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.
|
24
src/app/(auth)/components/auth.module.css
Normal file
24
src/app/(auth)/components/auth.module.css
Normal 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;
|
||||
}
|
112
src/app/(auth)/components/index.tsx
Normal file
112
src/app/(auth)/components/index.tsx
Normal 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'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
|
5
src/app/(auth)/signin/head.tsx
Normal file
5
src/app/(auth)/signin/head.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PageSeo from "@components/page-seo"
|
||||
|
||||
export default function AuthHead() {
|
||||
return <PageSeo title="Sign In" />
|
||||
}
|
5
src/app/(auth)/signin/page.tsx
Normal file
5
src/app/(auth)/signin/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Auth from "../components"
|
||||
|
||||
export default function SignInPage() {
|
||||
return <Auth page="signin" />
|
||||
}
|
5
src/app/(auth)/signup/head.tsx
Normal file
5
src/app/(auth)/signup/head.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PageSeo from "@components/page-seo"
|
||||
|
||||
export default function AuthHead() {
|
||||
return <PageSeo title="Sign Up" />
|
||||
}
|
11
src/app/(auth)/signup/page.tsx
Normal file
11
src/app/(auth)/signup/page.tsx
Normal 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} />
|
||||
}
|
76
src/app/(posts)/components/file-dropdown/dropdown.module.css
Normal file
76
src/app/(posts)/components/file-dropdown/dropdown.module.css
Normal 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);
|
||||
}
|
85
src/app/(posts)/components/file-dropdown/index.tsx
Normal file
85
src/app/(posts)/components/file-dropdown/index.tsx
Normal 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
|
82
src/app/(posts)/components/preview/index.tsx
Normal file
82
src/app/(posts)/components/preview/index.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
1
src/app/(posts)/components/preview/katex.min.css
vendored
Normal file
1
src/app/(posts)/components/preview/katex.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
122
src/app/(posts)/components/preview/preview.module.css
Normal file
122
src/app/(posts)/components/preview/preview.module.css
Normal 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);
|
||||
}
|
||||
}
|
86
src/app/(posts)/components/tabs/index.tsx
Normal file
86
src/app/(posts)/components/tabs/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
43
src/app/(posts)/components/tabs/tabs.module.css
Normal file
43
src/app/(posts)/components/tabs/tabs.module.css
Normal 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;
|
||||
}
|
11
src/app/(posts)/expired/page.tsx
Normal file
11
src/app/(posts)/expired/page.tsx
Normal 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're trying to view has expired.
|
||||
</Note>
|
||||
)
|
||||
}
|
26
src/app/(posts)/new/components/description/index.tsx
Normal file
26
src/app/(posts)/new/components/description/index.tsx
Normal 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
|
|
@ -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";
|
||||
}
|
||||
}
|
106
src/app/(posts)/new/components/drag-and-drop/index.tsx
Normal file
106
src/app/(posts)/new/components/drag-and-drop/index.tsx
Normal 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
|
|
@ -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%;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
44
src/app/(posts)/new/components/edit-document-list/index.tsx
Normal file
44
src/app/(posts)/new/components/edit-document-list/index.tsx
Normal 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
|
340
src/app/(posts)/new/components/new.tsx
Normal file
340
src/app/(posts)/new/components/new.tsx
Normal 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
|
47
src/app/(posts)/new/components/post.module.css
Normal file
47
src/app/(posts)/new/components/post.module.css
Normal 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);
|
||||
}
|
||||
}
|
40
src/app/(posts)/new/components/title/index.tsx
Normal file
40
src/app/(posts)/new/components/title/index.tsx
Normal 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)
|
18
src/app/(posts)/new/components/title/title.module.css
Normal file
18
src/app/(posts)/new/components/title/title.module.css
Normal 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;
|
||||
}
|
34
src/app/(posts)/new/from/[id]/page.tsx
Normal file
34
src/app/(posts)/new/from/[id]/page.tsx
Normal 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
|
5
src/app/(posts)/new/head.tsx
Normal file
5
src/app/(posts)/new/head.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PageSeo from "@components/page-seo"
|
||||
|
||||
export default function NewPostHead() {
|
||||
return <PageSeo title="New" />
|
||||
}
|
11
src/app/(posts)/new/layout.tsx
Normal file
11
src/app/(posts)/new/layout.tsx
Normal 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}</>
|
||||
}
|
6
src/app/(posts)/new/page.tsx
Normal file
6
src/app/(posts)/new/page.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import NewPost from "app/(posts)/new/components/new"
|
||||
import "./react-datepicker.css"
|
||||
|
||||
const New = () => <NewPost />
|
||||
|
||||
export default New
|
372
src/app/(posts)/new/react-datepicker.css
Normal file
372
src/app/(posts)/new/react-datepicker.css
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
54
src/app/(posts)/post/[id]/components/header/title/index.tsx
Normal file
54
src/app/(posts)/post/[id]/components/header/title/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
76
src/app/(posts)/post/[id]/components/post-page/index.tsx
Normal file
76
src/app/(posts)/post/[id]/components/post-page/index.tsx
Normal 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
|
|
@ -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
|
|
@ -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%;
|
||||
}
|
|
@ -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)
|
24
src/app/(posts)/post/[id]/head.tsx
Normal file
24
src/app/(posts)/post/[id]/head.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
14
src/app/(posts)/post/[id]/loading.tsx
Normal file
14
src/app/(posts)/post/[id]/loading.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
140
src/app/(posts)/post/[id]/page.tsx
Normal file
140
src/app/(posts)/post/[id]/page.tsx
Normal 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
|
24
src/app/(posts)/post/[id]/styles.module.css
Normal file
24
src/app/(posts)/post/[id]/styles.module.css
Normal 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;
|
||||
}
|
||||
}
|
9
src/app/admin/admin.module.css
Normal file
9
src/app/admin/admin.module.css
Normal 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
17
src/app/admin/layout.tsx
Normal 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
13
src/app/admin/loading.tsx
Normal 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
100
src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
44
src/app/author/[username]/page.tsx
Normal file
44
src/app/author/[username]/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
46
src/app/components/badges/badge.module.css
Normal file
46
src/app/components/badges/badge.module.css
Normal 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;
|
||||
}
|
17
src/app/components/badges/badge.tsx
Normal file
17
src/app/components/badges/badge.tsx
Normal 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
|
30
src/app/components/badges/created-ago-badge/index.tsx
Normal file
30
src/app/components/badges/created-ago-badge/index.tsx
Normal 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
|
58
src/app/components/badges/expiration-badge/index.tsx
Normal file
58
src/app/components/badges/expiration-badge/index.tsx
Normal 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
|
11
src/app/components/badges/visibility-badge/index.tsx
Normal file
11
src/app/components/badges/visibility-badge/index.tsx
Normal 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
|
110
src/app/components/badges/visibility-control/index.tsx
Normal file
110
src/app/components/badges/visibility-control/index.tsx
Normal 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
|
27
src/app/components/button-dropdown/dropdown.module.css
Normal file
27
src/app/components/button-dropdown/dropdown.module.css
Normal 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;
|
||||
}
|
54
src/app/components/button-dropdown/index.tsx
Normal file
54
src/app/components/button-dropdown/index.tsx
Normal 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
|
45
src/app/components/button-group/button-group.module.css
Normal file
45
src/app/components/button-group/button-group.module.css
Normal 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;
|
||||
}
|
||||
}
|
23
src/app/components/button-group/index.tsx
Normal file
23
src/app/components/button-group/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
58
src/app/components/button/button.module.css
Normal file
58
src/app/components/button/button.module.css
Normal 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);
|
||||
}
|
80
src/app/components/button/index.tsx
Normal file
80
src/app/components/button/index.tsx
Normal 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
|
15
src/app/components/card/card.module.css
Normal file
15
src/app/components/card/card.module.css
Normal 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;
|
||||
}
|
16
src/app/components/card/index.tsx
Normal file
16
src/app/components/card/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
15
src/app/components/fade-in/fade.module.css
Normal file
15
src/app/components/fade-in/fade.module.css
Normal 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;
|
||||
}
|
||||
}
|
30
src/app/components/fade-in/index.tsx
Normal file
30
src/app/components/fade-in/index.tsx
Normal 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
|
111
src/app/components/header/header.module.css
Normal file
111
src/app/components/header/header.module.css
Normal 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;
|
||||
}
|
||||
}
|
201
src/app/components/header/index.tsx
Normal file
201
src/app/components/header/index.tsx
Normal 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
|
76
src/app/components/input/index.tsx
Normal file
76
src/app/components/input/index.tsx
Normal 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
|
58
src/app/components/input/input.module.css
Normal file
58
src/app/components/input/input.module.css
Normal 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;
|
||||
}
|
18
src/app/components/link/index.tsx
Normal file
18
src/app/components/link/index.tsx
Normal 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
|
12
src/app/components/link/link.module.css
Normal file
12
src/app/components/link/link.module.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.color {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.color:hover {
|
||||
text-decoration: underline;
|
||||
}
|
18
src/app/components/note/index.tsx
Normal file
18
src/app/components/note/index.tsx
Normal 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
|
31
src/app/components/note/note.module.css
Normal file
31
src/app/components/note/note.module.css
Normal 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;
|
||||
}
|
89
src/app/components/page-seo/index.tsx
Normal file
89
src/app/components/page-seo/index.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
5
src/app/components/page/index.tsx
Normal file
5
src/app/components/page/index.tsx
Normal 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>
|
||||
}
|
10
src/app/components/page/page.module.css
Normal file
10
src/app/components/page/page.module.css
Normal 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;
|
||||
}
|
99
src/app/components/password-modal/index.tsx
Normal file
99
src/app/components/password-modal/index.tsx
Normal 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'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
|
69
src/app/components/password-modal/modal.module.css
Normal file
69
src/app/components/password-modal/modal.module.css
Normal 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);
|
||||
}
|
||||
}
|
35
src/app/components/popover/index.tsx
Normal file
35
src/app/components/popover/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
})
|
25
src/app/components/popover/popover.module.css
Normal file
25
src/app/components/popover/popover.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
163
src/app/components/post-list/index.tsx
Normal file
163
src/app/components/post-list/index.tsx
Normal 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
|
22
src/app/components/post-list/list-item-skeleton.tsx
Normal file
22
src/app/components/post-list/list-item-skeleton.tsx
Normal 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>
|
||||
)
|
36
src/app/components/post-list/list-item.module.css
Normal file
36
src/app/components/post-list/list-item.module.css
Normal 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;
|
||||
}
|
||||
}
|
113
src/app/components/post-list/list-item.tsx
Normal file
113
src/app/components/post-list/list-item.tsx
Normal 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
|
35
src/app/components/post-list/post-list.module.css
Normal file
35
src/app/components/post-list/post-list.module.css
Normal 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);
|
||||
}
|
48
src/app/components/scroll-to-top/index.tsx
Normal file
48
src/app/components/scroll-to-top/index.tsx
Normal 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
|
25
src/app/components/scroll-to-top/scroll.module.css
Normal file
25
src/app/components/scroll-to-top/scroll.module.css
Normal 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;
|
||||
}
|
20
src/app/components/settings-group/index.tsx
Normal file
20
src/app/components/settings-group/index.tsx
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
.content form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
11
src/app/components/skeleton/index.tsx
Normal file
11
src/app/components/skeleton/index.tsx
Normal 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 }} />
|
||||
}
|
4
src/app/components/skeleton/skeleton.module.css
Normal file
4
src/app/components/skeleton/skeleton.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.skeleton {
|
||||
background-color: var(--lighter-gray);
|
||||
border-radius: var(--radius);
|
||||
}
|
3
src/app/components/spinner/index.tsx
Normal file
3
src/app/components/spinner/index.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
import styles from "./spinner.module.css"
|
||||
|
||||
export const Spinner = () => <div className={styles.spinner} />
|
17
src/app/components/spinner/spinner.module.css
Normal file
17
src/app/components/spinner/spinner.module.css
Normal 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);
|
||||
}
|
||||
}
|
69
src/app/components/toasts/index.tsx
Normal file
69
src/app/components/toasts/index.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue