Compare commits

..

5 commits

Author SHA1 Message Date
Max Leiter
a30425a069
/verify-token --> /verify-signed-in 2022-05-06 21:57:59 -07:00
Max Leiter
743ca20470
add whitelisting IPs 2022-05-06 21:52:51 -07:00
Max Leiter
f74f7b1f1a
code review: don't create auth token if using header auth 2022-05-06 21:40:30 -07:00
Max Leiter
05cc23a144
remove file accidently included in rebase 2022-05-06 21:35:26 -07:00
Max Leiter
13040ab8cc
server: begin implementing header auth 2022-05-06 21:34:19 -07:00
366 changed files with 17311 additions and 19913 deletions

View file

@ -1,35 +0,0 @@
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Optional if you use Vercel (defaults to VERCEL_URL).
# Necessary in development unless you use the vercel CLI (`vc dev`)
DRIFT_URL=http://localhost:3000
# Optional: The first user becomes an admin. Defaults to false
ENABLE_ADMIN=false
# Required: Next auth secret is a required valid JWT secret. You can generate one with `openssl rand -hex 32`
NEXTAUTH_SECRET=7f8b8b5c5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5f5
# Required: but unnecessary if you use a supported host like Vercel
NEXTAUTH_URL=http://localhost:3000
# Optional: for locking your instance
REGISTRATION_PASSWORD=
# Optional: for if you want GitHub oauth. Currently incompatible with the registration password
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Optional: if you want Keycloak oauth. Currently incompatible with the registration password
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER= # keycloak path including realm
KEYCLOAK_NAME=
# Optional: if you want to support credential auth (username/password, supports registration password)
# Defaults to true
CREDENTIAL_AUTH=true
# Optional:
WELCOME_CONTENT=
WELCOME_TITLE=

View file

@ -1,14 +0,0 @@
{
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
"ignorePatterns": [
"node_modules/",
"__tests__/",
"coverage/",
".next/",
"public"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error"
}
}

36
.gitignore vendored
View file

@ -1,36 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
analyze
# 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 .vercel
drift.sqlite
# typescript
*.tsbuildinfo

View file

@ -1,5 +0,0 @@
{
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"dotenv.enableAutocloaking": false
}

135
README.md
View file

@ -1,19 +1,13 @@
# <img src="src/public/assets/logo.png" height="32px" alt="" /> Drift # <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
> **Note:** This branch is where all work is being done to refactor to the Next.js 13 app directory and React Server Components. Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is completely functional.
Drift is a self-hostable Gist clone. It's in beta, but is completely functional. You can try a demo at https://drift.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time.
You can try a demo at https://drift.lol. The demo is built on main but has no database, so files and accounts can be wiped at any time.
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User). If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
<hr /> <hr />
**Contents:** **Contents:**
- [Setup](#setup) - [Setup](#setup)
- [Development](#development) - [Development](#development)
- [Production](#production) - [Production](#production)
@ -26,122 +20,81 @@ Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://git
### Development ### Development
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation). In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).)
You can run `pnpm dev` in `client` for file watching and live reloading. You can run `yarn dev` in either / both folders to start the server and client with file watching / live reloading.
To work with [prisma](prisma.io/), you can use `pnpm prisma` or `pnpm exec prisma` to interact with the database. To migrate the sqlite database in development, you can use `yarn migrate` to see a list of options.
### Production ### Production
`pnpm build` will produce production code. `pnpm start` will start the Next.js server. `yarn build` in both `client/` and `server/` will produce production code for the client and server respectively.
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
In production the sqlite database will be automatically migrated to the latest version.
### Environment Variables ### Environment Variables
You can change these to your liking. You can change these to your liking.
`.env`: `client/.env`:
- `DRIFT_URL`: the URL of the drift instance. - `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`. - `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
`server/.env`:
- `PORT`: the default port to start the server on (3000 by default)
- `NODE_ENV`: defaults to development, can be `production`
- `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm).
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
- `REGISTRATION_PASSWORD`: if `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no additional password will be required.
- `SECRET_KEY`: the same secret key as the client
- `WELCOME_CONTENT`: a markdown string that's rendered on the home page - `WELCOME_CONTENT`: a markdown string that's rendered on the home page
- `WELCOME_TITLE`: the file title for the post on the homepage. - `WELCOME_TITLE`: the file title for the post on the homepage.
- `ENABLE_ADMIN`: the first account created is an administrator account - `ENABLE_ADMIN`: the first account created is an administrator account
- `REGISTRATION_PASSWORD`: the password required to register an account. If not set, no password is required. - `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images
- `NODE_ENV`: defaults to development, can be `production`
#### Auth environment variables ### For SSO
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
- `GITHUB_CLIENT_SECRET`: the client secret for GitHub OAuth.
- `NEXTAUTH_URL`: the URL of the drift instance. Not required if hosting on Vercel.
- `CREDENTIAL_AUTH`: whether to allow username/password authentication. Defaults to `true`.
- `HEADER_AUTH`: if true, enables authenthication via the HTTP header specified in `HEADER_AUTH_KEY` which is generally populated at the reverse-proxy level.
- `HEADER_AUTH_KEY`: if `HEADER_AUTH` is true, the header to look for the users username (like `Auth-User`)
- `HEADER_AUTH_ROLE`: if `HEADER_AUTH` is true, the header to look for the users role ("user" | "admin", at the moment)
- `HEADER_AUTH_WHITELISTED_IPS`: comma-separated list of IPs users can access Drift from using header authentication. Defaults to '127.0.0.1'.
## Running with pm2 ## Running with pm2
It's easy to start Drift using [pm2](https://pm2.keymetrics.io/). It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
First, add the `.env` file with your values (see the above section for the required options). First, add `.env` files to `client/` and `server/` with the values you want (see the above section for possible values).
Then, use the following commands to start the client and server:
Then, use the following command to start the server: - `cd server && yarn build && pm2 start yarn --name drift-server --interpreter bash -- start`
- `cd ..`
- `cd client && yarn build && pm2 start yarn --name drift-client --interpreter bash -- start`
- `pnpm build && pm2 start pnpm --name drift --interpreter bash -- start` You now use `pm2 ls` to see their statuses. Refer to pm2's docs or `pm2 help` for more information.
Refer to pm2's docs or `pm2 help` for more information.
## Running with Docker ## Running with Docker
## Running with systemd The client and server each have Dockerfiles ([client](https://github.com/MaxLeiter/Drift/blob/main/client/Dockerfile), [server](https://github.com/MaxLeiter/Drift/blob/main/server/Dockerfile)) you can use with a docker-compose; an example compose [is provided in the repository](https://github.com/MaxLeiter/Drift/blob/main/docker-compose.yml). It's recommended you pair running them with nginx or another reverse proxy. Also review the environment variables above and configure them to your liking.
_**NOTE:** We assume that you know how to enable user lingering if you don't want to use the systemd unit as root_
- As root
- Place the following systemd unit in ___/etc/systemd/system___ and name it _drift.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (Global)
##########
[Unit]
Description=Drift Server (Global)
After=default.target
[Service]
User=$USERNAME
Group=$USERNAME
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
- As a nomal user
- Place the following systemd unit inside ___/home/user/.config/systemd/user___ and name it _drift_user.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (User)
##########
[Unit]
Description=Drift Server (User)
After=default.target
[Service]
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
## Current status ## Current status
Drift is a work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist. Drift is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
- [x] Next.js 13 `app` directory - [x] creating and sharing private, public, unlisted posts
- [x] creating and sharing private, public, password-protected, and unlisted posts - [x] syntax highlighting (detected by file extension)
- [x] syntax highlighting - [x] multiple files per post
- [x] expiring posts - [x] uploading files via drag-and-drop
- [x] responsive UI - [x] responsive UI
- [x] user auth - [x] user auth
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11)) - [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
- [x] SSO via GitHub OAuth
- [x] downloading files (individually and entire posts) - [x] downloading files (individually and entire posts)
- [x] password protected posts - [x] password protected posts
- [x] postgres database - [x] sqlite database
- [x] administrator account / settings - [ ] administrator account / settings
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75)) - [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
- [ ] publish docker builds - [ ] publish docker builds
- [ ] user settings - [ ] user settings
- [ ] works enough with JavaScript disabled - [ ] works enough with JavaScript disabled
- [ ] in-depth documentation - [x] documentation
- [x] customizable homepage, so the demo can exist as-is but other instances can be built from the same source. Environment variable for the file contents? - [x] customizable homepage, so the demo can exist as-is but other instances can be built from the same source. Environment variable for the file contents?
- [ ] fleshed out API
- [ ] Swappable database backends
- [ ] More OAuth providers

2
client/.env.local Normal file
View file

@ -0,0 +1,2 @@
API_URL=http://localhost:3000
SECRET_KEY=secret

3
client/.eslintrc.json Normal file
View file

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

35
client/.gitignore vendored Normal file
View file

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

View file

@ -3,6 +3,5 @@
"trailingComma": "none", "trailingComma": "none",
"singleQuote": false, "singleQuote": false,
"printWidth": 80, "printWidth": 80,
"useTabs": true, "useTabs": true
"plugins": ["prettier-plugin-tailwindcss"]
} }

34
client/README.md Normal file
View file

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

View file

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

View file

@ -0,0 +1,38 @@
import { Popover, Button } from "@geist-ui/core"
import { MoreVertical } from "@geist-ui/icons"
type Action = {
title: string
onClick: () => void
}
const ActionDropdown = ({
title = "Actions",
actions,
showTitle = false
}: {
title?: string
showTitle?: boolean
actions: Action[]
}) => {
return (
<Popover
title={title}
content={
<>
{showTitle && <Popover.Item title>{title}</Popover.Item>}
{actions.map((action) => (
<Popover.Item onClick={action.onClick} key={action.title}>
{action.title}
</Popover.Item>
))}
</>
}
hideArrow
>
<Button iconRight={<MoreVertical />} auto></Button>
</Popover>
)
}
export default ActionDropdown

View file

@ -0,0 +1,25 @@
.adminWrapper table {
width: 100%;
border-spacing: 0;
border: 1px solid var(--gray);
border-radius: var(--radius);
padding: var(--gap-half);
}
.adminWrapper table th {
text-align: left;
background: var(--gray-light);
color: var(--gray-dark);
font-weight: bold;
}
.postModal details {
border-radius: var(--radius);
padding: var(--gap);
border-radius: var(--radius);
}
.postModal summary {
cursor: pointer;
outline: none;
}

View file

