Compare commits
165 commits
headerAuth
...
refactor
Author | SHA1 | Date | |
---|---|---|---|
|
0c20460c13 | ||
|
563136fdb3 | ||
|
bff0dbea38 | ||
|
5d5fd3182e | ||
|
702f59caf8 | ||
|
41e72ba04c | ||
|
0df85776a5 | ||
|
7f4745ade1 | ||
|
504d2742f4 | ||
|
69ca511cc2 | ||
|
a1fa7dbb8a | ||
|
c416f5d5e8 | ||
|
dc11f8eb0c | ||
|
5e4ecbb803 | ||
|
0f58c44261 | ||
|
d0a73a7cbc | ||
|
55e19381a7 | ||
|
3433371930 | ||
|
7887b42404 | ||
|
68fc679864 | ||
|
cb7d9ebc6b | ||
|
3041da80e2 | ||
|
a54a22f142 | ||
|
27a604dc90 | ||
|
6cf544fc72 | ||
|
86e323fbca | ||
|
b64281b1ac | ||
|
9c3375cbd0 | ||
|
806b173d22 | ||
|
590cc51ec8 | ||
|
85f21bf505 | ||
|
cc2215629d | ||
|
aaf2761004 | ||
|
88c65f2e9f | ||
|
6d184906b1 | ||
|
afd18d19a9 | ||
|
f2d42a6c0c | ||
|
0b6d31373d | ||
|
d1e5dca3d0 | ||
|
a5704e0a6d | ||
|
86c2fb4a73 | ||
|
fec58f2465 | ||
|
e21d896669 | ||
|
9a811e85b5 | ||
|
817a12fffb | ||
|
e51815eb16 | ||
|
64cfe9033e | ||
|
072516bdb0 | ||
|
b5b4bf08f6 | ||
|
98cbbf2347 | ||
|
c813ffaf56 | ||
|
eda977b203 | ||
|
f3d588c0eb | ||
|
a64cc78eed | ||
|
1acbb52e27 | ||
|
3048d842de | ||
|
41a7a90bda | ||
|
08abdd4642 | ||
|
be73154b4e | ||
|
acfcc04af4 | ||
|
f5e2fd365b | ||
|
16b4a5ae07 | ||
|
ba092152f2 | ||
|
7c8e2c9947 | ||
|
5b7efc8a06 | ||
|
e49ca2e749 | ||
|
ba732dcd71 | ||
|
6fb81d77b9 | ||
|
6b0a6bf3b6 | ||
|
a6c8c8c825 | ||
|
6148f8d1e9 | ||
|
c51ca39fa7 | ||
|
371dae25d9 | ||
|
6bbd380392 | ||
|
d9e7aa5ecf | ||
|
c21ca52a59 | ||
|
98ad33bcd8 | ||
|
b9ab0df7c0 | ||
|
02695345cd | ||
|
69a40df606 | ||
|
6aa5301d89 | ||
|
604f5d64d0 | ||
|
b848aa9e40 | ||
|
e41dc292b8 | ||
|
e4b215b7a8 | ||
|
19c5725847 | ||
|
631f98aaaf | ||
|
a97ba1b9aa | ||
|
f07f4789ee | ||
|
23a850253b | ||
|
5e976bfc0d | ||
|
3e199cf8d4 | ||
|
447974a74a | ||
|
65cf59e96b | ||
|
0631ae3897 | ||
|
82aadd94f2 | ||
|
69c482a165 | ||
|
34a92a265f | ||
|
ff310a67b9 | ||
|
f034f29a1d | ||
|
350575ccd4 | ||
|
70212232a0 | ||
|
aee2330e21 | ||
|
7ef45c28f0 | ||
|
5918b13867 | ||
|
72633c6ad2 | ||
|
a84dad1dde | ||
|
330dbd85b1 | ||
|
7eeadbe065 | ||
|
56eefc8419 | ||
|
9b593c849e | ||
|
8578714c4a | ||
|
44a05f6456 | ||
|
ce0c442273 | ||
|
fc79f7df4d | ||
|
23b7343963 | ||
|
1d7db6e059 | ||
|
a7660f6374 | ||
|
8048e99794 | ||
|
d6894ffb8b | ||
|
0cab3acd62 | ||
|
41ed505362 | ||
|
97e4742453 | ||
|
881e693e76 | ||
|
8fe7299258 | ||
|
12d9eafcd9 | ||
|
4cf448c35d | ||
|
3c5dcc24ac | ||
|
45c2e59105 | ||
|
dfe0d39fa0 | ||
|
bff7c90e5f | ||
|
3bebb6ac7d | ||
|
2c3e271df1 | ||
|
aef1788747 | ||
|
37d4dfebcf | ||
|
e1ef002300 | ||
|
8e7828d562 | ||
|
bc2a4acd29 | ||
|
c5e276b51c | ||
|
c31b911c86 | ||
|
2b36e3c58e | ||
|
f81999241f | ||
|
2b783145d4 | ||
|
0627ab7396 | ||
|
97cff7eb53 | ||
|
5f4749ebb3 | ||
|
733a93dd87 | ||
|
ecd4521403 | ||
|
c41cf7c5ef | ||
|
096cf41eee | ||
|
86b9172527 | ||
|
96da95818f | ||
|
96c4023c14 | ||
|
60d1b031f5 | ||
|
7c761eb727 | ||
|
68570b3bb7 | ||
|
8b0b172f7d | ||
|
cf7d89eb20 | ||
|
95d1ef31ef | ||
|
9b9c3c1d87 | ||
|
da870d6957 | ||
|
6b2b8b8be6 | ||
|
0a5a2adb26 | ||
|
0405f821c4 | ||
|
04ed522566 |
366 changed files with 19914 additions and 17312 deletions
35
.env.default
Normal file
35
.env.default
Normal file
|
@ -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=
|
14
.eslintrc.json
Normal file
14
.eslintrc.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
36
.gitignore
vendored
36
.gitignore
vendored
|
@ -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
|
.vercel
|
||||||
drift.sqlite
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
|
@ -3,5 +3,6 @@
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"useTabs": true
|
"useTabs": true,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
}
|
}
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"dotenv.enableAutocloaking": false
|
||||||
|
}
|
137
README.md
137
README.md
|
@ -1,13 +1,19 @@
|
||||||
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
|
# <img src="src/public/assets/logo.png" height="32px" alt="" /> 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).
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
**Contents:**
|
**Contents:**
|
||||||
|
|
||||||
- [Setup](#setup)
|
- [Setup](#setup)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Production](#production)
|
- [Production](#production)
|
||||||
|
@ -20,81 +26,122 @@ If you want to contribute, need support, or want to stay updated, you can join t
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).)
|
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation).
|
||||||
You can run `yarn dev` in either / both folders to start the server and client with file watching / live reloading.
|
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
|
### Production
|
||||||
|
|
||||||
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively.
|
`pnpm build` will produce production code. `pnpm start` will start the Next.js server.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
`client/.env`:
|
`.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
|
- `DRIFT_URL`: the URL of the drift instance.
|
||||||
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
|
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`.
|
||||||
|
|
||||||
`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
|
||||||
- `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`
|
||||||
|
|
||||||
### For SSO
|
#### 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`.
|
||||||
|
|
||||||
- `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 `.env` files to `client/` and `server/` with the values you want (see the above section for possible values).
|
First, add the `.env` file with your values (see the above section for the required options).
|
||||||
Then, use the following commands to start the client and server:
|
|
||||||
|
|
||||||
- `cd server && yarn build && pm2 start yarn --name drift-server --interpreter bash -- start`
|
Then, use the following command to start the server:
|
||||||
- `cd ..`
|
|
||||||
- `cd client && yarn build && pm2 start yarn --name drift-client --interpreter bash -- start`
|
|
||||||
|
|
||||||
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`
|
||||||
|
|
||||||
|
Refer to pm2's docs or `pm2 help` for more information.
|
||||||
|
|
||||||
## Running with Docker
|
## Running with Docker
|
||||||
|
|
||||||
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 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
|
## 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] Next.js 13 `app` directory
|
||||||
- [x] syntax highlighting (detected by file extension)
|
- [x] creating and sharing private, public, password-protected, and unlisted posts
|
||||||
- [x] multiple files per post
|
- [x] syntax highlighting
|
||||||
- [x] uploading files via drag-and-drop
|
- [x] expiring posts
|
||||||
- [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] sqlite database
|
- [x] postgres database
|
||||||
- [ ] administrator account / settings
|
- [x] 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
|
||||||
- [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?
|
- [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
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
API_URL=http://localhost:3000
|
|
||||||
SECRET_KEY=secret
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
35
client/.gitignore
vendored
35
client/.gitignore
vendored
|
@ -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
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"singleQuote": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"useTabs": true
|
|
||||||
}
|
|
|
@ -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.
|
|
|
@ -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 <GeistLink {...props} href={href} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Link
|
|
|
@ -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 (
|
|
||||||
<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
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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 (
|
|
||||||
<div className={styles.adminWrapper}>
|
|
||||||
<Text h2>Administration</Text>
|
|
||||||
<UserTable />
|
|
||||||
<Spacer height={1} />
|
|
||||||
<PostTable />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Admin
|
|
|
@ -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<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
|
|
|
@ -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<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
|
|
|
@ -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<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
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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<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'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
|
|
|
@ -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 (
|
|
||||||
<Badge type="secondary">
|
|
||||||
{" "}
|
|
||||||
<Tooltip hideArrow text={formattedTime}>
|
|
||||||
Created {time}
|
|
||||||
</Tooltip>
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CreatedAgoBadge
|
|
|
@ -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<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
|
|
|
@ -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 <Badge type={getBadgeType()}>{visibility}</Badge>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VisibilityBadge
|
|
|
@ -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 ? (
|
|
||||||
<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
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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<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
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
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
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<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)
|
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
|
@ -1,15 +0,0 @@
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.fadeIn {
|
|
||||||
animation-name: fadeInAnimation;
|
|
||||||
animation-fill-mode: backwards;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInAnimation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
.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) {
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
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
|
|
|
@ -1,109 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
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
|
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
|
@ -1,46 +0,0 @@
|
||||||
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)
|
|
|
@ -1,62 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -1,225 +0,0 @@
|
||||||
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
|
|
|
@ -1,3 +0,0 @@
|
||||||
.textarea {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
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
|
|
|
@ -1,25 +0,0 @@
|
||||||
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
|
|
|
@ -1,57 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
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
|
|
|
@ -1,26 +0,0 @@
|
||||||
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)
|
|
|
@ -1,41 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
|
@ -1,368 +0,0 @@
|
||||||
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
|
|
|
@ -1,83 +0,0 @@
|
||||||
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'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
|
|
|
@ -1,51 +0,0 @@
|
||||||
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)
|
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
|
@ -1,171 +0,0 @@
|
||||||
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
|
|
|
@ -1,27 +0,0 @@
|
||||||
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
|
|
|
@ -1,36 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
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
|
|
|
@ -1,178 +0,0 @@
|
||||||
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
|
|
|
@ -1,64 +0,0 @@
|
||||||
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
|
|
|
@ -1,56 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
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)
|
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
|
@ -1,134 +0,0 @@
|
||||||
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
|
|
|
@ -1,125 +0,0 @@
|
||||||
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
|
|
|
@ -1,20 +0,0 @@
|
||||||
// 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
|
|
|
@ -1,49 +0,0 @@
|
||||||
.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%;
|
|
||||||
}
|
|
|
@ -1,169 +0,0 @@
|
||||||
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)
|
|
|
@ -1,4 +0,0 @@
|
||||||
const byteToMB = (bytes: number) =>
|
|
||||||
Math.round((bytes / 1024 / 1024) * 100) / 100
|
|
||||||
|
|
||||||
export default byteToMB
|
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
|
@ -1,43 +0,0 @@
|
||||||
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
|
|
40
client/lib/types.d.ts
vendored
40
client/lib/types.d.ts
vendored
|
@ -1,40 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
|
@ -1,58 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
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
|
|
|
@ -1,39 +0,0 @@
|
||||||
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
|
|
|
@ -1,12 +0,0 @@
|
||||||
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
|
|
|
@ -1,63 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
|
@ -1,24 +0,0 @@
|
||||||
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
|
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
|
@ -1,19 +0,0 @@
|
||||||
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're trying to view has expired.
|
|
||||||
</Text>
|
|
||||||
</Note>
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Expired
|
|
|
@ -1,74 +0,0 @@
|
||||||
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
|
|
|
@ -1,67 +0,0 @@
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
|
|
||||||
import Header from "@components/header"
|
|
||||||
import MyPosts from "@components/my-posts"
|
|
||||||
import cookie from "cookie"
|
|
||||||
import type { GetServerSideProps } from "next"
|
|
||||||
import { Post } from "@lib/types"
|
|
||||||
import { Page } from "@geist-ui/core"
|
|
||||||
|
|
||||||
const Home = ({
|
|
||||||
morePosts,
|
|
||||||
posts,
|
|
||||||
error
|
|
||||||
}: {
|
|
||||||
morePosts: boolean
|
|
||||||
posts: Post[]
|
|
||||||
error: boolean
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Page className={styles.wrapper}>
|
|
||||||
<Page.Content className={styles.main}>
|
|
||||||
<MyPosts morePosts={morePosts} error={error} posts={posts} />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// get server side props
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
|
||||||
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
|
|
||||||
if (!driftToken) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${driftToken}`,
|
|
||||||
"x-secret-key": process.env.SECRET_KEY || ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!posts.ok) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await posts.json()
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
posts: data.posts,
|
|
||||||
error: posts.status !== 200,
|
|
||||||
morePosts: data.hasMore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home
|
|
|
@ -1,78 +0,0 @@
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
import NewPost from "@components/new-post"
|
|
||||||
import Header from "@components/header"
|
|
||||||
import PageSeo from "@components/page-seo"
|
|
||||||
import { Page } from "@geist-ui/core"
|
|
||||||
import Head from "next/head"
|
|
||||||
import { GetServerSideProps } from "next"
|
|
||||||
import { Post } from "@lib/types"
|
|
||||||
import cookie from "cookie"
|
|
||||||
|
|
||||||
const NewFromExisting = ({
|
|
||||||
post,
|
|
||||||
parentId
|
|
||||||
}: {
|
|
||||||
post: Post
|
|
||||||
parentId: string
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Page className={styles.wrapper}>
|
|
||||||
<PageSeo title="Create a new Drift" />
|
|
||||||
<Head>
|
|
||||||
{/* TODO: solve this. */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
|
||||||
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
|
||||||
</Head>
|
|
||||||
<Page.Content className={styles.main}>
|
|
||||||
<NewPost initialPost={post} newPostParent={parentId} />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({
|
|
||||||
req,
|
|
||||||
params
|
|
||||||
}) => {
|
|
||||||
const id = params?.id
|
|
||||||
const redirect = {
|
|
||||||
redirect: {
|
|
||||||
destination: "/new",
|
|
||||||
permanent: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
const driftToken = cookie.parse(req.headers.cookie || "")[`drift-token`]
|
|
||||||
|
|
||||||
const post = await fetch(`${process.env.API_URL}/posts/${id}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${driftToken}`,
|
|
||||||
"x-secret-key": process.env.SECRET_KEY || ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!post.ok) {
|
|
||||||
return redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await post.json()
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
post: data,
|
|
||||||
parentId: id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NewFromExisting
|
|
|
@ -1,24 +0,0 @@
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
import NewPost from "@components/new-post"
|
|
||||||
import Header from "@components/header"
|
|
||||||
import PageSeo from "@components/page-seo"
|
|
||||||
import { Page } from "@geist-ui/core"
|
|
||||||
import Head from "next/head"
|
|
||||||
|
|
||||||
const New = () => {
|
|
||||||
return (
|
|
||||||
<Page className={styles.wrapper}>
|
|
||||||
<PageSeo title="Create a new Drift" />
|
|
||||||
<Head>
|
|
||||||
{/* TODO: solve this. */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
|
||||||
<link rel="stylesheet" href="/css/react-datepicker.css" />
|
|
||||||
</Head>
|
|
||||||
<Page.Content className={styles.main}>
|
|
||||||
<NewPost />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default New
|
|
|
@ -1,80 +0,0 @@
|
||||||
import type { GetServerSideProps } from "next"
|
|
||||||
|
|
||||||
import type { Post } from "@lib/types"
|
|
||||||
import PostPage from "@components/post-page"
|
|
||||||
|
|
||||||
export type PostProps = {
|
|
||||||
post: Post
|
|
||||||
isProtected?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostView = ({ post, isProtected }: PostProps) => {
|
|
||||||
return <PostPage isProtected={isProtected} post={post} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({
|
|
||||||
params,
|
|
||||||
req,
|
|
||||||
res
|
|
||||||
}) => {
|
|
||||||
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-secret-key": process.env.SECRET_KEY || "",
|
|
||||||
Authorization: `Bearer ${req.cookies["drift-token"]}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (post.status === 401 || post.status === 403) {
|
|
||||||
return {
|
|
||||||
// can't access the post if it's private
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false
|
|
||||||
},
|
|
||||||
props: {}
|
|
||||||
}
|
|
||||||
} else if (post.status === 404 || !post.ok) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/404",
|
|
||||||
permanent: false
|
|
||||||
},
|
|
||||||
props: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await post.json()) as Post
|
|
||||||
const isAuthor = json.users?.find(
|
|
||||||
(user) => user.id === req.cookies["drift-userid"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (json.visibility === "public" || json.visibility === "unlisted") {
|
|
||||||
const sMaxAge = 60 * 60 * 12 // half a day
|
|
||||||
res.setHeader(
|
|
||||||
"Cache-Control",
|
|
||||||
`public, s-maxage=${sMaxAge}, max-age=${sMaxAge}`
|
|
||||||
)
|
|
||||||
} else if (json.visibility === "protected" && !isAuthor) {
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
post: {
|
|
||||||
id: json.id,
|
|
||||||
visibility: json.visibility,
|
|
||||||
expiresAt: json.expiresAt
|
|
||||||
},
|
|
||||||
isProtected: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
post: json,
|
|
||||||
key: params?.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PostView
|
|
|
@ -1,27 +0,0 @@
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Text,
|
|
||||||
Fieldset,
|
|
||||||
Input,
|
|
||||||
Page,
|
|
||||||
Note,
|
|
||||||
Textarea
|
|
||||||
} from "@geist-ui/core"
|
|
||||||
import PageSeo from "@components/page-seo"
|
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
import SettingsPage from "@components/settings"
|
|
||||||
|
|
||||||
const Settings = () => (
|
|
||||||
<Page width={"100%"}>
|
|
||||||
<PageSeo title="Drift - Settings" />
|
|
||||||
<Page.Content
|
|
||||||
className={styles.main}
|
|
||||||
style={{ gap: "var(--gap)", display: "flex", flexDirection: "column" }}
|
|
||||||
>
|
|
||||||
<SettingsPage />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default Settings
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { Page } from "@geist-ui/core"
|
|
||||||
import PageSeo from "@components/page-seo"
|
|
||||||
import Auth from "@components/auth"
|
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
const SignIn = () => (
|
|
||||||
<Page width={"100%"}>
|
|
||||||
<PageSeo title="Drift - Sign In" />
|
|
||||||
<Page.Content className={styles.main}>
|
|
||||||
<Auth page="signin" />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SignIn
|
|
|
@ -1,15 +0,0 @@
|
||||||
import { Page } from "@geist-ui/core"
|
|
||||||
import Auth from "@components/auth"
|
|
||||||
import PageSeo from "@components/page-seo"
|
|
||||||
import styles from "@styles/Home.module.css"
|
|
||||||
|
|
||||||
const SignUp = () => (
|
|
||||||
<Page width="100%">
|
|
||||||
<PageSeo title="Drift - Sign Up" />
|
|
||||||
<Page.Content className={styles.main}>
|
|
||||||
<Auth page="signup" />
|
|
||||||
</Page.Content>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SignUp
|
|
|
@ -1,124 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
width="72.000008"
|
|
||||||
height="72"
|
|
||||||
viewBox="0 0 19.05 19.05"
|
|
||||||
version="1.1"
|
|
||||||
id="svg5"
|
|
||||||
inkscape:export-filename="/home/reese/git/github.com/maxleiter/drift/logo.png"
|
|
||||||
inkscape:export-xdpi="682.66669"
|
|
||||||
inkscape:export-ydpi="682.66669"
|
|
||||||
inkscape:version="1.1.2 (1:1.1+202202050950+0a00cf5339)"
|
|
||||||
sodipodi:docname="logo.svg"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview7"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#ffffff"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="1"
|
|
||||||
inkscape:document-units="px"
|
|
||||||
showgrid="false"
|
|
||||||
showguides="false"
|
|
||||||
inkscape:zoom="13.877295"
|
|
||||||
inkscape:cx="10.448722"
|
|
||||||
inkscape:cy="34.444753"
|
|
||||||
inkscape:current-layer="g3632"
|
|
||||||
units="px"
|
|
||||||
viewbox-width="19.05" />
|
|
||||||
<defs
|
|
||||||
id="defs2">
|
|
||||||
<clipPath
|
|
||||||
clipPathUnits="userSpaceOnUse"
|
|
||||||
id="clipPath7860">
|
|
||||||
<circle
|
|
||||||
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
|
||||||
id="circle7862"
|
|
||||||
cx="115.27311"
|
|
||||||
cy="135.3275"
|
|
||||||
r="9.1405506" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
clipPathUnits="userSpaceOnUse"
|
|
||||||
id="clipPath7864">
|
|
||||||
<circle
|
|
||||||
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
|
||||||
id="circle7866"
|
|
||||||
cx="115.27311"
|
|
||||||
cy="135.3275"
|
|
||||||
r="9.1405506" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<g
|
|
||||||
inkscape:label="source strokes"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
style="display:none"
|
|
||||||
transform="translate(-106.13256,-126.18696)">
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 114.7741,133.0871 c 0,0 2.24373,3.38322 0.005,7.06735 -2.23896,3.68413 -8.84476,5.87171 -8.84476,5.87171"
|
|
||||||
id="path1824"
|
|
||||||
sodipodi:nodetypes="csc" />
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 99.71221,140.61603 c 0,0 6.55112,-0.26544 10.1251,-2.27285 3.57398,-2.00741 4.93679,-5.25608 4.93679,-5.25608"
|
|
||||||
id="path857"
|
|
||||||
sodipodi:nodetypes="czc" />
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 114.7741,133.0871 c 0,0 3.22515,3.50294 1.78507,7.47454 -1.44009,3.97159 -7.66948,7.78507 -7.66948,7.78507"
|
|
||||||
id="path949"
|
|
||||||
sodipodi:nodetypes="czc" />
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 114.7741,133.0871 c 0,0 6.66681,-0.12736 17.37373,8.90799"
|
|
||||||
id="path1345"
|
|
||||||
sodipodi:nodetypes="cc" />
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
inkscape:label="Layer 1 copy"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="g3632"
|
|
||||||
transform="translate(-106.13256,-126.18696)">
|
|
||||||
<rect
|
|
||||||
style="display:inline;fill:#1b1b1b;fill-opacity:1;stroke:none;stroke-width:2.81834;stroke-linecap:round"
|
|
||||||
id="rect6284"
|
|
||||||
width="18.28112"
|
|
||||||
height="18.28112"
|
|
||||||
x="106.13255"
|
|
||||||
y="126.18695"
|
|
||||||
clip-path="url(#clipPath7864)"
|
|
||||||
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)" />
|
|
||||||
<g
|
|
||||||
id="g937"
|
|
||||||
inkscape:label="drift"
|
|
||||||
clip-path="url(#clipPath7860)"
|
|
||||||
mask="none"
|
|
||||||
style="display:inline;stroke-width:0.959638"
|
|
||||||
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)">
|
|
||||||
<path
|
|
||||||
id="path935"
|
|
||||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 132.14783,141.99509 c -10.70692,-9.03535 -17.37373,-8.90799 -17.37373,-8.90799 0,0 2.38807,3.48286 0.94799,7.45446 -1.44009,3.97159 -7.66636,7.74668 -7.66636,7.74668 z"
|
|
||||||
sodipodi:nodetypes="csccc" />
|
|
||||||
<path
|
|
||||||
id="path931"
|
|
||||||
style="fill:#e7e7e7;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 108.88969,148.34671 c 0,0 6.22939,-3.81348 7.66948,-7.78507 1.44008,-3.9716 -1.78507,-7.47454 -1.78507,-7.47454 0,0 1.22037,3.09102 -1.01836,6.77515 -2.23896,3.68413 -8.92258,4.9787 -8.92258,4.9787 z"
|
|
||||||
sodipodi:nodetypes="cscccc" />
|
|
||||||
<path
|
|
||||||
id="path933"
|
|
||||||
style="fill:#c6c6c6;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 105.93434,146.02616 c 0,0 6.6058,-2.18758 8.84476,-5.87171 2.23873,-3.68413 -0.005,-7.06735 -0.005,-7.06735 0,0 -1.36281,3.24867 -4.93679,5.25608 -3.57398,2.00741 -10.1251,2.27285 -10.1251,2.27285 z"
|
|
||||||
sodipodi:nodetypes="csccc" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 5.1 KiB |
|
@ -1,372 +0,0 @@
|
||||||
.react-datepicker__year-read-view--down-arrow,
|
|
||||||
.react-datepicker__month-read-view--down-arrow,
|
|
||||||
.react-datepicker__month-year-read-view--down-arrow,
|
|
||||||
.react-datepicker__navigation-icon::before {
|
|
||||||
border-color: var(--light-gray);
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 3px 3px 0 0;
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 9px;
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
width: 9px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
|
|
||||||
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
|
|
||||||
margin-left: -4px;
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-wrapper {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--gray);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker--time-only .react-datepicker__triangle {
|
|
||||||
left: 35px;
|
|
||||||
}
|
|
||||||
.react-datepicker--time-only .react-datepicker__time-container {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker--time-only .react-datepicker__time,
|
|
||||||
.react-datepicker--time-only .react-datepicker__time-box {
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__triangle {
|
|
||||||
position: absolute;
|
|
||||||
left: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="bottom"] {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement="bottom-end"]
|
|
||||||
.react-datepicker__triangle,
|
|
||||||
.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
|
|
||||||
left: auto;
|
|
||||||
right: 50px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="top"] {
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="right"] {
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
|
|
||||||
left: auto;
|
|
||||||
right: 42px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="left"] {
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
|
|
||||||
left: 42px;
|
|
||||||
right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__header {
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--gray);
|
|
||||||
border-top-left-radius: var(--radius);
|
|
||||||
border-top-right-radius: var(--radius);
|
|
||||||
padding: 8px 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__header--time {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__year-dropdown-container--select,
|
|
||||||
.react-datepicker__month-dropdown-container--select,
|
|
||||||
.react-datepicker__month-year-dropdown-container--select,
|
|
||||||
.react-datepicker__year-dropdown-container--scroll,
|
|
||||||
.react-datepicker__month-dropdown-container--scroll,
|
|
||||||
.react-datepicker__month-year-dropdown-container--scroll {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__current-month,
|
|
||||||
.react-datepicker-time__header,
|
|
||||||
.react-datepicker-year-header {
|
|
||||||
margin-top: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.944rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-time__header {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation {
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
z-index: 1;
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
text-indent: -999em;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--previous {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--next {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
|
|
||||||
right: 85px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years-previous {
|
|
||||||
top: 4px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years-upcoming {
|
|
||||||
top: -4px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation:hover *::before {
|
|
||||||
border-color: var(--lighter-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation-icon {
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--next {
|
|
||||||
left: -2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--next::before {
|
|
||||||
transform: rotate(45deg);
|
|
||||||
left: -7px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--previous {
|
|
||||||
right: -2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--previous::before {
|
|
||||||
transform: rotate(225deg);
|
|
||||||
right: -7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-container {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__year {
|
|
||||||
margin: 0.4rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.react-datepicker__year-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
max-width: 180px;
|
|
||||||
}
|
|
||||||
.react-datepicker__year .react-datepicker__year-text {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4rem;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month {
|
|
||||||
margin: 0.4rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.react-datepicker__month .react-datepicker__month-text,
|
|
||||||
.react-datepicker__month .react-datepicker__quarter-text {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4rem;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__input-time-container {
|
|
||||||
clear: both;
|
|
||||||
width: 100%;
|
|
||||||
float: left;
|
|
||||||
margin: 5px 0 10px 15px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container .react-datepicker-time__caption {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"]::-webkit-inner-spin-button,
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__delimiter {
|
|
||||||
margin-left: 5px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-names,
|
|
||||||
.react-datepicker__week {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-names {
|
|
||||||
margin-bottom: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-name,
|
|
||||||
.react-datepicker__day,
|
|
||||||
.react-datepicker__time-name {
|
|
||||||
color: var(--fg);
|
|
||||||
display: inline-block;
|
|
||||||
width: 1.7rem;
|
|
||||||
line-height: 1.7rem;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0.166rem;
|
|
||||||
}
|
|
||||||
.react-datepicker__day,
|
|
||||||
.react-datepicker__month-text,
|
|
||||||
.react-datepicker__quarter-text,
|
|
||||||
.react-datepicker__year-text {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.react-datepicker__day:hover,
|
|
||||||
.react-datepicker__month-text:hover,
|
|
||||||
.react-datepicker__quarter-text:hover,
|
|
||||||
.react-datepicker__year-text:hover {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--today,
|
|
||||||
.react-datepicker__month-text--today,
|
|
||||||
.react-datepicker__quarter-text--today,
|
|
||||||
.react-datepicker__year-text--today {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.react-datepicker__day--highlighted,
|
|
||||||
.react-datepicker__month-text--highlighted,
|
|
||||||
.react-datepicker__quarter-text--highlighted,
|
|
||||||
.react-datepicker__year-text--highlighted {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: #3dcc4a;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--highlighted:hover,
|
|
||||||
.react-datepicker__month-text--highlighted:hover,
|
|
||||||
.react-datepicker__quarter-text--highlighted:hover,
|
|
||||||
.react-datepicker__year-text--highlighted:hover {
|
|
||||||
background-color: #32be3f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected,
|
|
||||||
.react-datepicker__day--in-selecting-range,
|
|
||||||
.react-datepicker__day--in-range,
|
|
||||||
.react-datepicker__month-text--selected,
|
|
||||||
.react-datepicker__month-text--in-selecting-range,
|
|
||||||
.react-datepicker__month-text--in-range,
|
|
||||||
.react-datepicker__quarter-text--selected,
|
|
||||||
.react-datepicker__quarter-text--in-selecting-range,
|
|
||||||
.react-datepicker__quarter-text--in-range,
|
|
||||||
.react-datepicker__year-text--selected,
|
|
||||||
.react-datepicker__year-text--in-selecting-range,
|
|
||||||
.react-datepicker__year-text--in-range {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--selected:hover {
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--keyboard-selected,
|
|
||||||
.react-datepicker__month-text--keyboard-selected,
|
|
||||||
.react-datepicker__quarter-text--keyboard-selected,
|
|
||||||
.react-datepicker__year-text--keyboard-selected {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--keyboard-selected:hover {
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month--selecting-range
|
|
||||||
.react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) {
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
transform: scale(1.15) translateY(-12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled {
|
|
||||||
color: var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
.wrapper {
|
|
||||||
height: 100% !important;
|
|
||||||
padding-bottom: var(--small-gap) !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
max-width: var(--main-content) !important;
|
|
||||||
margin: 0 auto !important;
|
|
||||||
padding: 0 0 !important;
|
|
||||||
}
|
|
|
@ -1,160 +0,0 @@
|
||||||
@import "./syntax.css";
|
|
||||||
@import "./markdown.css";
|
|
||||||
@import "./inter.css";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Spacing */
|
|
||||||
--gap-quarter: 0.25rem;
|
|
||||||
--gap-half: 0.5rem;
|
|
||||||
--gap: 1rem;
|
|
||||||
--gap-double: 2rem;
|
|
||||||
|
|
||||||
--small-gap: 4rem;
|
|
||||||
--big-gap: 4rem;
|
|
||||||
--main-content: 55rem;
|
|
||||||
--radius: 8px;
|
|
||||||
--inline-radius: 5px;
|
|
||||||
|
|
||||||
--gap-negative: calc(-1 * var(--gap));
|
|
||||||
--gap-half-negative: calc(-1 * var(--gap-half));
|
|
||||||
--gap-quarter-negative: calc(-1 * var(--gap-quarter));
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
|
||||||
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
--font-mono: ui-monospace, "SFMono-Regular", "Consolas", "Liberation Mono",
|
|
||||||
"Menlo", monospace;
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition: 0.1s ease-in-out;
|
|
||||||
--transition-slow: 0.3s ease-in-out;
|
|
||||||
|
|
||||||
--token: #999;
|
|
||||||
--comment: #999;
|
|
||||||
--keyword: #fff;
|
|
||||||
--name: #fff;
|
|
||||||
--highlight: #2e2e2e;
|
|
||||||
|
|
||||||
/* Dark Mode Colors */
|
|
||||||
--bg: #000;
|
|
||||||
--fg: #fafbfc;
|
|
||||||
--gray: #666;
|
|
||||||
--light-gray: #444;
|
|
||||||
--lighter-gray: #222;
|
|
||||||
--lightest-gray: #1a1a1a;
|
|
||||||
--darker-gray: #b4b4b4;
|
|
||||||
--darkest-gray: #efefef;
|
|
||||||
--article-color: #eaeaea;
|
|
||||||
--header-bg: rgba(19, 20, 21, 0.45);
|
|
||||||
--gray-alpha: rgba(255, 255, 255, 0.5);
|
|
||||||
--selection: rgba(255, 255, 255, 0.99);
|
|
||||||
|
|
||||||
--warning: rgb(27, 134, 23);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] {
|
|
||||||
--token: #666;
|
|
||||||
--comment: #999;
|
|
||||||
--keyword: #000;
|
|
||||||
--name: #333;
|
|
||||||
--highlight: #eaeaea;
|
|
||||||
|
|
||||||
--bg: #fff;
|
|
||||||
--fg: #000;
|
|
||||||
--gray: #888;
|
|
||||||
|
|
||||||
--light-gray: #dedede;
|
|
||||||
--lighter-gray: #f5f5f5;
|
|
||||||
--lightest-gray: #fafafa;
|
|
||||||
--darker-gray: #555;
|
|
||||||
--darkest-gray: #222;
|
|
||||||
--article-color: #212121;
|
|
||||||
--header-bg: rgba(255, 255, 255, 0.8);
|
|
||||||
--gray-alpha: rgba(19, 20, 21, 0.5);
|
|
||||||
--selection: rgba(0, 0, 0, 0.99);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
text-shadow: none;
|
|
||||||
background: var(--selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
hyphens: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1rem;
|
|
||||||
border-left: 3px solid var(--light-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
a.reset {
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre,
|
|
||||||
code {
|
|
||||||
font-family: var(--font-mono) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
:root {
|
|
||||||
--bg: #fff;
|
|
||||||
--fg: #000;
|
|
||||||
--gray: #888;
|
|
||||||
--light-gray: #dedede;
|
|
||||||
--lighter-gray: #f5f5f5;
|
|
||||||
--lightest-gray: #fafafa;
|
|
||||||
--article-color: #212121;
|
|
||||||
--header-bg: rgba(255, 255, 255, 0.8);
|
|
||||||
--gray-alpha: rgba(19, 20, 21, 0.5);
|
|
||||||
--selection: rgba(0, 0, 0, 0.99);
|
|
||||||
|
|
||||||
--token: #666;
|
|
||||||
--comment: #999;
|
|
||||||
--keyword: #000;
|
|
||||||
--name: #333;
|
|
||||||
--highlight: #eaeaea;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
text-shadow: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root,
|
|
||||||
#__next {
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
|
@ -1,100 +0,0 @@
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 200;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 800;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: block;
|
|
||||||
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
|
|
||||||
format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
|
||||||
U+FEFF, U+FFFD;
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"plugins": [{ "name": "typescript-plugin-css-modules" }],
|
|
||||||
"target": "es2020",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"strictFunctionTypes": true,
|
|
||||||
"strictBindCallApply": true,
|
|
||||||
"strictPropertyInitialization": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"alwaysStrict": true,
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@components/*": ["components/*"],
|
|
||||||
"@lib/*": ["lib/*"],
|
|
||||||
"@styles/*": ["styles/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
3196
client/yarn.lock
3196
client/yarn.lock
File diff suppressed because it is too large
Load diff
16
components.json
Normal file
16
components.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@components",
|
||||||
|
"utils": "@utils"
|
||||||
|
}
|
||||||
|
}
|
16
jest.config.js
Normal file
16
jest.config.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
setupFiles: ["<rootDir>/src/test/setup-tests.ts"],
|
||||||
|
// TODO: update to app dir
|
||||||
|
moduleNameMapper: {
|
||||||
|
"@lib/(.*)": "<rootDir>/src/lib/$1",
|
||||||
|
"@components/(.*)": "<rootDir>/src/app/components/$1",
|
||||||
|
"\\.(css)$": "identity-obj-proxy"
|
||||||
|
},
|
||||||
|
testPathIgnorePatterns: ["/node_modules/", "/.next/"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
1
client/next-env.d.ts → next-env.d.ts
vendored
1
client/next-env.d.ts → next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
/// <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.
|
46
next.config.mjs
Normal file
46
next.config.mjs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import bundleAnalyzer from "@next/bundle-analyzer"
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
appDir: true
|
||||||
|
},
|
||||||
|
rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/file/raw/:id",
|
||||||
|
destination: `/api/raw/:id`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/signout",
|
||||||
|
destination: `/api/auth/signout`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
domains: ["avatars.githubusercontent.com"]
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_DRIFT_URL:
|
||||||
|
process.env.DRIFT_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: "http://localhost:3000")
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: process.env.VERCEL_ENV !== "production"
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: process.env.VERCEL_ENV !== "production"
|
||||||
|
},
|
||||||
|
modularizeImports: {
|
||||||
|
"react-feather": {
|
||||||
|
transform: "react-feather/dist/icons/{{kebabCase member}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default process.env.ANALYZE === "true"
|
||||||
|
? bundleAnalyzer({ enabled: true })(nextConfig)
|
||||||
|
: nextConfig
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue