diff --git a/.env.default b/.env.default new file mode 100644 index 00000000..8d2bdda0 --- /dev/null +++ b/.env.default @@ -0,0 +1,35 @@ +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= diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..949a80a0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "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" + } +} diff --git a/.gitignore b/.gitignore index 57650435..21e54529 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,36 @@ +# 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 -drift.sqlite \ No newline at end of file + +# typescript +*.tsbuildinfo diff --git a/server/.prettierrc b/.prettierrc similarity index 58% rename from server/.prettierrc rename to .prettierrc index cda1eceb..34ee0ad6 100644 --- a/server/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "trailingComma": "none", "singleQuote": false, "printWidth": 80, - "useTabs": true + "useTabs": true, + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d4f9c4b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "dotenv.enableAutocloaking": false +} \ No newline at end of file diff --git a/README.md b/README.md index 77bceb5e..a8835e51 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ -# Drift +# Drift -Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is completely functional. +> **Note:** This branch is where all work is being done to refactor to the Next.js 13 app directory and React Server Components. -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. +Drift is a self-hostable Gist clone. It's in beta, but is completely functional. + +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). + +Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/). -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).
**Contents:** + - [Setup](#setup) - [Development](#development) - [Production](#production) @@ -20,75 +26,122 @@ If you want to contribute, need support, or want to stay updated, you can join t ### Development -In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).) -You can run `yarn dev` in either / both folders to start the server and client with file watching / live reloading. +In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation). +You can run `pnpm dev` in `client` for file watching and live reloading. -To migrate the sqlite database in development, you can use `yarn migrate` to see a list of options. +To work with [prisma](prisma.io/), you can use `pnpm prisma` or `pnpm exec prisma` to interact with the database. ### Production -`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. +`pnpm build` will produce production code. `pnpm start` will start the Next.js server. ### Environment Variables You can change these to your liking. -`client/.env`: - -- `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 -- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser - -`server/.env`: +`.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 +- `DRIFT_URL`: the URL of the drift instance. +- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`. - `WELCOME_CONTENT`: a markdown string that's rendered on the home page - `WELCOME_TITLE`: the file title for the post on the homepage. - `ENABLE_ADMIN`: the first account created is an administrator account -- `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images +- `REGISTRATION_PASSWORD`: the password required to register an account. If not set, no password is required. +- `NODE_ENV`: defaults to development, can be `production` + +#### Auth environment variables + +**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`. ## Running with pm2 It's easy to start Drift using [pm2](https://pm2.keymetrics.io/). -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: +First, add the `.env` file with your values (see the above section for the required options). -- `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` +Then, use the following command to start the server: -You now use `pm2 ls` to see their statuses. Refer to pm2's docs or `pm2 help` for more information. +- `pnpm build && pm2 start pnpm --name drift --interpreter bash -- start` -## Running with Docker +Refer to pm2's docs or `pm2 help` for more information. -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. +## Running with Docker +## Running with systemd + +_**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 -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. +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. -- [x] creating and sharing private, public, unlisted posts - - [x] syntax highlighting (detected by file extension) - - [x] multiple files per post - - [x] uploading files via drag-and-drop +- [x] Next.js 13 `app` directory +- [x] creating and sharing private, public, password-protected, and unlisted posts + - [x] syntax highlighting + - [x] expiring posts - [x] responsive UI - [x] user auth - [ ] 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] password protected posts -- [x] sqlite database -- [ ] administrator account / settings +- [x] postgres database +- [x] administrator account / settings - [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75)) - [ ] publish docker builds - [ ] user settings - [ ] works enough with JavaScript disabled -- [x] documentation +- [ ] in-depth 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? +- [ ] fleshed out API +- [ ] Swappable database backends +- [ ] More OAuth providers diff --git a/client/.env.local b/client/.env.local deleted file mode 100644 index 1eac9b7c..00000000 --- a/client/.env.local +++ /dev/null @@ -1,2 +0,0 @@ -API_URL=http://localhost:3000 -SECRET_KEY=secret diff --git a/client/.eslintrc.json b/client/.eslintrc.json deleted file mode 100644 index bffb357a..00000000 --- a/client/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index 3000161b..00000000 --- a/client/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -# 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 diff --git a/client/.prettierrc b/client/.prettierrc deleted file mode 100644 index cda1eceb..00000000 --- a/client/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": false, - "trailingComma": "none", - "singleQuote": false, - "printWidth": 80, - "useTabs": true -} diff --git a/client/README.md b/client/README.md deleted file mode 100644 index c87e0421..00000000 --- a/client/README.md +++ /dev/null @@ -1,34 +0,0 @@ -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. diff --git a/client/components/Link.tsx b/client/components/Link.tsx deleted file mode 100644 index 2cbb65cd..00000000 --- a/client/components/Link.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 -} - -export default Link diff --git a/client/components/admin/action-dropdown/index.tsx b/client/components/admin/action-dropdown/index.tsx deleted file mode 100644 index 41516896..00000000 --- a/client/components/admin/action-dropdown/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 ( - - {showTitle && {title}} - {actions.map((action) => ( - - {action.title} - - ))} - - } - hideArrow - > - - - ) -} - -export default ActionDropdown diff --git a/client/components/admin/admin.module.css b/client/components/admin/admin.module.css deleted file mode 100644 index 9c4cb99d..00000000 --- a/client/components/admin/admin.module.css +++ /dev/null @@ -1,25 +0,0 @@ -.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; -} diff --git a/client/components/admin/index.tsx b/client/components/admin/index.tsx deleted file mode 100644 index 338babe9..00000000 --- a/client/components/admin/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 ( -
- Administration - - - -
- ) -} - -export default Admin diff --git a/client/components/admin/post-table.tsx b/client/components/admin/post-table.tsx deleted file mode 100644 index 6bbb52d1..00000000 --- a/client/components/admin/post-table.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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() - 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 ( - deletePost() - } - ]} - /> - ) - } - } - ] - - return ( - - {!posts && Loading...} - {posts && ( - -
{posts.length} posts
-
- )} - {posts && } - - ) -} - -export default PostTable diff --git a/client/components/admin/user-table.tsx b/client/components/admin/user-table.tsx deleted file mode 100644 index b931db70..00000000 --- a/client/components/admin/user-table.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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() - 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 ( - - toggleRole(user.id, user.role === "admin" ? "user" : "admin") - }, - { - title: "Delete", - onClick: () => deleteUser(user.id) - } - ]} - /> - ) - } - } - ] - - return ( - - {!users && Loading...} - {users && ( - -
{users.length} users
-
- )} - {users &&
} - - ) -} - -export default UserTable diff --git a/client/components/app/index.tsx b/client/components/app/index.tsx deleted file mode 100644 index ab0a85a2..00000000 --- a/client/components/app/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 - 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 ( - - - -
- - - - ) -} - -export default App diff --git a/client/components/auth/auth.module.css b/client/components/auth/auth.module.css deleted file mode 100644 index 0b1da78b..00000000 --- a/client/components/auth/auth.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.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; -} diff --git a/client/components/auth/index.tsx b/client/components/auth/index.tsx deleted file mode 100644 index 5838bfe2..00000000 --- a/client/components/auth/index.tsx +++ /dev/null @@ -1,154 +0,0 @@ -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) => { - 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 ( -
-
-
-

{signingIn ? "Sign In" : "Sign Up"}

-
-
-
- setUsername(event.target.value)} - placeholder="Username" - required - scale={4 / 3} - /> - setPassword(event.target.value)} - placeholder="Password" - required - scale={4 / 3} - /> - {requiresServerPassword && ( - setServerPassword(event.target.value)} - placeholder="Server Password" - required - scale={4 / 3} - /> - )} - - -
-
- {signingIn ? ( - - Don't have an account?{" "} - - Sign up - - - ) : ( - - Already have an account?{" "} - - Sign in - - - )} -
- {errorMsg && ( - - {errorMsg} - - )} - -
-
- ) -} - -export default Auth diff --git a/client/components/badges/created-ago-badge/index.tsx b/client/components/badges/created-ago-badge/index.tsx deleted file mode 100644 index 9b84991b..00000000 --- a/client/components/badges/created-ago-badge/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 ( - - {" "} - - Created {time} - - - ) -} - -export default CreatedAgoBadge diff --git a/client/components/badges/expiration-badge/index.tsx b/client/components/badges/expiration-badge/index.tsx deleted file mode 100644 index 9151d915..00000000 --- a/client/components/badges/expiration-badge/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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( - 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 ( - - - {isExpired ? "Expired" : `Expires ${timeUntilString}`} - - - ) -} - -export default ExpirationBadge diff --git a/client/components/badges/visibility-badge/index.tsx b/client/components/badges/visibility-badge/index.tsx deleted file mode 100644 index 0385463f..00000000 --- a/client/components/badges/visibility-badge/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 {visibility} -} - -export default VisibilityBadge diff --git a/client/components/badges/visibility-control/index.tsx b/client/components/badges/visibility-control/index.tsx deleted file mode 100644 index 2fbcd9dc..00000000 --- a/client/components/badges/visibility-control/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -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 ? ( - - ) : ( - - - - - - - )} - - - ) -} - -export default VisibilityControl diff --git a/client/components/button-dropdown/dropdown.module.css b/client/components/button-dropdown/dropdown.module.css deleted file mode 100644 index dd03da08..00000000 --- a/client/components/button-dropdown/dropdown.module.css +++ /dev/null @@ -1,26 +0,0 @@ -.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; -} diff --git a/client/components/button-dropdown/index.tsx b/client/components/button-dropdown/index.tsx deleted file mode 100644 index 635f972b..00000000 --- a/client/components/button-dropdown/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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, keyof Props> -type ButtonDropdownProps = Props & Attrs - -const ButtonDropdown: React.FC< - React.PropsWithChildren -> = ({ type, className, disabled, loading, iconHeight = 24, ...props }) => { - const [visible, setVisible] = useState(false) - const [dropdown, setDropdown] = useState(null) - - const onClick = (e: React.MouseEvent) => { - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - setVisible(!visible) - } - - const onBlur = () => { - setVisible(false) - } - - const onMouseDown = (e: React.MouseEvent) => { - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - } - - const onMouseUp = (e: React.MouseEvent) => { - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - } - - const onMouseLeave = (e: React.MouseEvent) => { - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - setVisible(false) - } - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - setVisible(false) - } - } - - const onClickOutside = useCallback( - () => (e: React.MouseEvent) => { - 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 ( -
-
- {props.children[0]} - -
- {visible && ( -
-
- {props.children.slice(1)} -
-
- )} -
- ) -} - -export default ButtonDropdown diff --git a/client/components/button/button.module.css b/client/components/button/button.module.css deleted file mode 100644 index e94bb8ad..00000000 --- a/client/components/button/button.module.css +++ /dev/null @@ -1,40 +0,0 @@ -.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); -} diff --git a/client/components/button/index.tsx b/client/components/button/index.tsx deleted file mode 100644 index aa231458..00000000 --- a/client/components/button/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import styles from "./button.module.css" -import { forwardRef, Ref } from "react" - -type Props = React.HTMLProps & { - children: React.ReactNode - buttonType?: "primary" | "secondary" - className?: string - onClick?: (e: React.MouseEvent) => void -} - -// eslint-disable-next-line react/display-name -const Button = forwardRef( - ( - { - children, - onClick, - className, - buttonType = "primary", - type = "button", - disabled = false, - ...props - }, - ref - ) => { - return ( - - ) - } -) - -export default Button diff --git a/client/components/edit-document/document.module.css b/client/components/edit-document/document.module.css deleted file mode 100644 index c966020e..00000000 --- a/client/components/edit-document/document.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.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; - } -} diff --git a/client/components/edit-document/index.tsx b/client/components/edit-document/index.tsx deleted file mode 100644 index a675f17a..00000000 --- a/client/components/edit-document/index.tsx +++ /dev/null @@ -1,162 +0,0 @@ -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) => void - initialTab?: "edit" | "preview" - remove?: () => void - onPaste?: (e: any) => void -} - -const Document = ({ - onPaste, - remove, - title, - content, - setTitle, - initialTab = "edit", - handleOnContentChange -}: Props) => { - const codeEditorRef = useRef(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) => - 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 <> - // - //
- //
- // - // {remove && } - //
- //
- //
- // - //
- //
- // - // } - - return ( - <> - -
-
- - {remove && ( -
-
- {tab === "edit" && } - - - {/* */} -
- - */} -
- */} -
-