@ -0,0 +1,34 @@
import { Text, Spacer } from "@geist-ui/core"
import Cookies from "js-cookie"
import styles from "./admin.module.css"
import PostTable from "./post-table"
import UserTable from "./user-table"
export const adminFetcher = async (
url: string,
options?: {
method?: string
body?: any
}
) =>
fetch("/server-api/admin" + url, {
method: options?.method || "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
body: options?.body && JSON.stringify(options.body)
})
const Admin = () => {
return (
<div className={styles.adminWrapper}>
<Text h2>Administration</Text>
<UserTable />
<Spacer height={1} />
<PostTable />
</div>
)
}
export default Admin

View file

@ -0,0 +1,141 @@
import SettingsGroup from "@components/settings-group"
import { Fieldset, useToasts } from "@geist-ui/core"
import byteToMB from "@lib/byte-to-mb"
import { Post } from "@lib/types"
import Table from "rc-table"
import { useEffect, useMemo, useState } from "react"
import { adminFetcher } from "."
import ActionDropdown from "./action-dropdown"
const PostTable = () => {
const [posts, setPosts] = useState<Post[]>()
const { setToast } = useToasts()
useEffect(() => {
const fetchPosts = async () => {
const res = await adminFetcher("/posts")
const data = await res.json()
setPosts(data)
}
fetchPosts()
}, [])
const tablePosts = useMemo(
() =>
posts?.map((post) => {
return {
id: post.id,
title: post.title,
files: post.files?.length || 0,
createdAt: `${new Date(
post.createdAt
).toLocaleDateString()} ${new Date(
post.createdAt
).toLocaleTimeString()}`,
visibility: post.visibility,
size: post.files
? byteToMB(
post.files.reduce((acc, file) => acc + file.html.length, 0)
)
: 0,
actions: ""
}
}),
[posts]
)
const deletePost = async (/* id: string */) => {
return alert("Not implemented")
// const confirm = window.confirm("Are you sure you want to delete this post?")
// if (!confirm) return
// const res = await adminFetcher(`/posts/${id}`, {
// method: "DELETE",
// })
// const json = await res.json()
// if (res.status === 200) {
// setToast({
// text: "Post deleted",
// type: "success"
// })
// setPosts((posts) => {
// const newPosts = posts?.filter((post) => post.id !== id)
// return newPosts
// })
// } else {
// setToast({
// text: json.error || "Something went wrong",
// type: "error"
// })
// }
}
const tableColumns = [
{
title: "Title",
dataIndex: "title",
key: "title",
width: 50
},
{
title: "Files",
dataIndex: "files",
key: "files",
width: 10
},
{
title: "Created",
dataIndex: "createdAt",
key: "createdAt",
width: 100
},
{
title: "Visibility",
dataIndex: "visibility",
key: "visibility",
width: 50
},
{
title: "Size (MB)",
dataIndex: "size",
key: "size",
width: 10
},
{
title: "Actions",
dataIndex: "",
key: "actions",
width: 50,
render() {
return (
<ActionDropdown
title="Actions"
actions={[
{
title: "Delete",
onClick: () => deletePost()
}
]}
/>
)
}
}
]
return (
<SettingsGroup title="Posts">
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{posts && (
<Fieldset.Subtitle>
<h5>{posts.length} posts</h5>
</Fieldset.Subtitle>
)}
{posts && <Table columns={tableColumns} data={tablePosts} />}
</SettingsGroup>
)
}
export default PostTable

View file

@ -0,0 +1,161 @@
import { Fieldset, useToasts } from "@geist-ui/core"
import { User } from "@lib/types"
import { useEffect, useMemo, useState } from "react"
import { adminFetcher } from "."
import Table from "rc-table"
import SettingsGroup from "@components/settings-group"
import ActionDropdown from "./action-dropdown"
const UserTable = () => {
const [users, setUsers] = useState<User[]>()
const { setToast } = useToasts()
useEffect(() => {
const fetchUsers = async () => {
const res = await adminFetcher("/users")
const data = await res.json()
setUsers(data)
}
fetchUsers()
}, [])
const toggleRole = async (id: string, role: "admin" | "user") => {
const res = await adminFetcher("/users/toggle-role", {
method: "POST",
body: { id, role }
})
const json = await res.json()
if (res.status === 200) {
setToast({
text: "Role updated",
type: "success"
})
setUsers((users) => {
const newUsers = users?.map((user) => {
if (user.id === id) {
return {
...user,
role
}
}
return user
})
return newUsers
})
} else {
setToast({
text: json.error || "Something went wrong",
type: "error"
})
}
}
const deleteUser = async (id: string) => {
const confirm = window.confirm("Are you sure you want to delete this user?")
if (!confirm) return
const res = await adminFetcher(`/users/${id}`, {
method: "DELETE"
})
const json = await res.json()
if (res.status === 200) {
setToast({
text: "User deleted",
type: "success"
})
} else {
setToast({
text: json.error || "Something went wrong",
type: "error"
})
}
}
const tableUsers = useMemo(
() =>
users?.map((user) => {
return {
id: user.id,
username: user.username,
posts: user.posts?.length || 0,
createdAt: `${new Date(
user.createdAt
).toLocaleDateString()} ${new Date(
user.createdAt
).toLocaleTimeString()}`,
role: user.role,
actions: ""
}
}),
[users]
)
const usernameColumns = [
{
title: "Username",
dataIndex: "username",
key: "username",
width: 50
},
{
title: "Posts",
dataIndex: "posts",
key: "posts",
width: 10
},
{
title: "Created",
dataIndex: "createdAt",
key: "createdAt",
width: 100
},
{
title: "Role",
dataIndex: "role",
key: "role",
width: 50
},
{
title: "Actions",
dataIndex: "",
key: "actions",
width: 50,
render(user: User) {
return (
<ActionDropdown
title="Actions"
actions={[
{
title: user.role === "admin" ? "Change role" : "Make admin",
onClick: () =>
toggleRole(user.id, user.role === "admin" ? "user" : "admin")
},
{
title: "Delete",
onClick: () => deleteUser(user.id)
}
]}
/>
)
}
}
]
return (
<SettingsGroup title="Users">
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{users && (
<Fieldset.Subtitle>
<h5>{users.length} users</h5>
</Fieldset.Subtitle>
)}
{users && <Table columns={usernameColumns} data={tableUsers} />}
</SettingsGroup>
)
}
export default UserTable

View file

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

View file

@ -0,0 +1,22 @@
.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;
}
.formContentSpace {
margin-bottom: 1rem;
text-align: center;
}

View file

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

View file

@ -0,0 +1,27 @@
import { Badge, Tooltip } from "@geist-ui/core"
import { timeAgo } from "@lib/time-ago"
import { useMemo, useState, useEffect } from "react"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate))
}, 1000)
return () => clearInterval(interval)
}, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (
<Badge type="secondary">
{" "}
<Tooltip hideArrow text={formattedTime}>
Created {time}
</Tooltip>
</Badge>
)
}
export default CreatedAgoBadge

View file

@ -0,0 +1,66 @@
import { Badge, Tooltip } from "@geist-ui/core"
import { timeUntil } from "@lib/time-ago"
import { useCallback, useEffect, useMemo, useState } from "react"
const ExpirationBadge = ({
postExpirationDate
}: // onExpires
{
postExpirationDate: Date | string | null
onExpires?: () => void
}) => {
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 && timeUntilString === "in 0 seconds"
}, [timeUntilString])
// useEffect(() => {
// // check if expired every
// if (isExpired) {
// if (onExpires) {
// onExpires();
// }
// }
// }, [isExpired, onExpires])
if (!expirationDate) {
return null
}
return (
<Badge type={isExpired ? "error" : "warning"}>
<Tooltip
hideArrow
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
>
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
</Tooltip>
</Badge>
)
}
export default ExpirationBadge

View file

@ -0,0 +1,23 @@
import { Badge } from "@geist-ui/core"
import type { PostVisibility } from "@lib/types"
type Props = {
visibility: PostVisibility
}
const VisibilityBadge = ({ visibility }: Props) => {
const getBadgeType = () => {
switch (visibility) {
case "public":
return "success"
case "private":
return "warning"
case "unlisted":
return "default"
}
}
return <Badge type={getBadgeType()}>{visibility}</Badge>
}
export default VisibilityBadge

View file

