Compare commits

...

165 commits

Author SHA1 Message Date
Max Leiter
0c20460c13 /new page responsiveness 2023-07-20 20:48:02 -07:00
Max Leiter
563136fdb3 fix markdown checkbox colors 2023-07-20 20:24:10 -07:00
dependabot[bot]
bff0dbea38
Bump word-wrap from 1.2.3 to 1.2.4 (#151)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-20 19:13:41 -07:00
Max Leiter
5d5fd3182e
Shadify (#150)
Adds shadcn, bumps dependencies, overhaults lots of code.
2023-07-20 18:04:47 -07:00
PeGaSuS
702f59caf8
Add systemd units (#148)
Added systemd units, both for use as root and normal user
2023-06-10 12:02:24 -07:00
Max Leiter
41e72ba04c Bump next 2023-05-30 13:12:28 -07:00
Max Leiter
0df85776a5 Bump next 2023-05-26 13:34:40 -04:00
Max Leiter
7f4745ade1 Add loading fallback state for date picker 2023-05-21 14:06:51 -07:00
Max Leiter
504d2742f4 post-list <li> a11y improvements 2023-05-20 15:50:57 -07:00
Max Leiter
69ca511cc2 revalidate home page 2023-05-20 15:49:11 -07:00
Max Leiter
a1fa7dbb8a remove @vercel/og, use bundled package 2023-05-20 15:47:37 -07:00
Max Leiter
c416f5d5e8 migrate header info back to client-side 2023-05-20 15:42:21 -07:00
Max Leiter
dc11f8eb0c bump next and deps, fix header buttons 2023-05-20 15:16:50 -07:00
David Schultz
5e4ecbb803
Fix isAdmin check to be by role and not uid (#147)
* isAdmin should be based on role, not uid

* move admin link before sign in/out
2023-05-20 12:35:33 -07:00
Zdeněk Janeček
0f58c44261
fix: build errors (#145) 2023-05-13 00:00:47 -07:00
Max Leiter
d0a73a7cbc fix sign out, theme switch, auth provider icons 2023-04-13 20:13:08 -07:00
Zdeněk Janeček
55e19381a7
fix: add keys to header buttons (#142)
Co-authored-by: Zdeněk Janeček <zdenek.janecek@firma.seznam.cz>
2023-03-28 09:38:05 -07:00
Zdeněk Janeček
3433371930
feat: add keycloak login (#139)
Co-authored-by: Zdeněk Janeček <zdenek.janecek@firma.seznam.cz>
2023-03-28 00:37:27 -07:00
Max Leiter
7887b42404
components/header: rm memo from RSC (#141) 2023-03-28 00:37:10 -07:00
Max Leiter
68fc679864 SSR theme and auth in header. Move auth check to middleware. Bump next 2023-03-27 20:16:11 -07:00
Max Leiter
cb7d9ebc6b Bump next.js and deps 2023-03-01 20:11:48 -08:00
Max Leiter
3041da80e2 Fix useSessionSWR() expecting Drift's API interface instead of next-auths 2023-02-26 18:49:32 -08:00
Max Leiter
a54a22f142 chore: linting 2023-02-26 15:22:05 -08:00
Max Leiter
27a604dc90 fix building by adjusting types 2023-02-26 15:21:43 -08:00
Max Leiter
6cf544fc72 opt-out tests-backup from eslint/tsc 2023-02-26 14:46:04 -08:00
Max Leiter
86e323fbca Add some (WIP) tests 2023-02-26 14:44:32 -08:00
Max Leiter
b64281b1ac Slight shell script improvements 2023-02-26 01:21:08 -08:00
Max Leiter
9c3375cbd0 Add shell script for uploading files to drift 2023-02-26 01:15:17 -08:00
Max Leiter
806b173d22 Make CmdK dynamic 2023-02-25 22:46:55 -08:00
Max Leiter
590cc51ec8 Migrate some CSS to nested 2023-02-25 22:43:11 -08:00
Max Leiter
85f21bf505 Add cmdk, nested postcss 2023-02-25 22:36:29 -08:00
Max Leiter
cc2215629d lint, internally refactor header 2023-02-25 16:29:03 -08:00
Max Leiter
aaf2761004 add total-typescript ts-reset 2023-02-25 16:27:38 -08:00
Max Leiter
88c65f2e9f Bump deps 2023-02-25 16:27:23 -08:00
Max Leiter
6d184906b1 Switch to new Metadata API 2023-02-24 18:07:40 -08:00
Max Leiter
afd18d19a9 Remove /home rewrite, replace with page 2023-02-23 23:56:13 -08:00
Max Leiter
f2d42a6c0c Add ErrorBoundary to /mine, remove header underline 2023-02-23 23:46:26 -08:00
Max Leiter
0b6d31373d Add toast for revoked api key 2023-02-23 23:45:26 -08:00
Max Leiter
d1e5dca3d0 markdown: use --link for link color 2023-02-23 23:44:41 -08:00
Max Leiter
a5704e0a6d @next/font -> next/font 2023-02-23 23:44:02 -08:00
Max Leiter
86c2fb4a73 mobile header improvements 2023-02-23 20:40:07 -08:00
Max Leiter
fec58f2465 dep bumps, add pages feature, bug fixes, type improvements 2023-02-23 20:35:25 -08:00
Max Leiter
e21d896669 Improve list item skeleton on desktop 2023-02-21 23:48:34 -08:00
Max Leiter
9a811e85b5 chore: lint 2023-02-21 23:34:09 -08:00
Max Leiter
817a12fffb Add .env.default, support VERCEL_URL in lib/config 2023-02-21 23:27:03 -08:00
Max Leiter
e51815eb16 Bump next.js and related deps 2023-02-21 23:20:10 -08:00
Max Leiter
64cfe9033e Add (unworking) route handler for raw files 2023-02-21 23:19:55 -08:00
Max Leiter
072516bdb0 temporarily switch to /api/ route for raw files 2023-02-21 22:52:51 -08:00
Max Leiter
b5b4bf08f6 Fix post creation/text input 2023-01-29 00:31:24 -08:00
Max Leiter
98cbbf2347 Add suspense boundar around useSearchParams 2023-01-29 00:24:55 -08:00
Max Leiter
c813ffaf56 convert datepicker to next/dynamic 2023-01-29 00:14:04 -08:00
Max Leiter
eda977b203 ts fixes 2023-01-28 23:58:00 -08:00
Max Leiter
f3d588c0eb button style improvements, homepage and navbar refactors 2023-01-28 23:53:45 -08:00
Max Leiter
a64cc78eed Add loading.tsx for mine 2023-01-28 23:52:04 -08:00
Max Leiter
1acbb52e27 Add react-loading-boundary and error component 2023-01-28 23:51:40 -08:00
Max Leiter
3048d842de Add stack component 2023-01-28 23:51:22 -08:00
Max Leiter
41a7a90bda unstable_getServerSession -> getServerSession 2023-01-28 23:51:14 -08:00
Max Leiter
08abdd4642 bump deps 2023-01-28 21:58:21 -08:00
Max Leiter
be73154b4e some file cleanup, add api/og function 2023-01-23 21:05:17 -08:00
Max Leiter
acfcc04af4 Apply proper align-items to tabs on small screens 2023-01-13 15:50:31 -08:00
Max Leiter
f5e2fd365b Bump next.js to 13.1.2 stable 2023-01-13 15:50:18 -08:00
Max Leiter
16b4a5ae07 Re-implement public search 2023-01-13 00:17:50 -08:00
Max Leiter
ba092152f2 remove use of VERCEL_URL 2023-01-13 00:10:15 -08:00
Max Leiter
7c8e2c9947 Remove unused params 2023-01-13 00:08:23 -08:00
Max Leiter
5b7efc8a06 Fix post searching 2023-01-13 00:01:32 -08:00
Max Leiter
e49ca2e749 Linting, component clean up 2023-01-12 23:56:04 -08:00
Max Leiter
ba732dcd71 refactor to SWR and verifyApiUser; personal post search is broken 2023-01-12 20:50:59 -08:00
Max Leiter
6fb81d77b9 Fix building error 2023-01-07 15:55:25 -08:00
Max Leiter
6b0a6bf3b6 Fix showing password prompt for unauthed protected posts 2023-01-07 15:42:11 -08:00
Max Leiter
a6c8c8c825 Fix signing into correct user with credentials 2023-01-07 15:30:07 -08:00
Max Leiter
6148f8d1e9 Switch default buttonType to secondary 2023-01-07 15:14:56 -08:00
Max Leiter
c51ca39fa7 Eslint changes and linting (no-mix-spaces) 2023-01-07 14:52:27 -08:00
Max Leiter
371dae25d9 README updates for src refactor 2023-01-07 14:37:31 -08:00
Max Leiter
6bbd380392 Rm use-session 2023-01-07 13:04:07 -08:00
Max Leiter
d9e7aa5ecf Bug fixes, code cleanup, made root dir / 2023-01-07 13:02:52 -08:00
Max Leiter
c21ca52a59
README: fix logo path 2023-01-06 12:06:40 -08:00
Max Leiter
98ad33bcd8 API tokens 2023-01-05 21:05:49 -08:00
Max Leiter
b9ab0df7c0 Some styling fixes, potentially fix building w/ docker 2022-12-29 13:50:49 -05:00
Max Leiter
02695345cd formatting icons: refactor CSS/remove borders 2022-12-25 23:00:13 -08:00
Max Leiter
69a40df606 remove unnecessary CSS and !importants 2022-12-25 21:03:24 -08:00
Max Leiter
6aa5301d89 lib/server: move making 1st user admin to next auth event 2022-12-25 20:52:32 -08:00
Max Leiter
604f5d64d0 post/[id]: move function to new file to avoid invalid-segment-export 2022-12-25 20:07:10 -08:00
Max Leiter
b848aa9e40 Style improvements, re-enable themes, bump next 2022-12-25 20:00:26 -08:00
Max Leiter
e41dc292b8 Center icon and text in post list li 2022-12-18 18:35:55 -08:00
Max Leiter
e4b215b7a8 Fix post-list linting 2022-12-18 18:21:12 -08:00
Max Leiter
19c5725847 Add eslint configs, fix lint errors 2022-12-18 18:18:32 -08:00
Max Leiter
631f98aaaf Add @next/font, increase markdown legibility 2022-12-18 17:09:46 -08:00
Max Leiter
a97ba1b9aa Default credential auth to true, readme updates 2022-12-18 13:53:25 -08:00
Max Leiter
f07f4789ee cleanup view-document styling in jsx 2022-12-18 13:46:52 -08:00
Max Leiter
23a850253b File styling adjustments 2022-12-18 01:14:46 -08:00
Max Leiter
5e976bfc0d Fix export from segment config 2022-12-17 23:15:35 -08:00
Max Leiter
3e199cf8d4 remove unnecessary comment 2022-12-17 23:11:45 -08:00
Max Leiter
447974a74a Add deleting users to admin, refactor auth 2022-12-17 23:09:47 -08:00
Max Leiter
65cf59e96b Add credentials provider, fix header active style 2022-12-17 19:42:48 -08:00
Max Leiter
0631ae3897 Fix more types 2022-12-17 17:38:45 -08:00
Max Leiter
82aadd94f2 Use Prisma type utils 2022-12-17 17:30:17 -08:00
Max Leiter
69c482a165 Switch post html/content to Bytes from Text 2022-12-17 17:15:21 -08:00
Max Leiter
34a92a265f Fix /new/from posts 2022-12-17 16:28:49 -08:00
Max Leiter
ff310a67b9 Page/layout optimizations, bump next, styling fixes 2022-12-17 16:22:29 -08:00
Max Leiter
f034f29a1d bump react-hot-toasts to beta 2022-12-16 12:38:45 -08:00
Max Leiter
350575ccd4 intentionally break post rendering 2022-12-16 11:49:10 -08:00
Max Leiter
70212232a0 rename client to src 2022-12-09 19:30:19 -08:00
Max Leiter
aee2330e21 Fix building by addressing missing labels/props for inputs 2022-12-05 16:42:36 -08:00
Max Leiter
7ef45c28f0 Add public post listing to home page 2022-12-04 14:49:18 -08:00
Max Leiter
5918b13867 lint 2022-12-04 14:26:14 -08:00
Max Leiter
72633c6ad2 More uniform home page spacing, close mobile menu on click 2022-12-04 14:26:05 -08:00
Max Leiter
a84dad1dde Update readme to mention refactor branch 2022-12-04 02:07:12 -08:00
Max Leiter
330dbd85b1 bump next-themes 2022-12-04 02:04:47 -08:00
Max Leiter
7eeadbe065 Add basic /author/{id} page 2022-12-04 01:55:20 -08:00
Max Leiter
56eefc8419 add basic admin page, misc fixes 2022-12-04 01:31:51 -08:00
Max Leiter
9b593c849e Bump next and next-themes 2022-12-01 19:59:05 -08:00
Max Leiter
8578714c4a Fix SSG by moving auth from root layout 2022-12-01 19:45:19 -08:00
Max Leiter
44a05f6456 fix /new 2022-11-29 22:22:17 -08:00
Max Leiter
ce0c442273 Remove geist-ui, add loading prop to button, convert header to CSS 2022-11-29 22:10:51 -08:00
Max Leiter
fc79f7df4d bump next, use next-themes, remove geist icons and most of geist core 2022-11-29 00:43:08 -08:00
Max Leiter
23b7343963 bump lockfile 2022-11-28 21:01:22 -08:00
Max Leiter
1d7db6e059 rm vscode settings 2022-11-28 21:00:12 -08:00
Max Leiter
a7660f6374 add prisma to reg deps 2022-11-28 20:59:30 -08:00
Max Leiter
8048e99794 Fix linting issues 2022-11-28 18:36:11 -08:00
Max Leiter
d6894ffb8b remove more of geist-ui: add spinner, button dropdown, toasts. bump deps 2022-11-28 18:33:06 -08:00
Max Leiter
0cab3acd62 enforce label or aria-label on input props 2022-11-20 21:02:13 -08:00
Max Leiter
41ed505362 fix preview rendering on /home, fix signout redirect 2022-11-20 20:54:34 -08:00
Max Leiter
97e4742453 bump next, use custom tabs on /home 2022-11-20 19:26:07 -08:00
Max Leiter
881e693e76 title: don't re-render when updating post 2022-11-17 23:39:52 -08:00
Max Leiter
8fe7299258 fix error badge color 2022-11-17 23:01:48 -08:00
Max Leiter
12d9eafcd9 lint 2022-11-17 22:36:53 -08:00
Max Leiter
4cf448c35d more geist removal; add popover, convert more of post editing and viewing 2022-11-16 02:16:56 -08:00
Max Leiter
3c5dcc24ac Custom tabs 2022-11-16 00:49:12 -08:00
Max Leiter
45c2e59105 use custom input/buttons on postlist/new page 2022-11-15 22:52:25 -08:00
Max Leiter
dfe0d39fa0 remove next-themes, convert header to custom button 2022-11-15 20:50:54 -08:00
Max Leiter
bff7c90e5f Convert card from geist, badge style improvements 2022-11-15 19:07:07 -08:00
Max Leiter
3bebb6ac7d README updates 2022-11-14 19:00:21 -08:00
Max Leiter
2c3e271df1 only run prisma admin middleware if enable_admin 2022-11-14 18:49:58 -08:00
Max Leiter
aef1788747 rm server/ 2022-11-14 18:46:24 -08:00
Max Leiter
37d4dfebcf fix building 2022-11-14 18:45:06 -08:00
Max Leiter
e1ef002300 move react-datepicker.css 2022-11-14 18:40:16 -08:00
Max Leiter
8e7828d562 fix middleware, migrate gist importing 2022-11-14 18:39:42 -08:00
Max Leiter
bc2a4acd29 Fix admin page styling and user table 2022-11-14 17:32:32 -08:00
Max Leiter
c5e276b51c rm old constants 2022-11-14 17:26:37 -08:00
Max Leiter
c31b911c86 md table styles, expiration post fixes, dont render linenumbers 2022-11-14 17:24:35 -08:00
Max Leiter
2b36e3c58e fix admin 404 2022-11-14 01:34:17 -08:00
Max Leiter
f81999241f health check API route 2022-11-14 01:30:09 -08:00
Max Leiter
2b783145d4 fix admin page, expiring view, displayName setting/field 2022-11-14 01:28:40 -08:00
Max Leiter
0627ab7396 fix raw file viewing, rm password from settings, add admin api 2022-11-13 23:28:51 -08:00
Max Leiter
97cff7eb53 use custom badge component, add post deletion 2022-11-13 23:02:31 -08:00
Max Leiter
5f4749ebb3 radix tooltip, move header to layout 2022-11-12 18:39:03 -08:00
Max Leiter
733a93dd87 dep management 2022-11-12 17:19:27 -08:00
Max Leiter
ecd4521403 refactor getting html for files and previews 2022-11-12 17:11:05 -08:00
Max Leiter
c41cf7c5ef rm server code, add markdown rendering, html saving, visibility updating 2022-11-12 16:06:23 -08:00
Max Leiter
096cf41eee gen prisma migration, fix up rendering html (somewhat) in RSC 2022-11-12 01:57:30 -08:00
Max Leiter
86b9172527 fix up colocation 2022-11-12 01:28:06 -08:00
Max Leiter
96da95818f colocate components 2022-11-12 00:58:21 -08:00
Max Leiter
96c4023c14 migrate post page and create post api, misc changes 2022-11-11 23:59:33 -08:00
Max Leiter
60d1b031f5 use next-auth, add sign in via github, switch to postgres 2022-11-11 19:17:44 -08:00
Max Leiter
7c761eb727 actually bump next 2022-11-11 16:34:35 -08:00
Max Leiter
68570b3bb7 bump next, fix background color flash 2022-11-11 16:33:43 -08:00
Max Leiter
8b0b172f7d convert admin, run lint 2022-11-09 23:11:36 -08:00
Max Leiter
cf7d89eb20 cookies fixes, hook improvement, more porting 2022-11-09 19:46:12 -08:00
Max Leiter
95d1ef31ef rework cookies 2022-11-09 19:02:06 -08:00
Max Leiter
9b9c3c1d87 start api transition, prisma additions 2022-11-09 18:38:05 -08:00
Max Leiter
da870d6957 next-themes work, add index app/ page 2022-11-08 22:14:43 -08:00
Max Leiter
6b2b8b8be6 server: upgrade sqlite 2022-11-08 00:24:18 -08:00
Max Leiter
0a5a2adb26 dep improvements, style fixes, next/link codemod 2022-11-08 00:23:28 -08:00
Max Leiter
0405f821c4 switch to pnpm 2022-11-07 21:18:40 -08:00
renovate[bot]
04ed522566
fix(deps): update all non-major dependencies (#111)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-07 12:07:50 -07:00
366 changed files with 19915 additions and 17225 deletions

35
.env.default Normal file
View 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
View 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
View file

@ -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

View file

@ -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
View file

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

131
README.md
View file

@ -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). If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
<hr /> <hr />
**Contents:** **Contents:**
- [Setup](#setup) - [Setup](#setup)
- [Development](#development) - [Development](#development)
- [Production](#production) - [Production](#production)
@ -20,75 +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`
#### Auth environment variables
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
- `GITHUB_CLIENT_SECRET`: the client secret for GitHub OAuth.
- `NEXTAUTH_URL`: the URL of the drift instance. Not required if hosting on Vercel.
- `CREDENTIAL_AUTH`: whether to allow username/password authentication. Defaults to `true`.
## Running with pm2 ## 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

View file

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

View file

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

35
client/.gitignore vendored
View file

@ -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

View file

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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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&apos;t have an account?{" "}
<Link color href="/signup">
Sign up
</Link>
</Text>
) : (
<Text>
Already have an account?{" "}
<Link color href="/signin">
Sign in
</Link>
</Text>
)}
</div>
{errorMsg && (
<Note scale={0.75} type="error">
{errorMsg}
</Note>
)}
</form>
</div>
</div>
)
}
export default Auth

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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

View file

@ -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);
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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

View file

@ -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;
}
}

View file

@ -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) {
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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;
}

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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

View file

@ -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)

View file

@ -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);
}

View file

@ -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

View file

@ -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&apos;t protect your post from the server
administrator.
</Note>
)}
{error && (
<Note type="error" label="Error">
{error}
</Note>
)}
<Spacer />
<Input
width={"100%"}
label="Password"
marginBottom={1}
htmlType="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
{creating && (
<Input
width={"100%"}
label="Confirm"
htmlType="password"
placeholder="Confirm Password"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
)}
</Modal.Content>
<Modal.Action passive onClick={onClose}>
Cancel
</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>
}
</>
)
}
export default PasswordModal

View file

@ -1,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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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%;
}

View file

@ -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)

View file

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

View file

@ -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

View file

@ -1,28 +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(() => {
if (token) {
setSignedIn(true)
} else {
setSignedIn(false)
}
}, [setSignedIn, token])
return { signedIn, signin, token }
}
export default useSignedIn

View file

@ -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
View file

@ -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
}

View file

@ -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
)

View file

@ -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"
]
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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&apos;re trying to view has expired.
</Text>
</Note>
</Page.Content>
</Page>
)
}
export default Expired

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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"]
}

File diff suppressed because it is too large Load diff

16
components.json Normal file
View 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
View 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"
}
}

View file

@ -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
View 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