@ -0,0 +1,111 @@
import PasswordModal from "@components/new-post/password-modal"
import { Button, ButtonGroup, Loading, useToasts } from "@geist-ui/core"
import type { PostVisibility } from "@lib/types"
import Cookies from "js-cookie"
import { useCallback, useState } from "react"
type Props = {
postId: string
visibility: PostVisibility
setVisibility: (visibility: PostVisibility) => void
}
const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => {
const [isSubmitting, setSubmitting] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const { setToast } = useToasts()
const sendRequest = useCallback(
async (visibility: PostVisibility, password?: string) => {
const res = await fetch(`/server-api/posts/${postId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
body: JSON.stringify({ visibility, password })
})
if (res.ok) {
const json = await res.json()
setVisibility(json.visibility)
} else {
const json = await res.json()
setToast({
text: json.error.message,
type: "error"
})
setPasswordModalVisible(false)
}
},
[postId, setToast, setVisibility]
)
const onSubmit = useCallback(
async (visibility: PostVisibility, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false)
const timeout = setTimeout(() => setSubmitting(true), 100)
await sendRequest(visibility, password)
clearTimeout(timeout)
setSubmitting(false)
},
[sendRequest]
)
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = useCallback(
(password: string) => onSubmit("protected", password),
[onSubmit]
)
return (
<>
{isSubmitting ? (
<Loading />
) : (
<ButtonGroup margin={0}>
<Button
disabled={visibility === "private"}
onClick={() => onSubmit("private")}
>
Make private
</Button>
<Button
disabled={visibility === "public"}
onClick={() => onSubmit("public")}
>
Make Public
</Button>
<Button
disabled={visibility === "unlisted"}
onClick={() => onSubmit("unlisted")}
>
Unlist
</Button>
<Button onClick={() => onSubmit("protected")}>
{visibility === "protected"
? "Change Password"
: "Protect with password"}
</Button>
</ButtonGroup>
)}
<PasswordModal
creating={true}
isOpen={passwordModalVisible}
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</>
)
}
export default VisibilityControl

View file

@ -0,0 +1,26 @@
.main {
margin-bottom: 2rem;
}
.dropdown {
position: relative;
display: inline-block;
vertical-align: middle;
cursor: pointer;
padding: 0;
border: 0;
background: transparent;
}
.dropdownContent {
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.25rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,116 @@
import Button from "@components/button"
import React, { useCallback, useEffect } from "react"
import { useState } from "react"
import styles from "./dropdown.module.css"
import DownIcon from "@geist-ui/icons/arrowDown"
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 }) => {
const [visible, setVisible] = useState(false)
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(!visible)
}
const onBlur = () => {
setVisible(false)
}
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(false)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
setVisible(false)
}
}
const onClickOutside = useCallback(
() => (e: React.MouseEvent<HTMLDivElement>) => {
if (dropdown && !dropdown.contains(e.target as Node)) {
setVisible(false)
}
},
[dropdown]
)
useEffect(() => {
if (visible) {
document.addEventListener("mousedown", onClickOutside)
} else {
document.removeEventListener("mousedown", onClickOutside)
}
return () => {
document.removeEventListener("mousedown", onClickOutside)
}
}, [visible, onClickOutside])
if (!Array.isArray(props.children)) {
return null
}
return (
<div
className={`${styles.main} ${className}`}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onBlur={onBlur}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-end"
}}
>
{props.children[0]}
<Button
style={{ height: iconHeight, width: iconHeight }}
className={styles.icon}
onClick={() => setVisible(!visible)}
>
<DownIcon />
</Button>
</div>
{visible && (
<div className={`${styles.dropdown}`}>
<div className={`${styles.dropdownContent}`}>
{props.children.slice(1)}
</div>
</div>
)}
</div>
)
}
export default ButtonDropdown

View file

@ -0,0 +1,40 @@
.button {
user-select: none;
cursor: pointer;
border-radius: var(--radius);
color: var(--input-fg);
font-weight: 400;
font-size: 1.1rem;
background: var(--input-bg);
border: var(--input-border);
height: 2rem;
display: flex;
align-items: center;
padding: var(--gap-quarter) var(--gap-half);
transition: background-color var(--transition), color var(--transition);
width: 100%;
height: var(--input-height);
}
.button:hover,
.button:focus {
outline: none;
background: var(--input-bg-hover);
border: var(--input-border-focus);
}
.button[disabled] {
cursor: not-allowed;
background: var(--lighter-gray);
color: var(--gray);
}
.secondary {
background: var(--bg);
color: var(--fg);
}
.primary {
background: var(--fg);
color: var(--bg);
}

View file

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

View file

@ -1,8 +1,8 @@
import type { Document } from "../new" import type { Document } from "@lib/types"
import DocumentComponent from "./edit-document" import DocumentComponent from "@components/edit-document"
import { ChangeEvent, useCallback, ClipboardEvent } from "react" import { ChangeEvent, memo, useCallback } from "react"
function DocumentList({ const DocumentList = ({
docs, docs,
removeDoc, removeDoc,
updateDocContent, updateDocContent,
@ -13,8 +13,8 @@ function DocumentList({
updateDocTitle: (i: number) => (title: string) => void updateDocTitle: (i: number) => (title: string) => void
updateDocContent: (i: number) => (content: string) => void updateDocContent: (i: number) => (content: string) => void
removeDoc: (i: number) => () => void removeDoc: (i: number) => () => void
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void onPaste: (e: any) => void
}) { }) => {
const handleOnChange = useCallback( const handleOnChange = useCallback(
(i: number) => (e: ChangeEvent<HTMLTextAreaElement>) => { (i: number) => (e: ChangeEvent<HTMLTextAreaElement>) => {
updateDocContent(i)(e.target.value) updateDocContent(i)(e.target.value)
@ -41,4 +41,4 @@ function DocumentList({
) )
} }
export default DocumentList export default memo(DocumentList)

View file

@ -0,0 +1,47 @@
.card {
margin: var(--gap) auto;
padding: var(--gap);
border: 1px solid var(--light-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%;
}
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}
@media (max-width: 768px) {
.actionWrapper .actions {
position: relative;
margin-left: 0 !important;
}
}

View file

@ -1,27 +1,22 @@
import { import Bold from "@geist-ui/icons/bold"
Bold, import Italic from "@geist-ui/icons/italic"
Code, import Link from "@geist-ui/icons/link"
Image as ImageIcon, import Code from "@geist-ui/icons/code"
Italic, import List from "@geist-ui/icons/list"
Link,
List import ImageIcon from "@geist-ui/icons/image"
} from "react-feather"
import { RefObject, useMemo } from "react" import { RefObject, useMemo } from "react"
import styles from "./formatting-icons.module.css" import styles from "../document.module.css"
import { Button, ButtonGroup, Tooltip } from "@geist-ui/core"
import { TextareaMarkdownRef } from "textarea-markdown-editor" import { TextareaMarkdownRef } from "textarea-markdown-editor"
import { Tooltip } from "@components/tooltip"
import { Button } from "@components/button"
import clsx from "clsx"
import React from "react"
// TODO: clean up // TODO: clean up
function FormattingIcons({ const FormattingIcons = ({
textareaRef, textareaRef
className
}: { }: {
textareaRef?: RefObject<TextareaMarkdownRef> textareaRef?: RefObject<TextareaMarkdownRef>
className?: string }) => {
}) {
const formattingActions = useMemo(() => { const formattingActions = useMemo(() => {
const handleBoldClick = () => textareaRef?.current?.trigger("bold") const handleBoldClick = () => textareaRef?.current?.trigger("bold")
const handleItalicClick = () => textareaRef?.current?.trigger("italic") const handleItalicClick = () => textareaRef?.current?.trigger("italic")
@ -65,25 +60,25 @@ function FormattingIcons({
}, [textareaRef]) }, [textareaRef])
return ( return (
<div className={clsx(styles.actionWrapper, className)}> <div className={styles.actionWrapper}>
{formattingActions.map(({ icon, name, action }) => ( <ButtonGroup className={styles.actions}>
<Tooltip {formattingActions.map(({ icon, name, action }) => (
content={name[0].toUpperCase() + name.slice(1).replace("-", " ")} <Tooltip
key={name} text={name[0].toUpperCase() + name.slice(1).replace("-", " ")}
delayDuration={100} key={name}
>
<Button
aria-label={name}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
variant="ghost"
> >
{React.cloneElement(icon, { <Button
className: "h-4 w-4" auto
})} scale={2 / 3}
</Button> px={0.6}
</Tooltip> aria-label={name}
))} icon={icon}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
/>
</Tooltip>
))}
</ButtonGroup>
</div> </div>
) )
} }

View file

@ -0,0 +1,162 @@
import {
ChangeEvent,
memo,
useCallback,
useMemo,
useRef,
useState
} from "react"
import styles from "./document.module.css"
import Trash from "@geist-ui/icons/trash"
import FormattingIcons from "./formatting-icons"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import { Button, Input, Spacer, Tabs, Textarea } from "@geist-ui/core"
import Preview from "@components/preview"
// import Link from "next/link"
type Props = {
title?: string
content?: string
setTitle?: (title: string) => void
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
initialTab?: "edit" | "preview"
remove?: () => void
onPaste?: (e: any) => void
}
const Document = ({
onPaste,
remove,
title,
content,
setTitle,
initialTab = "edit",
handleOnContentChange
}: Props) => {
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%"
const handleTabChange = (newTab: string) => {
if (newTab === "edit") {
codeEditorRef.current?.focus()
}
setTab(newTab as "edit" | "preview")
}
const 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]
)
// if (skeleton) {
// return <>
// <Spacer height={1} />
// <div className={styles.card}>
// <div className={styles.fileNameContainer}>
// <Skeleton width={275} height={36} />
// {remove && <Skeleton width={36} height={36} />}
// </div>
// <div className={styles.descriptionContainer}>
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
// <Skeleton width={'100%'} height={350} />
// </div >
// </div>
// </>
// }
return (
<>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
value={title}
onChange={onTitleChange}
marginTop="var(--gap-double)"
size={1.2}
font={1.2}
label="Filename"
width={"100%"}
id={title}
/>
{remove && (
<Button
type="abort"
ghost
icon={<Trash />}
auto
height={"36px"}
width={"36px"}
onClick={() => removeFile(remove)}
/>
)}
</div>
<div className={styles.descriptionContainer}>
{tab === "edit" && <FormattingIcons textareaRef={codeEditorRef} />}
<Tabs
onChange={handleTabChange}
initialValue={initialTab}
hideDivider
leftSpace={0}
>
<Tabs.Item label={"Edit"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<TextareaMarkdown.Wrapper ref={codeEditorRef}>
<Textarea
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
placeholder=""
value={content}
onChange={handleOnContentChange}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</TextareaMarkdown.Wrapper>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: "var(--gap-half)" }}>
<Preview height={height} title={title} content={content} />
</div>
</Tabs.Item>
</Tabs>
</div>
</div>
</>
)
}
export default memo(Document)

View file

@ -0,0 +1,17 @@
import { Page } from "@geist-ui/core"
const Error = ({ status }: { status: number }) => {
return (
<Page title={status.toString() || "Error"}>
{status === 404 ? (
<h1>This page cannot be found.</h1>
) : (
<section>
<p>An error occurred: {status}</p>
</section>
)}
</Page>
)
}
export default Error

View file

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

View file

@ -1,32 +1,19 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/ // https://www.joshwcomeau.com/snippets/react-components/fade-in/
import React from "react"
import styles from "./fade.module.css" import styles from "./fade.module.css"
function FadeIn({ const FadeIn = ({
duration = 300, duration = 300,
delay = 0, delay = 0,
children, children,
as,
...delegated ...delegated
}: { }: {
duration?: number duration?: number
delay?: number delay?: number
children: React.ReactNode children: React.ReactNode
as?: React.ElementType | JSX.Element [key: string]: any
} & React.HTMLAttributes<HTMLElement>) { }) => {
if (as !== null && typeof as === "object") {
return React.cloneElement(as, {
className: styles.fadeIn,
style: {
...(as.props.style || {}),
animationDuration: duration + "ms",
animationDelay: delay + "ms"
}
})
}
const Element = as || "div"
return ( return (
<Element <div
{...delegated} {...delegated}
className={styles.fadeIn} className={styles.fadeIn}
style={{ style={{
@ -36,7 +23,7 @@ function FadeIn({
}} }}
> >
{children} {children}
</Element> </div>
) )
} }

View file

@ -0,0 +1,72 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.dropdownButton {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
}
.content {
list-style: none;
width: 100%;
padding: 0 var(--gap);
margin: 0;
}
.content li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: 0 0;
}
.content li:hover,
.content li:focus {
background-color: var(--lighter-gray);
}
.content li a {
display: flex;
align-items: center;
text-align: center;
padding: var(--gap-half) var(--gap);
color: var(--dark-gray);
}
.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;
}
.cardContent {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--gap-half);
padding-top: 200px;
}
@media screen and (max-width: 82rem) {
}

View file

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

View file

@ -0,0 +1,109 @@
.fileTreeWrapper {
display: block;
position: absolute;
left: 0;
height: 100%;
}
.fileTreeWrapper h5 {
text-align: center;
}
.fileTree {
list-style: none;
width: 100%;
}
.fileTree li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: var(--gap-half) 0;
}
.fileTree li a {
margin: 0px;
display: block;
width: 100%;
height: 100%;
padding: var(--gap-half);
}
.fileTree li:hover,
.fileTree li:focus,
.fileTree li:active {
background: var(--lighter-gray);
}
.fileTree li .fileTreeIcon {
display: inline-block;
padding-right: var(--gap-half);
padding-left: var(--gap-half);
}
.fileTree li .fileTreeTitle {
font-size: 1.1rem;
}
.fileTree li::before {
content: "";
padding: 0;
margin: 0;
}
.card {
top: 0;
overflow-y: scroll;
overflow-x: hidden;
position: fixed;
}
.cardContent {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--gap-half);
padding-top: 200px;
}
@media screen and (max-width: 82rem) {
.fileTreeWrapper {
position: relative;
width: 100%;
height: auto;
margin-top: var(--gap);
}
.card {
position: relative;
padding: 0;
}
.cardContent {
margin: var(--gap);
padding: 0;
}
.fileTree {
width: 100%;
}
.fileTree li {
padding: 0.5rem 0;
}
.fileTree li .fileTreeIcon {
margin-right: var(--gap-half);
}
.fileTree li .fileTreeTitle {
font-size: 1.2rem;
}
.fileTree li::before {
content: "";
padding: 0;
margin: 0;
}
}

View file

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

View file

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

View file

@ -0,0 +1,46 @@
import React, { useEffect, useState } from "react"
import MoonIcon from "@geist-ui/icons/moon"
import SunIcon from "@geist-ui/icons/sun"
// import { useAllThemes, useTheme } from '@geist-ui/core'
import styles from "./header.module.css"
import { Select } from "@geist-ui/core"
import { useTheme } from "next-themes"
const Controls = () => {
const [mounted, setMounted] = useState(false)
const { resolvedTheme, setTheme } = useTheme()
useEffect(() => setMounted(true), [])
if (!mounted) return null
const switchThemes = () => {
if (resolvedTheme === "dark") {
setTheme("light")
} else {
setTheme("dark")
}
}
return (
<div className={styles.wrapper}>
<Select
scale={0.5}
h="28px"
pure
onChange={switchThemes}
value={resolvedTheme}
>
<Select.Option value="light">
<span className={styles.selectContent}>
<SunIcon size={14} /> Light
</span>
</Select.Option>
<Select.Option value="dark">
<span className={styles.selectContent}>
<MoonIcon size={14} /> Dark
</span>
</Select.Option>
</Select>
</div>
)
}
export default React.memo(Controls)

View file

@ -0,0 +1,62 @@
.tabs {
justify-content: center;
display: flex;
margin: var(--gap) 0;
}
.tabs .buttons {
display: flex;
justify-content: center;
align-items: center;
}
.tabs .buttons > button,
.tabs .buttons > a > button {
border: none;
border-radius: 0;
cursor: pointer;
}
.tabs .active {
border-bottom: 1px solid var(--darker-gray) !important;
}
.mobile {
position: absolute;
z-index: 1;
}
.controls {
margin-top: var(--gap);
display: none !important;
}
@media only screen and (max-width: 650px) {
.tabs {
display: none;
}
.controls {
display: block !important;
}
}
.controls button:active,
.controls button:focus,
.controls button:hover {
outline: 1px solid rgba(0, 0, 0, 0.2);
}
.wrapper {
display: flex;
align-items: center;
width: min-content;
}
.selectContent {
width: auto;
height: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -0,0 +1,225 @@
import {
ButtonGroup,
Button,
Page,
Spacer,
useBodyScroll,
useMediaQuery
} from "@geist-ui/core"
import { useCallback, useEffect, useMemo, useState } from "react"
import styles from "./header.module.css"
import useSignedIn from "../../lib/hooks/use-signed-in"
import HomeIcon from "@geist-ui/icons/home"
import MenuIcon from "@geist-ui/icons/menu"
import GitHubIcon from "@geist-ui/icons/github"
import SignOutIcon from "@geist-ui/icons/userX"
import SignInIcon from "@geist-ui/icons/user"
import SignUpIcon from "@geist-ui/icons/userPlus"
import NewIcon from "@geist-ui/icons/plusCircle"
import YourIcon from "@geist-ui/icons/list"
import MoonIcon from "@geist-ui/icons/moon"
import SettingsIcon from "@geist-ui/icons/settings"
import SunIcon from "@geist-ui/icons/sun"
import { useTheme } from "next-themes"
import useUserData from "@lib/hooks/use-user-data"
import Link from "next/link"
import { useRouter } from "next/router"
type Tab = {
name: string
icon: JSX.Element
value: string
onClick?: () => void
href?: string
}
const Header = () => {
const router = useRouter()
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery("xs", { match: "down" })
const { signedIn: isSignedIn } = useSignedIn()
const userData = useUserData()
const [pages, setPages] = useState<Tab[]>([])
const { setTheme, resolvedTheme } = useTheme()
useEffect(() => {
setBodyHidden(expanded)
}, [expanded, setBodyHidden])
useEffect(() => {
if (!isMobile) {
setExpanded(false)
}
}, [isMobile])
useEffect(() => {
const defaultPages: Tab[] = [
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== "undefined")
setTheme(resolvedTheme === "light" ? "dark" : "light")
},
icon: resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
value: "theme"
}
]
if (isSignedIn)
setPages([
{
name: "new",
icon: <NewIcon />,
value: "new",
href: "/new"
},
{
name: "yours",
icon: <YourIcon />,
value: "yours",
href: "/mine"
},
{
name: "settings",
icon: <SettingsIcon />,
value: "settings",
href: "/settings"
},
{
name: "sign out",
icon: <SignOutIcon />,
value: "signout",
href: "/signout"
},
...defaultPages
])
else
setPages([
{
name: "home",
icon: <HomeIcon />,
value: "home",
href: "/"
},
{
name: "Sign in",
icon: <SignInIcon />,
value: "signin",
href: "/signin"
},
{
name: "Sign up",
icon: <SignUpIcon />,
value: "signup",
href: "/signup"
},
...defaultPages
])
if (userData?.role === "admin") {
setPages((pages) => [
...pages,
{
name: "admin",
icon: <SettingsIcon />,
value: "admin",
href: "/admin"
}
])
}
// TODO: investigate deps causing infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isSignedIn, resolvedTheme, userData])
const onTabChange = useCallback(
(tab: string) => {
if (typeof window === "undefined") return
const match = pages.find((page) => page.value === tab)
if (match?.onClick) {
match.onClick()
}
},
[pages]
)
const getButton = useCallback(
(tab: Tab) => {
const activeStyle = router.pathname === tab.href ? styles.active : ""
if (tab.onClick) {
return (
<Button
auto={isMobile ? false : true}
key={tab.value}
icon={tab.icon}
onClick={() => onTabChange(tab.value)}
className={`${styles.tab} ${activeStyle}`}
shadow={false}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else if (tab.href) {
return (
<Link key={tab.value} href={tab.href}>
<a className={styles.tab}>
<Button
className={activeStyle}
auto={isMobile ? false : true}
icon={tab.icon}
shadow={false}
>
{tab.name ? tab.name : undefined}
</Button>
</a>
</Link>
)
}
},
[isMobile, onTabChange, router.pathname]
)
const buttons = useMemo(() => pages.map(getButton), [pages, getButton])
return (
<Page.Header>
<div className={styles.tabs}>
<div className={styles.buttons}>{buttons}</div>
</div>
<div className={styles.controls}>
<Button
effect={false}
auto
type="abort"
onClick={() => setExpanded(!expanded)}
aria-label="Menu"
>
<Spacer height={5 / 6} width={0} />
<MenuIcon />
</Button>
</div>
{/* setExpanded should occur elsewhere; we don't want to close if they change themes */}
{isMobile && expanded && (
<div className={styles.mobile} onClick={() => setExpanded(!expanded)}>
<ButtonGroup
vertical
style={{
background: "var(--bg)"
}}
>
{buttons}
</ButtonGroup>
</div>
)}
</Page.Header>
)
}
export default Header

View file

@ -0,0 +1,3 @@
.textarea {
height: 100%;
}

View file

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

View file

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

View file

@ -0,0 +1,57 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: 100%;
font-size: 1rem;
}
.input {
height: 2.5rem;
border-radius: var(--inline-radius);
background: var(--bg);
color: var(--fg);
border: 1px solid var(--light-gray);
padding: 0 var(--gap-half);
outline: none;
transition: border-color var(--transition);
display: flex;
justify-content: center;
margin: 0;
width: 100%;
}
.input::placeholder {
font-size: 1.5rem;
}
.input:focus {
border-color: var(--input-border-focus);
}
.label {
display: inline-flex;
width: initial;
height: 100%;
align-items: center;
pointer-events: none;
margin: 0;
padding: 0 var(--gap-half);
color: var(--fg);
background-color: var(--light-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);
font-size: inherit;
line-height: 1;
white-space: nowrap;
}
@media screen and (max-width: 768px) {
.wrapper {
margin-bottom: var(--gap);
}
}

View file

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

View file

@ -0,0 +1,26 @@
import { ChangeEvent, memo } from "react"
import { Input } from "@geist-ui/core"
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="A short description of your post"
/>
</div>
)
}
export default memo(Description)

View file

@ -0,0 +1,41 @@
.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-width: 2px;
border-radius: 2px;
border-style: dashed;
outline: none;
transition: all 0.24s ease-in-out;
cursor: pointer;
}
.dropzone:focus {
box-shadow: 0 0 4px 1px rgba(124, 124, 124, 0.5);
}
.error {
color: red;
font-size: 0.8rem;
transition: border 0.24s ease-in-out;
border: 2px solid red;
border-radius: 2px;
padding: 20px;
}
.error ul {
margin: 0;
padding-left: var(--gap-double);
}

View file

@ -1,5 +1,8 @@
import { Text, useMediaQuery, useTheme, useToasts } from "@geist-ui/core"
import { memo } from "react"
import { useDropzone } from "react-dropzone" import { useDropzone } from "react-dropzone"
import styles from "./drag-and-drop.module.css" import styles from "./drag-and-drop.module.css"
import type { Document } from "@lib/types"
import generateUUID from "@lib/generate-uuid" import generateUUID from "@lib/generate-uuid"
import { import {
allowedFileTypes, allowedFileTypes,
@ -7,11 +10,13 @@ import {
allowedFileExtensions allowedFileExtensions
} from "@lib/constants" } from "@lib/constants"
import byteToMB from "@lib/byte-to-mb" import byteToMB from "@lib/byte-to-mb"
import type { Document } from "../new"
import { useToasts } from "@components/toasts"
function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) { function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
const { palette } = useTheme()
const { setToast } = useToasts() const { setToast } = useToasts()
const isMobile = useMediaQuery("xs", {
match: "down"
})
const onDrop = async (acceptedFiles: File[]) => { const onDrop = async (acceptedFiles: File[]) => {
const newDocs = await Promise.all( const newDocs = await Promise.all(
acceptedFiles.map((file) => { acceptedFiles.map((file) => {
@ -19,9 +24,9 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
const reader = new FileReader() const reader = new FileReader()
reader.onabort = () => reader.onabort = () =>
setToast({ message: "File reading was aborted", type: "error" }) setToast({ text: "File reading was aborted", type: "error" })
reader.onerror = () => reader.onerror = () =>
setToast({ message: "File reading failed", type: "error" }) setToast({ text: "File reading failed", type: "error" })
reader.onload = () => { reader.onload = () => {
const content = reader.result as string const content = reader.result as string
resolve({ resolve({
@ -74,27 +79,34 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
{file.name}: {file.name}:
<ul> <ul>
{errors.map((e) => ( {errors.map((e) => (
<li key={e.code}>{e.message}</li> <li key={e.code}>
<Text>{e.message}</Text>
</li>
))} ))}
</ul> </ul>
</li> </li>
)) ))
const verb = isMobile ? "tap" : "click"
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div {...getRootProps()}> <div
{...getRootProps()}
className={styles.dropzone}
style={{
borderColor: palette.accents_3
}}
>
<input {...getInputProps()} /> <input {...getInputProps()} />
{!isDragActive && ( {!isDragActive && (
<p className="cursor-pointer select-none rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground"> <Text p>Drag some files here, or {verb} to select files</Text>
Drag and drop files here, or click to select
</p>
)} )}
{isDragActive && <p>Release to drop the files here</p>} {isDragActive && <Text p>Release to drop the files here</Text>}
</div> </div>
{fileRejections.length > 0 && ( {fileRejections.length > 0 && (
<ul className={styles.error}> <ul className={styles.error}>
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */} {/* <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> <Text h5>There was a problem with one or more of your files.</Text>
{fileRejectionItems} {fileRejectionItems}
</ul> </ul>
)} )}
@ -102,4 +114,4 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
) )
} }
export default FileDropzone export default memo(FileDropzone)

View file

@ -0,0 +1,368 @@
import { Button, useToasts, ButtonDropdown, Input } from "@geist-ui/core"
import { useRouter } from "next/router"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import generateUUID from "@lib/generate-uuid"
import FileDropzone from "./drag-and-drop"
import styles from "./post.module.css"
import Title from "./title"
import Cookies from "js-cookie"
import type {
Post as PostType,
PostVisibility,
Document as DocumentType
} from "@lib/types"
import PasswordModal from "./password-modal"
import EditDocumentList from "@components/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"
const Post = ({
initialPost,
newPostParent
}: {
initialPost?: PostType
newPostParent?: string
}) => {
const { setToast } = useToasts()
const router = useRouter()
const [title, setTitle] = useState<string>()
const [description, setDescription] = useState<string>()
const [expiresAt, setExpiresAt] = useState<Date | null>(null)
const emptyDoc = useMemo(
() => [
{
title: "",
content: "",
id: generateUUID()
}
],
[]
)
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
// the /new/from/{id} route fetches an initial post
useEffect(() => {
if (initialPost) {
setDocs(
initialPost.files?.map((doc) => ({
title: doc.title,
content: doc.content,
id: doc.id
})) || emptyDoc
)
setTitle(getTitleForPostCopy(initialPost.title))
setDescription(initialPost.description)
}
}, [emptyDoc, initialPost])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(
async (
url: string,
data: {
expiresAt: Date | null
visibility?: PostVisibility
title?: string
files?: DocumentType[]
password?: string
userId: string
parentId?: string
}
) => {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
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()
setToast({
text: json.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: PostVisibility, password?: string) => {
if (visibility === "protected" && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false)
setSubmitting(true)
let hasErrored = false
if (!title) {
setToast({
text: "Please fill out the post title",
type: "error"
})
hasErrored = true
}
if (!docs.length) {
setToast({
text: "Please add at least one document",
type: "error"
})
hasErrored = true
}
for (const doc of docs) {
if (!doc.title) {
setToast({
text: "Please fill out all the document titles",
type: "error"
})
hasErrored = true
}
}
if (hasErrored) {
setSubmitting(false)
return
}
await sendRequest("/server-api/posts/create", {
title,
files: docs,
visibility,
password,
userId: Cookies.get("drift-userid") || "",
expiresAt,
parentId: newPostParent
})
},
[docs, expiresAt, newPostParent, sendRequest, setToast, title]
)
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = useCallback(
(password: string) => onSubmit("protected", password),
[onSubmit]
)
const onChangeExpiration = useCallback((date: Date) => setExpiresAt(date), [])
const onChangeTitle = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
},
[setTitle]
)
const onChangeDescription = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setDescription(e.target.value)
},
[setDescription]
)
const updateDocTitle = useCallback(
(i: number) => (title: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, title } : doc))
)
},
[setDocs]
)
const updateDocContent = useCallback(
(i: number) => (content: string) => {
setDocs((docs) =>
docs.map((doc, index) => (i === index ? { ...doc, content } : doc))
)
},
[setDocs]
)
const removeDoc = useCallback(
(i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
},
[setDocs]
)
const uploadDocs = useCallback(
(files: DocumentType[]) => {
// if no title is set and the only document is empty,
const isFirstDocEmpty =
docs.length <= 1 && (docs.length ? docs[0].title === "" : true)
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle("Uploaded files")
}
}
if (isFirstDocEmpty) setDocs(files)
else setDocs((docs) => [...docs, ...files])
},
[docs, title]
)
// pasted files
// const files = e.clipboardData.files as File[]
// if (files.length) {
// const docs = Array.from(files).map((file) => ({
// title: file.name,
// content: '',
// id: generateUUID()
// }))
// }
const onPaste = useCallback(
(e: any) => {
const pastedText = e.clipboardData.getData("text")
if (pastedText) {
if (!title) {
setTitle("Pasted text")
}
}
},
[title]
)
const CustomTimeInput = ({
date,
value,
onChange
}: {
date: Date
value: string
onChange: (date: string) => void
}) => (
<input
type="time"
value={value}
onChange={(e) => {
if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16))
}
}}
style={{
backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)"
}}
required
/>
)
return (
<div style={{ paddingBottom: 150 }}>
<Title title={title} onChange={onChangeTitle} />
<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"
>
Add a File
</Button>
<div className={styles.rightButtons}>
{
<DatePicker
onChange={onChangeExpiration}
customInput={
<Input
label="Expires at"
clearable
width="100%"
height="40px"
/>
}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-ignore
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
dateFormat="MM/dd/yyyy h:mm aa"
className={styles.datePicker}
clearButtonTitle={"Clear"}
// TODO: investigate why this causes margin shift if true
enableTabLoop={false}
minDate={new Date()}
/>
}
<ButtonDropdown loading={isSubmitting} type="success">
<ButtonDropdown.Item main onClick={() => onSubmit("private")}>
Create Private
</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit("public")}>
Create Public
</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit("unlisted")}>
Create Unlisted
</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit("protected")}>
Create with Password
</ButtonDropdown.Item>
</ButtonDropdown>
</div>
</div>
<PasswordModal
creating={true}
isOpen={passwordModalVisible}
onClose={onClosePasswordModal}
onSubmit={submitPassword}
/>
</div>
)
}
export default Post

View file

@ -0,0 +1,83 @@
import { Modal, Note, Spacer, Input } from "@geist-ui/core"
import { useState } from "react"
type Props = {
creating: boolean
isOpen: boolean
onClose: () => void
onSubmit: (password: string) => void
}
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 (
<>
{/* TODO: investigate disableBackdropClick not updating state? */}
{
<Modal visible={isOpen} disableBackdropClick={false}>
<Modal.Title>Enter a password</Modal.Title>
<Modal.Content>
{!error && creating && (
<Note type="warning" label="Warning">
This doesn&apos;t protect your post from the server
administrator.
</Note>
)}
{error && (
<Note type="error" label="Error">
{error}
</Note>
)}
<Spacer />
<Input
width={"100%"}
label="Password"
marginBottom={1}
htmlType="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
{creating && (
<Input
width={"100%"}
label="Confirm"
htmlType="password"
placeholder="Confirm Password"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
)}
</Modal.Content>
<Modal.Action passive onClick={onClose}>
Cancel
</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>
}
</>
)
}
export default PasswordModal

View file

@ -1,15 +1,9 @@
.root {
padding-bottom: 200px;
display: flex;
flex-direction: column;
gap: var(--gap-half);
}
.buttons { .buttons {
position: relative; position: relative;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
margin-top: var(--gap-double);
gap: var(--gap); gap: var(--gap);
} }
@ -19,12 +13,30 @@
align-items: center; align-items: center;
} }
.datePicker {
flex: 1;
}
.title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: var(--gap);
}
.description { .description {
width: 100%; width: 100%;
margin-bottom: var(--gap);
} }
@media screen and (max-width: 650px) { @media screen and (max-width: 650px) {
.title {
align-items: flex-start;
flex-direction: column;
}
.buttons { .buttons {
flex-direction: column; flex-direction: column;
margin: 0; margin: 0;
@ -34,7 +46,7 @@
.buttons .rightButtons { .buttons .rightButtons {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-end;
} }
.buttons .rightButtons > * { .buttons .rightButtons > * {

View file

@ -0,0 +1,51 @@
import { ChangeEvent, memo, useEffect, useState } from "react"
import { Text } from "@geist-ui/core"
import ShiftBy from "@components/shift-by"
import styles from "../post.module.css"
import { Input } from "@geist-ui/core"
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 ..."
]
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string
}
const Title = ({ onChange, title }: props) => {
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
useEffect(() => {
// set random placeholder on load
setPlaceholder(
titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]
)
}, [])
return (
<div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>
Drift
</Text>
<ShiftBy y={-3}>
<Input
placeholder={placeholder}
value={title || ""}
onChange={onChange}
height={"55px"}
font={1.5}
label="Post title"
style={{ width: "100%" }}
/>
</ShiftBy>
</div>
)
}
export default memo(Title)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
.title {
display: flex;
justify-content: space-between;
}
.badges {
display: flex;
gap: var(--gap-half);
}
.buttons {
display: flex;
gap: var(--gap-half);
}
.oneline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media screen and (max-width: 700px) {
.badges {
flex-direction: column;
align-items: flex-start;
}
.badges > * {
width: min-content;
}
.title {
flex-direction: column;
gap: var(--gap);
}
}

View file

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

View file

@ -1,22 +1,17 @@
.container { .container ul {
ul { list-style: none;
list-style: none; padding: 0;
padding: 0; margin: 0;
margin: 0; }
margin-top: 0.5rem;
@media (max-width: 768px) { .container ul li {
padding: 0 var(--gap); padding: 0.5rem 0;
} }
}
ul li { .container ul li::before {
padding: 0.5rem 0; content: "";
} padding: 0;
margin: 0;
> * {
width: 100%;
}
} }
.postHeader { .postHeader {

View file

@ -0,0 +1,178 @@
import PageSeo from "@components/page-seo"
import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from "@components/view-document"
import styles from "./post-page.module.css"
import homeStyles from "@styles/Home.module.css"
import type { File, Post, PostVisibility } from "@lib/types"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react"
import Archive from "@geist-ui/icons/archive"
import Edit from "@geist-ui/icons/edit"
import Parent from "@geist-ui/icons/arrowUpCircle"
import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/router"
import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Cookies from "js-cookie"
import PasswordModalPage from "./password-modal-wrapper"
import VisibilityControl from "@components/badges/visibility-control"
type Props = {
post: Post
isProtected?: boolean
}
const PostPage = ({ post: initialPost, isProtected }: Props) => {
const [post, setPost] = useState<Post>(initialPost)
const [visibility, setVisibility] = useState<PostVisibility>(post.visibility)
const [isExpired, setIsExpired] = useState(
post.expiresAt ? new Date(post.expiresAt) < new Date() : null
)
const [isLoading, setIsLoading] = useState(true)
const [isOwner] = useState(
post.users ? post.users[0].id === Cookies.get("drift-userid") : false
)
const router = useRouter()
const isMobile = useMediaQuery("mobile")
useEffect(() => {
if (!isOwner && isExpired) {
router.push("/expired")
}
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (!isOwner && expirationDate < new Date()) {
router.push("/expired")
} else {
setIsLoading(false)
}
let interval: NodeJS.Timer | null = null
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
setIsExpired(expirationDate < new Date())
}, 4000)
}
return () => {
if (interval) clearInterval(interval)
}
}, [isExpired, isOwner, post.expiresAt, post.users, router])
const download = async () => {
if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(
post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})
).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
const viewParentClick = () => {
router.push(`/post/${post.parent!.id}`)
}
if (isLoading) {
return <></>
}
const isAvailable = !isExpired && !isProtected && post.title
return (
<Page width={"100%"}>
<PageSeo
title={`${post.title} - Drift`}
description={post.description}
isPrivate={false}
/>
{!isAvailable && <PasswordModalPage setPost={setPost} />}
<Page.Content className={homeStyles.main}>
<div className={styles.header}>
<span className={styles.buttons}>
<ButtonGroup
vertical={isMobile}
marginLeft={0}
marginRight={0}
marginTop={1}
marginBottom={1}
>
<Button
auto
icon={<Edit />}
onClick={editACopy}
style={{ textTransform: "none" }}
>
Edit a Copy
</Button>
{post.parent && (
<Button auto icon={<Parent />} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button
auto
onClick={download}
icon={<Archive />}
style={{ textTransform: "none" }}
>
Download as ZIP Archive
</Button>
<FileDropdown isMobile={isMobile} files={post.files || []} />
</ButtonGroup>
</span>
<span className={styles.title}>
<Text h3>{post.title}</Text>
<span className={styles.badges}>
<VisibilityBadge visibility={visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
</span>
</div>
{post.description && (
<div>
<Text p>{post.description}</Text>
</div>
)}
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={"preview"}
id={id}
content={content}
/>
))}
{isOwner && (
<span className={styles.controls}>
<VisibilityControl
postId={post.id}
visibility={visibility}
setVisibility={setVisibility}
/>
</span>
)}
<ScrollToTop />
</Page.Content>
</Page>
)
}
export default PostPage

View file

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

View file

@ -0,0 +1,56 @@
.header .title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.header .title .badges {
display: flex;
gap: var(--gap-half);
}
.header .title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
.header .buttons {
display: flex;
justify-content: flex-end;
margin-bottom: var(--gap);
}
.controls {
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 900px) {
.header {
flex-direction: column;
gap: var(--gap);
}
}
@media screen and (max-width: 700px) {
.header .title {
flex-direction: column;
gap: var(--gap-half);
}
.header .title .badges {
flex-direction: column;
align-items: center;
}
.header .title .badges > * {
width: min-content;
}
.header .buttons {
display: flex;
justify-content: center;
}
}

View file

@ -0,0 +1,67 @@
import Cookies from "js-cookie"
import { memo, useEffect, useState } from "react"
import styles from "./preview.module.css"
type Props = {
height?: number | string
fileId?: string
content?: string
title?: string
// file extensions we can highlight
}
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [preview, setPreview] = useState<string>(content || "")
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
async function fetchPost() {
if (fileId) {
const resp = await fetch(`/api/html/${fileId}`, {
method: "GET"
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
} else if (content) {
const resp = await fetch("/server-api/files/html", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token") || ""}`
},
body: JSON.stringify({
title,
content
})
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
}
setIsLoading(false)
}
fetchPost()
}, [content, fileId, title])
return (
<>
{isLoading ? (
<div>Loading...</div>
) : (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}}
/>
)}
</>
)
}
export default memo(MarkdownPreview)

View file

@ -1,63 +1,3 @@
.markdownPreview {
padding: var(--gap-quarter);
font-size: 16px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
font-size: 16px;
line-height: 1.75;
}
.markdownPreview {
padding: var(--gap-quarter);
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
}
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
font-weight: 600;
}
.markdownPreview h1 {
font-size: 1.775rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1.125rem;
}
.markdownPreview h5 {
font-size: 1.1rem;
}
.markdownPreview p {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
}
.markdownPreview pre { .markdownPreview pre {
border-radius: 3px; border-radius: 3px;
font-family: "Courier New", Courier, monospace; font-family: "Courier New", Courier, monospace;
@ -69,6 +9,35 @@
word-wrap: break-word; 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 */ /* Auto-linked headers */
.markdownPreview h1 a, .markdownPreview h1 a,
.markdownPreview h2 a, .markdownPreview h2 a,
@ -93,49 +62,50 @@
filter: opacity(0.5); filter: opacity(0.5);
} }
.markdownPreview ul { .markdownPreview h1 {
margin-bottom: 1.5rem; font-size: 2rem;
margin-left: 1.5rem;
list-style-type: disc;
} }
.markdownPreview ol { .markdownPreview h2 {
margin-bottom: 1.5rem; font-size: 1.5rem;
margin-left: 1.5rem; }
list-style-type: decimal;
.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 { .markdownPreview ul li::before {
content: ""; content: "";
} }
.markdownPreview code {
border-radius: 3px;
white-space: pre-wrap;
word-wrap: break-word;
color: inherit !important;
}
.markdownPreview code::before, .markdownPreview code::before,
.markdownPreview code::after { .markdownPreview code::after {
content: ""; content: "";
} }
.markdownPreview blockquote {
padding-left: 1.5rem;
font-style: italic;
border-left-width: 2px;
}
.markdownPreview blockquote p {
margin-top: 0;
}
.markdownPreview table {
overflow-y: auto;
width: 100%;
}
.markdownPreview table th {
font-weight: 600;
padding: 0;
margin: 0;
border-top-width: 1px;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.markdownPreview h1 a::after, .markdownPreview h1 a::after,
.markdownPreview h2 a::after, .markdownPreview h2 a::after,

View file

@ -1,19 +1,10 @@
"use client" import { Tooltip, Button, Spacer } from "@geist-ui/core"
import ChevronUp from "@geist-ui/icons/chevronUpCircleFill"
import { Button } from "@components/button"
import { Tooltip } from "@components/tooltip"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ChevronUp } from "react-feather"
import styles from "./scroll.module.css" import styles from "./scroll.module.css"
const ScrollToTop = () => { const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false) const [shouldShow, setShouldShow] = useState(false)
const isReducedMotion =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
useEffect(() => { useEffect(() => {
// if user is scrolled, set visible // if user is scrolled, set visible
const handleScroll = () => { const handleScroll = () => {
@ -23,15 +14,29 @@ const ScrollToTop = () => {
return () => window.removeEventListener("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>) => { const onClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.blur() e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" }) window.scrollTo({ top: 0, behavior: isReducedMotion ? "auto" : "smooth" })
} }
return ( return (
<div className={styles.root}> <div
style={{
display: "flex",
flexDirection: "row",
width: "100%",
height: 24,
justifyContent: "flex-end"
}}
>
<Tooltip <Tooltip
content="Scroll to Top" hideArrow
text="Scroll to Top"
className={`${styles["scroll-up"]} ${ className={`${styles["scroll-up"]} ${
shouldShow ? styles["scroll-up-shown"] : "" shouldShow ? styles["scroll-up-shown"] : ""
}`} }`}
@ -39,8 +44,10 @@ const ScrollToTop = () => {
<Button <Button
aria-label="Scroll to Top" aria-label="Scroll to Top"
onClick={onClick} onClick={onClick}
variant={"secondary"} style={{ background: "var(--light-gray)" }}
auto
> >
<Spacer height={2 / 3} inline width={0} />
<ChevronUp /> <ChevronUp />
</Button> </Button>
</Tooltip> </Tooltip>

View file

@ -1,10 +1,3 @@
.root {
display: flex;
flex-direction: row;
width: 100%;
justify-content: flex-end;
}
.scroll-up { .scroll-up {
position: fixed; position: fixed;
z-index: 2; z-index: 2;

View file

@ -0,0 +1,21 @@
import { Fieldset, Text, Divider } from "@geist-ui/core"
import styles from "./settings-group.module.css"
type Props = {
title: string
children: React.ReactNode | React.ReactNode[]
}
const SettingsGroup = ({ title, children }: Props) => {
return (
<Fieldset>
<Fieldset.Content>
<Text h4>{title}</Text>
</Fieldset.Content>
<Divider />
<Fieldset.Content className={styles.content}>{children}</Fieldset.Content>
</Fieldset>
)
}
export default SettingsGroup

View file

@ -0,0 +1,26 @@
import Password from "./sections/password"
import Profile from "./sections/profile"
import SettingsGroup from "../settings-group"
const SettingsPage = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
marginBottom: "var(--gap)"
}}
>
<h1>Settings</h1>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="Password">
<Password />
</SettingsGroup>
</div>
)
}
export default SettingsPage

View file

@ -0,0 +1,134 @@
import { Input, Button, useToasts } from "@geist-ui/core"
import Cookies from "js-cookie"
import { useState } from "react"
const Password = () => {
const [password, setPassword] = useState<string>("")
const [newPassword, setNewPassword] = useState<string>("")
const [confirmPassword, setConfirmPassword] = useState<string>("")
const { setToast } = useToasts()
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value)
}
const handleNewPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewPassword(e.target.value)
}
const handleConfirmPasswordChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
setConfirmPassword(e.target.value)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!password || !newPassword || !confirmPassword) {
setToast({
text: "Please fill out all fields",
type: "error"
})
}
if (newPassword !== confirmPassword) {
setToast({
text: "New password and confirm password do not match",
type: "error"
})
}
const res = await fetch("/server-api/auth/change-password", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
body: JSON.stringify({
oldPassword: password,
newPassword
})
})
if (res.status === 200) {
setToast({
text: "Password updated successfully",
type: "success"
})
setPassword("")
setNewPassword("")
setConfirmPassword("")
} else {
const data = await res.json()
setToast({
text: data.error ?? "Failed to update password",
type: "error"
})
}
}
return (
<form
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
maxWidth: "300px"
}}
onSubmit={onSubmit}
>
<div>
<label htmlFor="current-password">Current password</label>
<Input
onChange={handlePasswordChange}
minLength={6}
maxLength={128}
value={password}
id="current-password"
htmlType="password"
required
autoComplete="current-password"
placeholder="Current Password"
width={"100%"}
/>
</div>
<div>
<label htmlFor="new-password">New password</label>
<Input
onChange={handleNewPasswordChange}
minLength={6}
maxLength={128}
value={newPassword}
id="new-password"
htmlType="password"
required
autoComplete="new-password"
placeholder="New Password"
width={"100%"}
/>
</div>
<div>
<label htmlFor="confirm-password">Confirm password</label>
<Input
onChange={handleConfirmPasswordChange}
minLength={6}
maxLength={128}
value={confirmPassword}
id="confirm-password"
htmlType="password"
required
autoComplete="confirm-password"
placeholder="Confirm Password"
width={"100%"}
/>
</div>
<Button htmlType="submit" auto>
Change Password
</Button>
</form>
)
}
export default Password

View file

@ -0,0 +1,125 @@
import { Note, Input, Textarea, Button, useToasts } from "@geist-ui/core"
import useUserData from "@lib/hooks/use-user-data"
import Cookies from "js-cookie"
import { useEffect, useState } from "react"
const Profile = () => {
const user = useUserData()
const [name, setName] = useState<string>()
const [email, setEmail] = useState<string>()
const [bio, setBio] = useState<string>()
useEffect(() => {
console.log(user)
if (user?.displayName) setName(user.displayName)
if (user?.email) setEmail(user.email)
if (user?.bio) setBio(user.bio)
}, [user])
const { setToast } = useToasts()
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBio(e.target.value)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!name && !email && !bio) {
setToast({
text: "Please fill out at least one field",
type: "error"
})
return
}
const data = {
displayName: name,
email,
bio
}
const res = await fetch("/server-api/user/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("drift-token")}`
},
body: JSON.stringify(data)
})
if (res.status === 200) {
setToast({
text: "Profile updated",
type: "success"
})
} else {
setToast({
text: "Something went wrong updating your profile",
type: "error"
})
}
}
return (
<>
<Note type="warning" marginBottom={"var(--gap)"}>
This information will be publicly available on your profile
</Note>
<form
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
maxWidth: "300px"
}}
onSubmit={onSubmit}
>
<div>
<label htmlFor="displayName">Display name</label>
<Input
id="displayName"
width={"100%"}
placeholder="my name"
value={name || ""}
onChange={handleNameChange}
/>
</div>
<div>
<label htmlFor="email">Email</label>
<Input
id="email"
htmlType="email"
width={"100%"}
placeholder="my@email.io"
value={email || ""}
onChange={handleEmailChange}
/>
</div>
<div>
<label htmlFor="bio">Biography (max 250 characters)</label>
<Textarea
id="bio"
width="100%"
maxLength={250}
placeholder="I enjoy..."
value={bio || ""}
onChange={handleBioChange}
/>
</div>
<Button htmlType="submit" auto>
Submit
</Button>
</form>
</>
)
}
export default Profile

View file

@ -0,0 +1,20 @@
// https://www.joshwcomeau.com/snippets/react-components/shift-by/
type Props = {
x?: number
y?: number
children: React.ReactNode
}
function ShiftBy({ x = 0, y = 0, children }: Props) {
return (
<div
style={{
transform: `translate(${x}px, ${y}px)`,
display: "inline-block"
}}
>
{children}
</div>
)
}
export default ShiftBy

View file

@ -0,0 +1,49 @@
.card {
margin: var(--gap) auto;
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;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}
.textarea {
height: 100%;
}

View file

@ -0,0 +1,169 @@
import { memo, useRef, useState } from "react"
import styles from "./document.module.css"
import Download from "@geist-ui/icons/download"
import ExternalLink from "@geist-ui/icons/externalLink"
import Skeleton from "react-loading-skeleton"
import {
Button,
Text,
ButtonGroup,
Spacer,
Tabs,
Textarea,
Tooltip,
Link,
Tag
} from "@geist-ui/core"
import HtmlPreview from "@components/preview"
import FadeIn from "@components/fade-in"
// import Link from "next/link"
type Props = {
title: string
initialTab?: "edit" | "preview"
skeleton?: boolean
id: string
content: string
}
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
<Tooltip hideArrow text="Download">
<a
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button
scale={2 / 3}
px={0.6}
icon={<Download />}
auto
aria-label="Download"
/>
</a>
</Tooltip>
<Tooltip hideArrow text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3}
px={0.6}
icon={<ExternalLink />}
auto
aria-label="Open raw file in new tab"
/>
</a>
</Tooltip>
</ButtonGroup>
</div>
)
}
const Document = ({
content,
title,
initialTab = "edit",
skeleton,
id
}: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%"
const handleTabChange = (newTab: string) => {
if (newTab === "edit") {
codeEditorRef.current?.focus()
}
setTab(newTab as "edit" | "preview")
}
const rawLink = () => {
if (id) {
return `/file/raw/${id}`
}
}
if (skeleton) {
return (
<>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
</div>
<div className={styles.descriptionContainer}>
<div style={{ flexDirection: "row", display: "flex" }}>
<Skeleton width={125} height={36} />
</div>
<Skeleton width={"100%"} height={350} />
</div>
</div>
</>
)
}
return (
<FadeIn>
<Spacer height={1} />
<div className={styles.card}>
<Link href={`#${title}`} className={styles.fileNameContainer}>
<Tag
height={"100%"}
id={`${title}`}
width={"100%"}
style={{ borderRadius: 0 }}
>
{title || "Untitled"}
</Tag>
</Link>
<div className={styles.descriptionContainer}>
<DownloadButton rawLink={rawLink()} />
<Tabs
onChange={handleTabChange}
initialValue={initialTab}
hideDivider
leftSpace={0}
>
<Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div
style={{
marginTop: "var(--gap-half)",
display: "flex",
flexDirection: "column"
}}
>
<Textarea
readOnly
ref={codeEditorRef}
value={content}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: "var(--gap-half)" }}>
<HtmlPreview
height={height}
fileId={id}
content={content}
title={title}
/>
</div>
</Tabs.Item>
</Tabs>
</div>
</div>
</FadeIn>
)
}
export default memo(Document)

4
client/lib/byte-to-mb.ts Normal file
View file

@ -0,0 +1,4 @@
const byteToMB = (bytes: number) =>
Math.round((bytes / 1024 / 1024) * 100) / 100
export default byteToMB

View file

@ -1,17 +1,3 @@
import { ServerPost } from "./server/prisma"
// Visibilties for the webpages feature
export const ALLOWED_VISIBILITIES_FOR_WEBPAGE = ["public", "unlisted"]
export function isAllowedVisibilityForWebpage(
visibility: ServerPost["visibility"]
) {
return ALLOWED_VISIBILITIES_FOR_WEBPAGE.includes(visibility)
}
export const DEFAULT_THEME = "dark"
export const SIGNED_IN_COOKIE = "next-auth.session-token"
// Code files for uploading with drag and drop and syntax highlighting
export const allowedFileTypes = [ export const allowedFileTypes = [
"application/json", "application/json",
"application/x-javascript", "application/x-javascript",
@ -84,8 +70,6 @@ export const codeFileExtensions = [
"cxx", "cxx",
"go", "go",
"h", "h",
"m",
"ha",
"hpp", "hpp",
"htm", "htm",
"html", "html",
@ -109,7 +93,6 @@ export const codeFileExtensions = [
"rb", "rb",
"rs", "rs",
"s", "s",
"sh",
"sass", "sass",
"scala", "scala",
"scss", "scss",
@ -126,7 +109,7 @@ export const codeFileExtensions = [
"xml", "xml",
"y", "y",
"yaml", "yaml",
"fish" "zig"
] ]
export const allowedFileExtensions = [ export const allowedFileExtensions = [

View file

@ -14,9 +14,7 @@ const replaceLastInString = (
) )
} }
const getTitleForPostCopy = (title?: string) => { const getTitleForPostCopy = (title: string) => {
if (!title) return ""
const numberAtEndOfTitle = title.split(" ").pop() const numberAtEndOfTitle = title.split(" ").pop()
if (numberAtEndOfTitle) { if (numberAtEndOfTitle) {
const number = parseInt(numberAtEndOfTitle) const number = parseInt(numberAtEndOfTitle)

View file

@ -0,0 +1,11 @@
import useSWR from "swr"
// https://2020.paco.me/blog/shared-hook-state-with-swr
const useSharedState = <T>(key: string, initial?: T) => {
const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial
})
return [state, setState] as const
}
export default useSharedState

View file

@ -0,0 +1,51 @@
import Cookies from "js-cookie"
import { useEffect } from "react"
import useSharedState from "./use-shared-state"
const useSignedIn = () => {
const [signedIn, setSignedIn] = useSharedState(
"signedIn",
typeof window === "undefined" ? false : !!Cookies.get("drift-token")
)
const token = Cookies.get("drift-token")
const signin = (token: string) => {
setSignedIn(true)
// TODO: investigate SameSite / CORS cookie security
Cookies.set("drift-token", token)
}
useEffect(() => {
const attemptSignIn = async () => {
// If header auth is enabled, the reverse proxy will add it between this fetch and the server.
// Otherwise, the token will be used.
const res = await fetch("/server-api/auth/verify-signed-in", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
})
if (res.status !== 200) {
setSignedIn(false)
return
}
}
attemptSignIn()
}, [setSignedIn, token])
useEffect(() => {
if (token) {
setSignedIn(true)
} else {
setSignedIn(false)
}
}, [setSignedIn, token])
return { signedIn, signin, token }
}
export default useSignedIn

View file

@ -1,6 +1,6 @@
import { useRef, useEffect } from "react" import { useRef, useEffect } from "react"
function useTraceUpdate(props: { [key: string]: unknown }) { function useTraceUpdate(props: { [key: string]: any }) {
const prev = useRef(props) const prev = useRef(props)
useEffect(() => { useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => { const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
@ -8,7 +8,7 @@ function useTraceUpdate(props: { [key: string]: unknown }) {
ps[k] = [prev.current[k], v] ps[k] = [prev.current[k], v]
} }
return ps return ps
}, {} as { [key: string]: unknown }) }, {} as { [key: string]: any })
if (Object.keys(changedProps).length > 0) { if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps) console.log("Changed props:", changedProps)
} }

View file

@ -0,0 +1,43 @@
import { User } from "@lib/types"
import Cookies from "js-cookie"
import { useRouter } from "next/router"
import { useEffect, useMemo, useState } from "react"
const useUserData = () => {
const [authToken, setAuthToken] = useState<string>(
Cookies.get("drift-token") || ""
)
const [user, setUser] = useState<User>()
const router = useRouter()
useEffect(() => {
const token = Cookies.get("drift-token")
if (token) {
setAuthToken(token)
}
}, [setAuthToken])
useEffect(() => {
if (authToken) {
const fetchUser = async () => {
const response = await fetch(`/server-api/user/self`, {
headers: {
Authorization: `Bearer ${authToken}`
}
})
if (response.ok) {
const user = await response.json()
setUser(user)
} else {
Cookies.remove("drift-token")
setAuthToken("")
router.push("/")
}
}
fetchUser()
}
}, [authToken, router])
return user
}
export default useUserData

View file

@ -10,8 +10,9 @@ const epochs = [
["second", 1] ["second", 1]
] as const ] as const
// Get duration
const getDuration = (timeAgoInSeconds: number) => { const getDuration = (timeAgoInSeconds: number) => {
for (const [name, seconds] of epochs) { for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds) const interval = Math.floor(timeAgoInSeconds / seconds)
if (interval >= 1) { if (interval >= 1) {

40
client/lib/types.d.ts vendored Normal file
View file

@ -0,0 +1,40 @@
export type PostVisibility = "unlisted" | "private" | "public" | "protected"
export type Document = {
title: string
content: string
id: string
}
export type File = {
id: string
title: string
content: string
html: string
createdAt: string
}
type Files = File[]
export type Post = {
id: string
title: string
description: string
visibility: PostVisibility
files?: Files
createdAt: string
users?: User[]
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
expiresAt: Date | string | null
}
type User = {
id: string
username: string
posts?: Post[]
role: "admin" | "user" | ""
createdAt: string
displayName?: string
bio?: string
email?: string
}

View file

@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

40
client/next.config.mjs Normal file
View file

@ -0,0 +1,40 @@
import dotenv from "dotenv"
import bundleAnalyzer from "@next/bundle-analyzer"
dotenv.config()
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
outputStandalone: true,
esmExternals: true
},
webpack: (config, { dev, isServer }) => {
if (!dev && !isServer) {
// TODO: enabling Preact causes the file switcher to hang the browser process
// Object.assign(config.resolve.alias, {
// react: "preact/compat",
// "react-dom/test-utils": "preact/test-utils",
// "react-dom": "preact/compat"
// })
}
return config
},
async rewrites() {
return [
{
source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`
},
{
source: "/file/raw/:id",
destination: `/api/raw/:id`
}
]
}
}
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
nextConfig
)

58
client/package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "drift",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint && prettier --list-different --config .prettierrc '{components,lib,pages}/**/*.{ts,tsx}' --write",
"analyze": "cross-env ANALYZE=true next build",
"find:unused": "next-unused"
},
"dependencies": {
"@geist-ui/core": "2.3.8",
"@geist-ui/icons": "1.0.1",
"@types/cookie": "0.5.0",
"@types/js-cookie": "3.0.1",
"client-zip": "2.1.0",
"cookie": "0.5.0",
"dotenv": "16.0.0",
"js-cookie": "3.0.1",
"next": "12.1.5",
"next-themes": "0.1.1",
"rc-table": "7.24.1",
"react": "18.0.0",
"react-datepicker": "4.7.0",
"react-dom": "18.0.0",
"react-dropzone": "12.0.5",
"react-loading-skeleton": "3.1.0",
"swr": "1.3.0",
"textarea-markdown-editor": "0.1.13"
},
"devDependencies": {
"@next/bundle-analyzer": "12.1.5",
"@types/node": "17.0.23",
"@types/react": "18.0.5",
"@types/react-datepicker": "4.4.0",
"@types/react-dom": "18.0.1",
"cross-env": "7.0.3",
"eslint": "8.13.0",
"eslint-config-next": "12.1.5",
"next-unused": "0.0.6",
"prettier": "2.6.2",
"typescript": "4.6.3",
"typescript-plugin-css-modules": "3.4.0"
},
"next-unused": {
"alias": {
"@components": "components/",
"@lib": "lib/",
"@styles": "styles/"
},
"include": [
"components",
"lib"
]
}
}

61
client/pages/_app.tsx Normal file
View file

@ -0,0 +1,61 @@
import "@styles/globals.css"
import type { AppProps as NextAppProps } from "next/app"
import "react-loading-skeleton/dist/skeleton.css"
import Head from "next/head"
import { ThemeProvider } from "next-themes"
import App from "@components/app"
import React from "react"
type AppProps<P = any> = {
pageProps: P
} & Omit<NextAppProps<P>, "pageProps">
function MyApp({ Component, pageProps }: AppProps) {
return (
<div>
<Head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/assets/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link
rel="mask-icon"
href="/assets/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="apple-mobile-web-app-title" content="Drift" />
<meta name="application-name" content="Drift" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<title>Drift</title>
</Head>
<React.StrictMode>
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
<App Component={Component} pageProps={pageProps} />
</ThemeProvider>
</React.StrictMode>
</div>
)
}
export default MyApp

View file

@ -0,0 +1,39 @@
import { CssBaseline } from "@geist-ui/core"
import Document, {
Html,
Head,
Main,
NextScript,
DocumentContext
} from "next/document"
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx)
const styles = CssBaseline.flush()
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{styles}
</> // TODO: Investigate typescript
) as any
}
}
render() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument

12
client/pages/_error.tsx Normal file
View file

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

View file

@ -0,0 +1,63 @@
import { NextFetchEvent, NextRequest, NextResponse } from "next/server"
const PUBLIC_FILE = /\.(.*)$/
export function middleware(req: NextRequest, event: NextFetchEvent) {
const pathname = req.nextUrl.pathname
const signedIn = req.cookies["drift-token"]
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
const isPageRequest =
!PUBLIC_FILE.test(pathname) &&
!pathname.startsWith("/api") &&
// header added when next/link pre-fetches a route
!req.headers.get("x-middleware-preflight")
if (!req.headers.get("x-middleware-preflight") && pathname === "/signout") {
// If you're signed in we remove the cookie and redirect to the home page
// If you're not signed in we redirect to the home page
if (signedIn) {
const resp = NextResponse.redirect(getURL(""))
resp.clearCookie("drift-token")
resp.clearCookie("drift-userid")
const signoutPromise = new Promise((resolve) => {
fetch(`${process.env.API_URL}/auth/signout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${signedIn}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
}).then(() => {
resolve(true)
})
})
event.waitUntil(signoutPromise)
return resp
}
} else if (isPageRequest) {
if (signedIn) {
if (
pathname === "/" ||
pathname === "/signin" ||
pathname === "/signup"
) {
return NextResponse.redirect(getURL("new"))
}
} else if (!signedIn) {
if (pathname.startsWith("/new")) {
return NextResponse.redirect(getURL("signin"))
}
}
if (pathname.includes("/protected/") || pathname.includes("/private/")) {
const urlWithoutVisibility = pathname
.replace("/protected/", "/")
.replace("/private/", "/")
.substring(1)
return NextResponse.redirect(getURL(urlWithoutVisibility))
}
}
return NextResponse.next()
}

55
client/pages/admin.tsx Normal file
View file

@ -0,0 +1,55 @@
import styles from "@styles/Home.module.css"
import Header from "@components/header"
import { Page } from "@geist-ui/core"
import { useEffect } from "react"
import Admin from "@components/admin"
import useSignedIn from "@lib/hooks/use-signed-in"
import { useRouter } from "next/router"
import { GetServerSideProps } from "next"
import cookie from "cookie"
const AdminPage = () => {
const { signedIn } = useSignedIn()
const router = useRouter()
useEffect(() => {
if (typeof window === "undefined") return
if (!signedIn) {
router.push("/")
}
}, [router, signedIn])
return (
<Page className={styles.wrapper}>
<Page.Content className={styles.main}>
<Admin />
</Page.Content>
</Page>
)
}
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
const res = await fetch(`${process.env.API_URL}/admin/is-admin`, {
headers: {
Authorization: `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
})
if (res.ok) {
return {
props: {
signedIn: true
}
}
} else {
return {
redirect: {
destination: "/",
permanent: false
}
}
}
}
export default AdminPage

View file

@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const file = await fetch(`${process.env.API_URL}/files/html/${id}`, {
headers: {
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
if (file.ok) {
const json = await file.text()
const data = json
// serve the file raw as plain text
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400")
res.status(200).write(data, "utf-8")
res.end()
} else {
res.status(404).send("File not found")
}
}
export default getRawFile

View file

@ -0,0 +1,34 @@
import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
headers: {
Accept: "text/plain",
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400")
if (file.ok) {
const json = await file.json()
const data = json
const { title, content } = data
// serve the file raw as plain text
if (download) {
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
} else {
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
}
res.status(200).write(content, "utf-8")
res.end()
} else {
res.status(404).send("File not found")
}
}
export default getRawFile

19
client/pages/expired.tsx Normal file
View file

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

74
client/pages/index.tsx Normal file
View file

@ -0,0 +1,74 @@
import styles from "@styles/Home.module.css"
import PageSeo from "@components/page-seo"
import HomeComponent from "@components/home"
import { Page, Text } from "@geist-ui/core"
import type { GetStaticProps } from "next"
import { InferGetStaticPropsType } from "next"
type Props =
| {
introContent: string
introTitle: string
rendered: string
}
| {
error: boolean
}
export const getStaticProps: GetStaticProps = async () => {
try {
const resp = await fetch(process.env.API_URL + `/welcome`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || ""
}
})
const { title, content, rendered } = await resp.json()
return {
props: {
introContent: content || null,
rendered: rendered || null,
introTitle: title || null
},
// Next.js will attempt to re-generate the page:
// - When a request comes in
// - At most every 60 seconds
revalidate: 60 // In seconds
}
} catch (err) {
// If there was an error, it's likely due to the server not running, so we attempt to regenerate the page
return {
props: {
error: true
},
revalidate: 10 // In seconds
}
}
}
// TODO: fix props type
const Home = ({
rendered,
introContent,
introTitle,
error
}: InferGetStaticPropsType<typeof getStaticProps>) => {
return (
<Page className={styles.wrapper}>
<PageSeo />
<Page.Content className={styles.main}>
{error && <Text>Something went wrong. Is the server running?</Text>}
{!error && (
<HomeComponent
rendered={rendered}
introContent={introContent}
introTitle={introTitle}
/>
)}
</Page.Content>
</Page>
)
}
export default Home

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