Compare commits
420 commits
dragAndDro
...
refactor
Author | SHA1 | Date | |
---|---|---|---|
|
0c20460c13 | ||
|
563136fdb3 | ||
|
bff0dbea38 | ||
|
5d5fd3182e | ||
|
702f59caf8 | ||
|
41e72ba04c | ||
|
0df85776a5 | ||
|
7f4745ade1 | ||
|
504d2742f4 | ||
|
69ca511cc2 | ||
|
a1fa7dbb8a | ||
|
c416f5d5e8 | ||
|
dc11f8eb0c | ||
|
5e4ecbb803 | ||
|
0f58c44261 | ||
|
d0a73a7cbc | ||
|
55e19381a7 | ||
|
3433371930 | ||
|
7887b42404 | ||
|
68fc679864 | ||
|
cb7d9ebc6b | ||
|
3041da80e2 | ||
|
a54a22f142 | ||
|
27a604dc90 | ||
|
6cf544fc72 | ||
|
86e323fbca | ||
|
b64281b1ac | ||
|
9c3375cbd0 | ||
|
806b173d22 | ||
|
590cc51ec8 | ||
|
85f21bf505 | ||
|
cc2215629d | ||
|
aaf2761004 | ||
|
88c65f2e9f | ||
|
6d184906b1 | ||
|
afd18d19a9 | ||
|
f2d42a6c0c | ||
|
0b6d31373d | ||
|
d1e5dca3d0 | ||
|
a5704e0a6d | ||
|
86c2fb4a73 | ||
|
fec58f2465 | ||
|
e21d896669 | ||
|
9a811e85b5 | ||
|
817a12fffb | ||
|
e51815eb16 | ||
|
64cfe9033e | ||
|
072516bdb0 | ||
|
b5b4bf08f6 | ||
|
98cbbf2347 | ||
|
c813ffaf56 | ||
|
eda977b203 | ||
|
f3d588c0eb | ||
|
a64cc78eed | ||
|
1acbb52e27 | ||
|
3048d842de | ||
|
41a7a90bda | ||
|
08abdd4642 | ||
|
be73154b4e | ||
|
acfcc04af4 | ||
|
f5e2fd365b | ||
|
16b4a5ae07 | ||
|
ba092152f2 | ||
|
7c8e2c9947 | ||
|
5b7efc8a06 | ||
|
e49ca2e749 | ||
|
ba732dcd71 | ||
|
6fb81d77b9 | ||
|
6b0a6bf3b6 | ||
|
a6c8c8c825 | ||
|
6148f8d1e9 | ||
|
c51ca39fa7 | ||
|
371dae25d9 | ||
|
6bbd380392 | ||
|
d9e7aa5ecf | ||
|
c21ca52a59 | ||
|
98ad33bcd8 | ||
|
b9ab0df7c0 | ||
|
02695345cd | ||
|
69a40df606 | ||
|
6aa5301d89 | ||
|
604f5d64d0 | ||
|
b848aa9e40 | ||
|
e41dc292b8 | ||
|
e4b215b7a8 | ||
|
19c5725847 | ||
|
631f98aaaf | ||
|
a97ba1b9aa | ||
|
f07f4789ee | ||
|
23a850253b | ||
|
5e976bfc0d | ||
|
3e199cf8d4 | ||
|
447974a74a | ||
|
65cf59e96b | ||
|
0631ae3897 | ||
|
82aadd94f2 | ||
|
69c482a165 | ||
|
34a92a265f | ||
|
ff310a67b9 | ||
|
f034f29a1d | ||
|
350575ccd4 | ||
|
70212232a0 | ||
|
aee2330e21 | ||
|
7ef45c28f0 | ||
|
5918b13867 | ||
|
72633c6ad2 | ||
|
a84dad1dde | ||
|
330dbd85b1 | ||
|
7eeadbe065 | ||
|
56eefc8419 | ||
|
9b593c849e | ||
|
8578714c4a | ||
|
44a05f6456 | ||
|
ce0c442273 | ||
|
fc79f7df4d | ||
|
23b7343963 | ||
|
1d7db6e059 | ||
|
a7660f6374 | ||
|
8048e99794 | ||
|
d6894ffb8b | ||
|
0cab3acd62 | ||
|
41ed505362 | ||
|
97e4742453 | ||
|
881e693e76 | ||
|
8fe7299258 | ||
|
12d9eafcd9 | ||
|
4cf448c35d | ||
|
3c5dcc24ac | ||
|
45c2e59105 | ||
|
dfe0d39fa0 | ||
|
bff7c90e5f | ||
|
3bebb6ac7d | ||
|
2c3e271df1 | ||
|
aef1788747 | ||
|
37d4dfebcf | ||
|
e1ef002300 | ||
|
8e7828d562 | ||
|
bc2a4acd29 | ||
|
c5e276b51c | ||
|
c31b911c86 | ||
|
2b36e3c58e | ||
|
f81999241f | ||
|
2b783145d4 | ||
|
0627ab7396 | ||
|
97cff7eb53 | ||
|
5f4749ebb3 | ||
|
733a93dd87 | ||
|
ecd4521403 | ||
|
c41cf7c5ef | ||
|
096cf41eee | ||
|
86b9172527 | ||
|
96da95818f | ||
|
96c4023c14 | ||
|
60d1b031f5 | ||
|
7c761eb727 | ||
|
68570b3bb7 | ||
|
8b0b172f7d | ||
|
cf7d89eb20 | ||
|
95d1ef31ef | ||
|
9b9c3c1d87 | ||
|
da870d6957 | ||
|
6b2b8b8be6 | ||
|
0a5a2adb26 | ||
|
0405f821c4 | ||
|
04ed522566 | ||
|
6a951cad78 | ||
|
a52e9a1c62 | ||
|
bceeb5cee8 | ||
|
b5024e3f45 | ||
|
be6de7c796 | ||
|
3d2bec0d5e | ||
|
a3c733f82e | ||
|
519b6cdc71 | ||
|
a884fdbfef | ||
|
265f7b6161 | ||
|
57cded29c3 | ||
|
3269dfc0dc | ||
|
4177897691 | ||
|
454ea303a6 | ||
|
683cad2a8d | ||
|
c0566efc98 | ||
|
00b03db3ef | ||
|
b9d26e16f7 | ||
|
5df56fbdae | ||
|
2ba613d562 | ||
|
6bb73b877e | ||
|
3c2f902877 | ||
|
4658d90c8c | ||
|
f687152455 | ||
|
43aa68e082 | ||
|
3d747f41cc | ||
|
7ce6acf5fe | ||
|
16103f2fcb | ||
|
7d08570915 | ||
|
7d5afbc682 | ||
|
9df1a22aa7 | ||
|
16d5780110 | ||
|
67e1b9889b | ||
|
4bcf791c86 | ||
|
873db86fb1 | ||
|
917e85196e | ||
|
9850c9a9ca | ||
|
6481de22d4 | ||
|
90d9fabd27 | ||
|
d17e240e1c | ||
|
05826aa344 | ||
|
fe589d63d8 | ||
|
1369bdf996 | ||
|
f510813e4b | ||
|
481d4ae36c | ||
|
83def0ec86 | ||
|
401a0df63b | ||
|
36e255ad2b | ||
|
c44ab907bb | ||
|
8ada3a6300 | ||
|
b6af63671b | ||
|
d1415d1ee2 | ||
|
9fe9b818c4 | ||
|
bc6362e412 | ||
|
2a9e7ba6fc | ||
|
18dff00a93 | ||
|
5dabbbe64b | ||
|
2ecf1b21ca | ||
|
c73b7f66a3 | ||
|
0e57e28b6c | ||
|
6c39d1c7c0 | ||
|
c6f89a28ad | ||
|
b6439858df | ||
|
9cbcfd3397 | ||
|
32cc1f861e | ||
|
808314658d | ||
|
e5b9b65b55 | ||
|
06d847dfa3 | ||
|
3a879edc23 | ||
|
d495d7b222 | ||
|
f8ba5b32c9 | ||
|
f6cd545ca7 | ||
|
76a2b50c6b | ||
|
ef005ef0b2 | ||
|
5e9288e9fb | ||
|
52dc5e41a5 | ||
|
a1fef656bb | ||
|
e7cec9b827 | ||
|
763cb1dadc | ||
|
b8cdc2cf72 | ||
|
ce01eba9c0 | ||
|
f927fae9ed | ||
|
06fad98ee1 | ||
|
f20fa72b6d | ||
|
ead3b0af9d | ||
|
702dad14cb | ||
|
60b21a1d9d | ||
|
24157ff10e | ||
|
64e9c58d5d | ||
|
dafc0c37f8 | ||
|
8da6d62cea | ||
|
6a6a2a3496 | ||
|
1c2fef0ee4 | ||
|
dee06fab90 | ||
|
e3e9d993f2 | ||
|
eae627807b | ||
|
8291010f26 | ||
|
222b020e9a | ||
|
5da96a8f0a | ||
|
88d14a40b1 | ||
|
76e7bb8013 | ||
|
47cd9cc094 | ||
|
93e8b7e1d9 | ||
|
9f810378f1 | ||
|
752b2c0980 | ||
|
f1381e30b9 | ||
|
9cc3db414f | ||
|
d24e94da04 | ||
|
0504bd57e2 | ||
|
6de415ed99 | ||
|
871b57ea3c | ||
|
009aefdb8a | ||
|
a84459b859 | ||
|
62a77b619e | ||
|
57f9966729 | ||
|
29743a67a5 | ||
|
6f811f66a5 | ||
|
85ae8173bb | ||
|
6afc4c915e | ||
|
7505bb43fe | ||
|
fb8f14fd98 | ||
|
fd7d0be6ba | ||
|
333e3647e0 | ||
|
e0b0102603 | ||
|
1c411f3bdc | ||
|
ac1cf27d56 | ||
|
73e2edfe2b | ||
|
de54754833 | ||
|
e12e20418a | ||
|
5ac73718cf | ||
|
62bc7af004 | ||
|
9d9f2d98a7 | ||
|
945d3fbe63 | ||
|
0815d43ee8 | ||
|
887ecfabbc | ||
|
ff8d5aab5c | ||
|
1ace04985c | ||
|
dfea957046 | ||
|
448c443e2e | ||
|
12f25c49a7 | ||
|
bb893fa6ba | ||
|
a61f2c00e2 | ||
|
5aca059953 | ||
|
7c6eb87870 | ||
|
21332a7d1e | ||
|
951088bacf | ||
|
9949faeebd | ||
|
c1c5af2b18 | ||
|
b77265e6b6 | ||
|
e5f467b26a | ||
|
2823c217ea | ||
|
056a2bd3ce | ||
|
da8e7415dc | ||
|
b93e42a347 | ||
|
41238cb79f | ||
|
8b2a22e3d3 | ||
|
de68796101 | ||
|
6a4ff9c307 | ||
|
6045200ac4 | ||
|
186d536175 | ||
|
7c857cf318 | ||
|
2a1f95238b | ||
|
bbee452250 | ||
|
7ca0cbac4c | ||
|
c55ca681b4 | ||
|
60f2ab99b3 | ||
|
48a8e9f6a9 | ||
|
d4120e6f41 | ||
|
9bdff8f28f | ||
|
534cd87dc9 | ||
|
a139acc747 | ||
|
f92d854336 | ||
|
c0c18e5b61 | ||
|
ef16bfc565 | ||
|
26a9639589 | ||
|
0a724f6f97 | ||
|
118c06f272 | ||
|
19988e49ed | ||
|
30e32e33cf | ||
|
eaffebb53c | ||
|
34b1ab979f | ||
|
d1ee9d857f | ||
|
da46422764 | ||
|
d83cdf3eeb | ||
|
97f354a271 | ||
|
12cc8bccaa | ||
|
266848e6b2 | ||
|
c1dcfb6a58 | ||
|
ecd06a2258 | ||
|
a3130e6d2a | ||
|
fb38ecc932 | ||
|
d30c34deec | ||
|
90fa28ad65 | ||
|
3efbeb726f | ||
|
2ec4019ea2 | ||
|
d06d0ffea2 | ||
|
1c68aa9765 | ||
|
87da281f1b | ||
|
ba1efe3a9e | ||
|
e37bd00a13 | ||
|
dc64972188 | ||
|
5b3d69d4a7 | ||
|
3f0212c5c6 | ||
|
65b0c8f7f3 | ||
|
bf878473af | ||
|
abe419daba | ||
|
a5e4c0ef75 | ||
|
594e903fe4 | ||
|
b84d982be2 | ||
|
c4cd55f4e6 | ||
|
e2c5e2dac9 | ||
|
c57e0d6692 | ||
|
3f8511e0c1 | ||
|
2fbcb41cdd | ||
|
921f219c5a | ||
|
9ba17db6f9 | ||
|
8117eb8b8a | ||
|
dd0f38bd8b | ||
|
1eb998c7d6 | ||
|
8324a26eb6 | ||
|
d407c2f546 | ||
|
7b2baad782 | ||
|
c9f84fe69c | ||
|
9d6db0c40b | ||
|
59d33042f2 | ||
|
c720b929ce | ||
|
e646df43f2 | ||
|
3ac9cbcf4e | ||
|
ac9027c522 | ||
|
7364eb668b | ||
|
79a8f498c5 | ||
|
d75819a02a | ||
|
a92062414f | ||
|
ecbd0584c2 | ||
|
ed3d413eab | ||
|
1f0d60424e | ||
|
10c8b02397 | ||
|
a361b16293 | ||
|
2ba1dd7f26 | ||
|
80bcf68a7f | ||
|
a490b94bce | ||
|
0e0c4e36ac | ||
|
39eb4aae04 | ||
|
7f50654da4 | ||
|
721d32fc35 | ||
|
bbbbb19d26 | ||
|
ace6de42bd | ||
|
66c8a96e6a | ||
|
64d37df5a3 | ||
|
f9e9c6fe06 | ||
|
606e38e192 | ||
|
988b05d52d | ||
|
a251d7f764 | ||
|
6c0c45091f | ||
|
54adafa41d |
35
.env.default
Normal file
|
@ -0,0 +1,35 @@
|
|||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
|
||||
# Optional if you use Vercel (defaults to VERCEL_URL).
|
||||
# Necessary in development unless you use the vercel CLI (`vc dev`)
|
||||
DRIFT_URL=http://localhost:3000
|
||||
|
||||
# Optional: The first user becomes an admin. Defaults to false
|
||||
ENABLE_ADMIN=false
|
||||
|
||||
# Required: Next auth secret is a required valid JWT secret. You can generate one with `openssl rand -hex 32`
|
||||
NEXTAUTH_SECRET=7f8b8b5c5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5f5
|
||||
|
||||
# Required: but unnecessary if you use a supported host like Vercel
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Optional: for locking your instance
|
||||
REGISTRATION_PASSWORD=
|
||||
|
||||
# Optional: for if you want GitHub oauth. Currently incompatible with the registration password
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Optional: if you want Keycloak oauth. Currently incompatible with the registration password
|
||||
KEYCLOAK_ID=
|
||||
KEYCLOAK_SECRET=
|
||||
KEYCLOAK_ISSUER= # keycloak path including realm
|
||||
KEYCLOAK_NAME=
|
||||
|
||||
# Optional: if you want to support credential auth (username/password, supports registration password)
|
||||
# Defaults to true
|
||||
CREDENTIAL_AUTH=true
|
||||
|
||||
# Optional:
|
||||
WELCOME_CONTENT=
|
||||
WELCOME_TITLE=
|
14
.eslintrc.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
github: MaxLeiter
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: Feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
26
.github/workflows/server-CI.yaml
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Install deps
|
||||
run: yarn
|
||||
- name: Run tests
|
||||
run: yarn test
|
1
client/.gitignore → .gitignore
vendored
|
@ -4,6 +4,7 @@
|
|||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
analyze
|
||||
|
||||
# testing
|
||||
/coverage
|
8
.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"useTabs": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"dotenv.enableAutocloaking": false
|
||||
}
|
16
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
## Contributing
|
||||
|
||||
Thank you for your interest in Drift!
|
||||
|
||||
### I want to report a bug
|
||||
|
||||
Look at the open and closed issues to see if this was not already discussed before. If you can't see any, feel free to open a new issue.
|
||||
If you think you discovered a security vulnerability, do not open a public issue on GitHub. Please email maxwell.leiter@gmail.com in the interest of responsible disclosure.
|
||||
|
||||
### I want to contribute to the code
|
||||
|
||||
Make sure to discuss your ideas with the community in an issue or on the IRC channel.
|
||||
Take a look at the open issues labeled as help wanted or good first issue if you want to help without having a specific idea in mind.
|
||||
Make sure that your PRs do not contain unnecessary commits or merge commits. Squash commits whenever possible.
|
||||
Rebase (instead of merge) outdated PRs on the master branch.
|
||||
Give extra care to your commit messages. Use the imperative present tense and follow Tim Pope's guidelines.
|
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 Max Leiter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
152
README.md
|
@ -1,29 +1,147 @@
|
|||
# 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 (almost, no database yet) 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).
|
||||
|
||||
## Current status
|
||||
Drit 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 built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
|
||||
|
||||
- [x] creating and sharing private, public, unlisted posts
|
||||
- [x] syntax highlighting (detected by file extension)
|
||||
- [x] multiple files per post
|
||||
- [ ] uploading files via drag-and-drop
|
||||
<hr />
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Development](#development)
|
||||
- [Production](#production)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Running with pm2](#running-with-pm2)
|
||||
- [Running with Docker](#running-with-docker)
|
||||
- [Current status](#current-status)
|
||||
|
||||
## Setup
|
||||
|
||||
### Development
|
||||
|
||||
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation).
|
||||
You can run `pnpm dev` in `client` for file watching and live reloading.
|
||||
|
||||
To work with [prisma](prisma.io/), you can use `pnpm prisma` or `pnpm exec prisma` to interact with the database.
|
||||
|
||||
### Production
|
||||
|
||||
`pnpm build` will produce production code. `pnpm start` will start the Next.js server.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can change these to your liking.
|
||||
|
||||
`.env`:
|
||||
|
||||
- `DRIFT_URL`: the URL of the drift instance.
|
||||
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`.
|
||||
- `WELCOME_CONTENT`: a markdown string that's rendered on the home page
|
||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
||||
- `ENABLE_ADMIN`: the first account created is an administrator account
|
||||
- `REGISTRATION_PASSWORD`: the password required to register an account. If not set, no password is required.
|
||||
- `NODE_ENV`: defaults to development, can be `production`
|
||||
|
||||
#### Auth environment variables
|
||||
|
||||
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
|
||||
|
||||
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
|
||||
- `GITHUB_CLIENT_SECRET`: the client secret for GitHub OAuth.
|
||||
- `NEXTAUTH_URL`: the URL of the drift instance. Not required if hosting on Vercel.
|
||||
- `CREDENTIAL_AUTH`: whether to allow username/password authentication. Defaults to `true`.
|
||||
|
||||
## Running with pm2
|
||||
|
||||
It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
|
||||
First, add the `.env` file with your values (see the above section for the required options).
|
||||
|
||||
Then, use the following command to start the server:
|
||||
|
||||
- `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 systemd
|
||||
|
||||
_**NOTE:** We assume that you know how to enable user lingering if you don't want to use the systemd unit as root_
|
||||
|
||||
- As root
|
||||
- Place the following systemd unit in ___/etc/systemd/system___ and name it _drift.service_
|
||||
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
|
||||
|
||||
```
|
||||
##########
|
||||
# Drift Systemd Unit (Global)
|
||||
##########
|
||||
[Unit]
|
||||
Description=Drift Server (Global)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
User=$USERNAME
|
||||
Group=$USERNAME
|
||||
Type=simple
|
||||
WorkingDirectory=/home/$USERNAME/Drift
|
||||
ExecStart=/usr/bin/pnpm start
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
- As a nomal user
|
||||
- Place the following systemd unit inside ___/home/user/.config/systemd/user___ and name it _drift_user.service_
|
||||
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
|
||||
|
||||
```
|
||||
##########
|
||||
# Drift Systemd Unit (User)
|
||||
##########
|
||||
[Unit]
|
||||
Description=Drift Server (User)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/$USERNAME/Drift
|
||||
ExecStart=/usr/bin/pnpm start
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
## Current status
|
||||
|
||||
Drift is a work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
|
||||
|
||||
- [x] Next.js 13 `app` directory
|
||||
- [x] creating and sharing private, public, password-protected, and unlisted posts
|
||||
- [x] syntax highlighting
|
||||
- [x] expiring posts
|
||||
- [x] responsive UI
|
||||
- [x] user auth
|
||||
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
||||
- [ ] downloading files (individually and entire posts)
|
||||
- [ ] password protected posts
|
||||
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
||||
- [ ] non-node backend
|
||||
- [ ] administrator account / settings
|
||||
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
|
||||
- [x] SSO via GitHub OAuth
|
||||
- [x] downloading files (individually and entire posts)
|
||||
- [x] password protected posts
|
||||
- [x] postgres database
|
||||
- [x] administrator account / settings
|
||||
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
|
||||
- [ ] publish docker builds
|
||||
- [ ] user settings
|
||||
- [ ] works enough with JavaScript disabled
|
||||
- [ ] documentation
|
||||
- [ ] 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?
|
||||
- [ ] in-depth documentation
|
||||
- [x] customizable homepage, so the demo can exist as-is but other instances can be built from the same source. Environment variable for the file contents?
|
||||
- [ ] fleshed out API
|
||||
- [ ] Swappable database backends
|
||||
- [ ] More OAuth providers
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
|
@ -1 +0,0 @@
|
|||
API_URL=http://localhost:3000
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
# Install dependencies only when needed
|
||||
FROM node:16-alpine AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# If using npm with a `package-lock.json` comment out above and use below instead
|
||||
# COPY package.json package-lock.json ./
|
||||
# RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
# RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# You only need to copy next.config.js if you are NOT using the default configuration
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV PORT 3001
|
||||
|
||||
CMD ["node", "server.js"]
|
|
@ -1,34 +0,0 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
@ -1,12 +0,0 @@
|
|||
import { Link as GeistLink, LinkProps } 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.substr(1) : props.href;
|
||||
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
|
||||
(href)
|
||||
return <GeistLink {...props} href={href} />
|
||||
}
|
||||
|
||||
export default Link
|
|
@ -1,21 +0,0 @@
|
|||
.container {
|
||||
padding: 2rem 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.formHeader {
|
||||
margin-bottom: 1rem;
|
||||
}
|
|
@ -1,100 +0,0 @@
|
|||
import { FormEvent, useState } from 'react'
|
||||
import { Button, Card, Input, Text } from '@geist-ui/core'
|
||||
import styles from './auth.module.css'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from '../Link'
|
||||
|
||||
const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const signingIn = page === 'signin'
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
|
||||
const handleJson = (json: any) => {
|
||||
if (json.error) {
|
||||
setError(json.error.message)
|
||||
} else {
|
||||
localStorage.setItem('drift-token', json.token)
|
||||
localStorage.setItem('drift-userid', json.userId)
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
const reqOpts = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
if (signingIn) {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/signin', reqOpts)
|
||||
const json = await resp.json()
|
||||
handleJson(json)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Something went wrong")
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/signup', reqOpts)
|
||||
const json = await resp.json()
|
||||
handleJson(json)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Something went wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formHeader}>
|
||||
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<div className={styles.formGroup}>
|
||||
<Input
|
||||
htmlType="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username"
|
||||
required
|
||||
label='Username'
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<Input
|
||||
htmlType='password'
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
label='Password'
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button type="success" ghost htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
{signingIn && <Text>Don't have an account? <Link color href="/signup" >Sign up</Link></Text>}
|
||||
{!signingIn && <Text>Already have an account? <Link color href="/signin" >Sign in</Link></Text>}
|
||||
</div>
|
||||
{error && <Text type='error'>{error}</Text>}
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default Auth
|
|
@ -1,31 +0,0 @@
|
|||
.input {
|
||||
background: #efefef;
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 400px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.fileNameContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.fileNameContainer {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.fileNameContainer > div {
|
||||
/* Override geist-ui styling */
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: 100%;
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
import { Button, Card, Input, Spacer, Tabs, Textarea } from "@geist-ui/core"
|
||||
import { ChangeEvent, memo, useMemo, useRef, useState } from "react"
|
||||
import styles from './document.module.css'
|
||||
import MarkdownPreview from '../preview'
|
||||
import { Trash } from '@geist-ui/icons'
|
||||
import FormattingIcons from "../formatting-icons"
|
||||
import Skeleton from "react-loading-skeleton"
|
||||
type Props = {
|
||||
editable?: boolean
|
||||
remove?: () => void
|
||||
title?: string
|
||||
content?: string
|
||||
setTitle?: (title: string) => void
|
||||
setContent?: (content: string) => void
|
||||
initialTab?: "edit" | "preview"
|
||||
skeleton?: boolean
|
||||
}
|
||||
|
||||
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton }: Props) => {
|
||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const height = editable ? "500px" : '100%'
|
||||
|
||||
const handleTabChange = (newTab: string) => {
|
||||
if (newTab === 'edit') {
|
||||
codeEditorRef.current?.focus()
|
||||
}
|
||||
setTab(newTab as 'edit' | 'preview')
|
||||
}
|
||||
|
||||
const getType = useMemo(() => {
|
||||
if (!title) return
|
||||
const pathParts = title.split(".")
|
||||
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
|
||||
return language
|
||||
}, [title])
|
||||
|
||||
const removeFile = (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()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skeleton) {
|
||||
return <>
|
||||
<Spacer height={1} />
|
||||
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||
<div className={styles.fileNameContainer}>
|
||||
<Skeleton width={275} height={36} />
|
||||
{editable && <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 >
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Spacer height={1} />
|
||||
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||
<div className={styles.fileNameContainer}>
|
||||
<Input
|
||||
placeholder="MyFile.md"
|
||||
value={title}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null}
|
||||
marginTop="var(--gap-double)"
|
||||
size={1.2}
|
||||
font={1.2}
|
||||
label="Filename"
|
||||
disabled={!editable}
|
||||
width={"100%"}
|
||||
/>
|
||||
{remove && editable && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
|
||||
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
||||
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit">
|
||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Textarea
|
||||
ref={codeEditorRef}
|
||||
placeholder="Type some contents..."
|
||||
value={content}
|
||||
onChange={(event) => setContent ? setContent(event.target.value) : null}
|
||||
width="100%"
|
||||
disabled={!editable}
|
||||
// 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">
|
||||
<MarkdownPreview height={height} content={content} type={getType} />
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
|
||||
</div >
|
||||
</Card >
|
||||
<Spacer height={1} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default memo(Document)
|
|
@ -1,139 +0,0 @@
|
|||
import { ButtonGroup, Button } from "@geist-ui/core"
|
||||
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
|
||||
import { RefObject, useCallback, useMemo } from "react"
|
||||
|
||||
// TODO: clean up
|
||||
|
||||
const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTMLTextAreaElement>, setText?: (text: string) => void }) => {
|
||||
// const { textBefore, textAfter, selectedText } = useMemo(() => {
|
||||
// if (textareaRef && textareaRef.current) {
|
||||
// const textarea = textareaRef.current
|
||||
// const text = textareaRef.current.value
|
||||
// const selectionStart = textarea.selectionStart
|
||||
// const selectionEnd = textarea.selectionEnd
|
||||
// const textBefore = text.substring(0, selectionStart)
|
||||
// const textAfter = text.substring(selectionEnd)
|
||||
// const selectedText = text.substring(selectionStart, selectionEnd)
|
||||
// return { textBefore, textAfter, selectedText }
|
||||
// }
|
||||
// return { textBefore: '', textAfter: '' }
|
||||
// }, [textareaRef,])
|
||||
|
||||
const handleBoldClick = useCallback((e) => {
|
||||
if (textareaRef?.current && setText) {
|
||||
const selectionStart = textareaRef.current.selectionStart
|
||||
const selectionEnd = textareaRef.current.selectionEnd
|
||||
const text = textareaRef.current.value
|
||||
const before = text.substring(0, selectionStart)
|
||||
const after = text.substring(selectionEnd)
|
||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||
|
||||
const newText = `${before}**${selectedText}**${after}`
|
||||
setText(newText)
|
||||
|
||||
// TODO; fails because settext async
|
||||
textareaRef.current.setSelectionRange(before.length + 2, before.length + 2 + selectedText.length)
|
||||
}
|
||||
}, [setText, textareaRef])
|
||||
|
||||
const handleItalicClick = useCallback((e) => {
|
||||
if (textareaRef?.current && setText) {
|
||||
const selectionStart = textareaRef.current.selectionStart
|
||||
const selectionEnd = textareaRef.current.selectionEnd
|
||||
const text = textareaRef.current.value
|
||||
const before = text.substring(0, selectionStart)
|
||||
const after = text.substring(selectionEnd)
|
||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||
const newText = `${before}*${selectedText}*${after}`
|
||||
setText(newText)
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||
}
|
||||
}, [setText, textareaRef])
|
||||
|
||||
const handleLinkClick = useCallback((e) => {
|
||||
if (textareaRef?.current && setText) {
|
||||
const selectionStart = textareaRef.current.selectionStart
|
||||
const selectionEnd = textareaRef.current.selectionEnd
|
||||
const text = textareaRef.current.value
|
||||
const before = text.substring(0, selectionStart)
|
||||
const after = text.substring(selectionEnd)
|
||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||
let formattedText = '';
|
||||
if (selectedText.includes('http')) {
|
||||
formattedText = `[](${selectedText})`
|
||||
} else {
|
||||
formattedText = `[${selectedText}](https://)`
|
||||
}
|
||||
const newText = `${before}${formattedText}${after}`
|
||||
setText(newText)
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||
}
|
||||
}, [setText, textareaRef])
|
||||
|
||||
const handleImageClick = useCallback((e) => {
|
||||
if (textareaRef?.current && setText) {
|
||||
const selectionStart = textareaRef.current.selectionStart
|
||||
const selectionEnd = textareaRef.current.selectionEnd
|
||||
const text = textareaRef.current.value
|
||||
const before = text.substring(0, selectionStart)
|
||||
const after = text.substring(selectionEnd)
|
||||
const selectedText = text.substring(selectionStart, selectionEnd)
|
||||
let formattedText = '';
|
||||
if (selectedText.includes('http')) {
|
||||
formattedText = `![](${selectedText})`
|
||||
} else {
|
||||
formattedText = `![${selectedText}](https://)`
|
||||
}
|
||||
const newText = `${before}${formattedText}${after}`
|
||||
setText(newText)
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||
}
|
||||
}, [setText, textareaRef])
|
||||
|
||||
const formattingActions = useMemo(() => [
|
||||
{
|
||||
icon: <Bold />,
|
||||
name: 'bold',
|
||||
action: handleBoldClick
|
||||
},
|
||||
{
|
||||
icon: <Italic />,
|
||||
name: 'italic',
|
||||
action: handleItalicClick
|
||||
},
|
||||
// {
|
||||
// icon: <Underline />,
|
||||
// name: 'underline',
|
||||
// action: handleUnderlineClick
|
||||
// },
|
||||
{
|
||||
icon: <Link />,
|
||||
name: 'hyperlink',
|
||||
action: handleLinkClick
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon />,
|
||||
name: 'image',
|
||||
action: handleImageClick
|
||||
}
|
||||
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<ButtonGroup style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}>
|
||||
{formattingActions.map(({ icon, name, action }) => (
|
||||
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default FormattingIcons
|
|
@ -1,41 +0,0 @@
|
|||
import React from 'react'
|
||||
import MoonIcon from '@geist-ui/icons/moon'
|
||||
import SunIcon from '@geist-ui/icons/sun'
|
||||
import { Select } from '@geist-ui/core'
|
||||
import { ThemeProps } from '../../pages/_app'
|
||||
// import { useAllThemes, useTheme } from '@geist-ui/core'
|
||||
import styles from './header.module.css'
|
||||
|
||||
const Controls = ({ changeTheme, theme }: ThemeProps) => {
|
||||
const switchThemes = (type: string | string[]) => {
|
||||
changeTheme()
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
window.localStorage.setItem('drift-theme', Array.isArray(type) ? type[0] : type)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Select
|
||||
scale={0.5}
|
||||
h="28px"
|
||||
pure
|
||||
onChange={switchThemes}
|
||||
value={theme}
|
||||
>
|
||||
<Select.Option value="light">
|
||||
<span className={styles.selectContent}>
|
||||
<SunIcon size={14} /> Light
|
||||
</span>
|
||||
</Select.Option>
|
||||
<Select.Option value="dark">
|
||||
<span className={styles.selectContent}>
|
||||
<MoonIcon size={14} /> Dark
|
||||
</span>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Controls);
|
|
@ -1,43 +0,0 @@
|
|||
.tabs {
|
||||
flex: 1 1;
|
||||
padding: 0 var(--gap);
|
||||
}
|
||||
|
||||
.mobile {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 650px) {
|
||||
.tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.controls button:active,
|
||||
.controls button:focus,
|
||||
.controls button:hover {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.selectContent {
|
||||
width: auto;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
|
@ -1,221 +0,0 @@
|
|||
import { Page, ButtonGroup, Button, useBodyScroll, useMediaQuery, Tabs, Spacer } from "@geist-ui/core";
|
||||
import { Github as GitHubIcon, UserPlus as SignUpIcon, User as SignInIcon, Home as HomeIcon, Menu as MenuIcon, Tool as SettingsIcon, UserX as SignoutIcon, PlusCircle as NewIcon, List as YourIcon, Moon, Sun } from "@geist-ui/icons";
|
||||
import { DriftProps } from "../../pages/_app";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import styles from './header.module.css';
|
||||
import { useRouter } from "next/router";
|
||||
import useSignedIn from "../../lib/hooks/use-signed-in";
|
||||
|
||||
type Tab = {
|
||||
name: string
|
||||
icon: JSX.Element
|
||||
condition?: boolean
|
||||
value: string
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
}
|
||||
|
||||
|
||||
const Header = ({ changeTheme, theme }: DriftProps) => {
|
||||
const router = useRouter();
|
||||
const [selectedTab, setSelectedTab] = useState<string>();
|
||||
const [expanded, setExpanded] = useState<boolean>(false)
|
||||
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
|
||||
const isMobile = useMediaQuery('xs', { match: 'down' })
|
||||
const { isLoading, isSignedIn, signout } = useSignedIn({ redirectIfNotAuthed: false })
|
||||
const [pages, setPages] = useState<Tab[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setBodyHidden(expanded)
|
||||
}, [expanded, setBodyHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setExpanded(false)
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
useEffect(() => {
|
||||
const pageList: Tab[] = [
|
||||
{
|
||||
name: "Home",
|
||||
href: "/",
|
||||
icon: <HomeIcon />,
|
||||
condition: true,
|
||||
value: "home"
|
||||
},
|
||||
{
|
||||
name: "New",
|
||||
href: "/new",
|
||||
icon: <NewIcon />,
|
||||
condition: isSignedIn,
|
||||
value: "new"
|
||||
},
|
||||
{
|
||||
name: "Yours",
|
||||
href: "/mine",
|
||||
icon: <YourIcon />,
|
||||
condition: isSignedIn,
|
||||
value: "mine"
|
||||
},
|
||||
// {
|
||||
// name: "Settings",
|
||||
// href: "/settings",
|
||||
// icon: <SettingsIcon />,
|
||||
// condition: isSignedIn
|
||||
// },
|
||||
{
|
||||
name: "Sign out",
|
||||
onClick: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.clear();
|
||||
|
||||
// // send token to API blacklist
|
||||
// fetch('/api/auth/signout', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json'
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// token: localStorage.getItem("drift-token")
|
||||
// })
|
||||
// })
|
||||
|
||||
signout();
|
||||
router.push("/signin");
|
||||
}
|
||||
},
|
||||
href: "#signout",
|
||||
icon: <SignoutIcon />,
|
||||
condition: isSignedIn,
|
||||
value: "signout"
|
||||
},
|
||||
{
|
||||
name: "Sign in",
|
||||
href: "/signin",
|
||||
icon: <SignInIcon />,
|
||||
condition: !isSignedIn,
|
||||
value: "signin"
|
||||
},
|
||||
{
|
||||
name: "Sign up",
|
||||
href: "/signup",
|
||||
icon: <SignUpIcon />,
|
||||
condition: !isSignedIn,
|
||||
value: "signup"
|
||||
},
|
||||
{
|
||||
name: isMobile ? "GitHub" : "",
|
||||
href: "https://github.com/maxleiter/drift",
|
||||
icon: <GitHubIcon />,
|
||||
condition: true,
|
||||
value: "github"
|
||||
},
|
||||
{
|
||||
name: isMobile ? "Change theme" : "",
|
||||
onClick: function () {
|
||||
if (typeof window !== 'undefined') {
|
||||
changeTheme();
|
||||
setSelectedTab(undefined);
|
||||
}
|
||||
},
|
||||
icon: theme === 'light' ? <Moon /> : <Sun />,
|
||||
condition: true,
|
||||
value: "theme",
|
||||
}
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return setPages([])
|
||||
}
|
||||
|
||||
setPages(pageList.filter(page => page.condition))
|
||||
}, [changeTheme, isLoading, isMobile, isSignedIn, router, signout, theme])
|
||||
|
||||
// useEffect(() => {
|
||||
// setSelectedTab(pages.find((page) => {
|
||||
// console.log(page.href, router.asPath)
|
||||
// if (page.href && page.href === router.asPath) {
|
||||
// return true
|
||||
// }
|
||||
// })?.href)
|
||||
// }, [pages, router, router.pathname])
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
const match = pages.find(page => page.value === tab)
|
||||
if (match?.onClick) {
|
||||
match.onClick()
|
||||
} else if (match?.href) {
|
||||
router.push(`${match.href}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
|
||||
<div className={styles.tabs}>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
leftSpace={0}
|
||||
align="center"
|
||||
hideDivider
|
||||
hideBorder
|
||||
onChange={onTabChange}>
|
||||
{!isLoading && pages.map((tab) => {
|
||||
return <Tabs.Item
|
||||
font="14px"
|
||||
label={<>{tab.icon} {tab.name}</>}
|
||||
value={tab.value}
|
||||
key={`${tab.value}`}
|
||||
/>
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
auto
|
||||
type="abort"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Spacer height={5 / 6} width={0} />
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{isMobile && expanded && (<div className={styles.mobile}>
|
||||
<ButtonGroup vertical>
|
||||
{pages.map((tab, index) => {
|
||||
return <Button
|
||||
key={`${tab.name}-${index}`}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
icon={tab.icon}
|
||||
>
|
||||
{tab.name}
|
||||
</Button>
|
||||
})}
|
||||
</ButtonGroup>
|
||||
</div>)}
|
||||
</Page.Header >
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
||||
|
||||
// {/* {/* <ButtonGroup>
|
||||
// <Button onClick={() => {
|
||||
|
||||
// }}><Link href="/signin">Sign out</Link></Button>
|
||||
// <Button>
|
||||
// <Link href="/mine">
|
||||
// Yours
|
||||
// </Link>
|
||||
// </Button>
|
||||
// <Button>
|
||||
// {/* TODO: Link outside Button, but seems to break ButtonGroup */}
|
||||
// <Link href="/new">
|
||||
// New
|
||||
// </Link>
|
||||
// </Button >
|
||||
// <Button onClick={() => changeTheme()}>
|
||||
// <ShiftBy y={6}>{theme.type === 'light' ? <Moon /> : <Sun />}</ShiftBy>
|
||||
// </Button>
|
||||
// </ButtonGroup > * /}
|
|
@ -1,16 +0,0 @@
|
|||
import useSWR from "swr"
|
||||
import PostList from "../post-list"
|
||||
|
||||
const fetcher = (url: string) => fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("drift-token")}`
|
||||
},
|
||||
}).then(r => r.json())
|
||||
|
||||
const MyPosts = () => {
|
||||
const { data, error } = useSWR('/api/users/mine', fetcher)
|
||||
return <PostList posts={data} error={error} />
|
||||
}
|
||||
|
||||
export default MyPosts
|
|
@ -1,40 +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: border 0.24s ease-in-out;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
font-size: 0.8rem;
|
||||
transition: border 0.24s ease-in-out;
|
||||
border: 2px solid red;
|
||||
border-radius: 2px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error > li:before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.error ul {
|
||||
margin: 0;
|
||||
padding-left: var(--gap-double);
|
||||
}
|
|
@ -1,178 +0,0 @@
|
|||
import { Button, Text, useTheme, useToasts } from '@geist-ui/core'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import styles from './drag-and-drop.module.css'
|
||||
import { Document } from '../'
|
||||
import generateUUID from '../../../lib/generate-uuid'
|
||||
import { XCircle } from '@geist-ui/icons'
|
||||
const allowedFileTypes = [
|
||||
'application/json',
|
||||
'application/x-javascript',
|
||||
'application/xhtml+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
'text/tab-separated-values',
|
||||
'text/x-c',
|
||||
'text/x-c++',
|
||||
'text/x-csharp',
|
||||
'text/x-java',
|
||||
'text/x-javascript',
|
||||
'text/x-php',
|
||||
'text/x-python',
|
||||
'text/x-ruby',
|
||||
'text/x-scala',
|
||||
'text/x-swift',
|
||||
'text/x-typescript',
|
||||
'text/x-vb',
|
||||
'text/x-vbscript',
|
||||
'text/x-yaml',
|
||||
'text/x-c++',
|
||||
'text/x-c#',
|
||||
'text/mathml',
|
||||
'text/x-markdown',
|
||||
'text/markdown',
|
||||
]
|
||||
|
||||
// Files with no extension can't be easily detected as plain-text,
|
||||
// so instead of allowing all of them we'll just allow common ones
|
||||
const allowedFileNames = [
|
||||
'Makefile',
|
||||
'README',
|
||||
'Dockerfile',
|
||||
'Jenkinsfile',
|
||||
'LICENSE',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.env.example',
|
||||
'.env.development',
|
||||
'.env.production',
|
||||
'.env.test',
|
||||
'.env.staging',
|
||||
'.env.development.local',
|
||||
'yarn.lock',
|
||||
]
|
||||
|
||||
const allowedFileExtensions = [
|
||||
'json',
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'c',
|
||||
'cpp',
|
||||
'c++',
|
||||
'c#',
|
||||
'java',
|
||||
'php',
|
||||
'py',
|
||||
'rb',
|
||||
'scala',
|
||||
'swift',
|
||||
'vb',
|
||||
'vbscript',
|
||||
'yaml',
|
||||
'less',
|
||||
'stylus',
|
||||
'styl',
|
||||
'sass',
|
||||
'scss',
|
||||
'lock',
|
||||
'md',
|
||||
'markdown',
|
||||
'txt',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'csv',
|
||||
'log',
|
||||
'sql',
|
||||
'xml',
|
||||
'webmanifest',
|
||||
]
|
||||
|
||||
// TODO: this shouldn't need to know about docs
|
||||
function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) {
|
||||
const { palette } = useTheme()
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
acceptedFiles.forEach((file: File) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onabort = () => console.log('file reading was aborted')
|
||||
reader.onerror = () => console.log('file reading has failed')
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string
|
||||
if (docs.length === 1 && docs[0].content === '') {
|
||||
setDocs([{
|
||||
title: file.name,
|
||||
content,
|
||||
id: generateUUID()
|
||||
}])
|
||||
} else {
|
||||
setDocs([...docs, {
|
||||
title: file.name,
|
||||
content,
|
||||
id: generateUUID()
|
||||
}])
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
})
|
||||
|
||||
}, [docs, setDocs])
|
||||
|
||||
const validator = (file: File) => {
|
||||
// TODO: make this configurable
|
||||
const maxFileSize = 1000000;
|
||||
if (file.size > maxFileSize) {
|
||||
return {
|
||||
code: 'file-too-big',
|
||||
message: 'File is too big. Maximum file size is ' + (maxFileSize).toFixed(2) + ' MB.',
|
||||
}
|
||||
}
|
||||
// We initially try to use the browser provided mime type, and then fall back to file names and finally extensions
|
||||
if (allowedFileTypes.includes(file.type) || allowedFileNames.includes(file.name) || allowedFileExtensions.includes(file.name?.split('.').pop() || '')) {
|
||||
return null
|
||||
} else {
|
||||
return {
|
||||
code: "not-plain-text",
|
||||
message: `Only plain text files are allowed.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, validator })
|
||||
|
||||
const fileRejectionItems = fileRejections.map(({ file, errors }) => (
|
||||
<li key={file.name}>
|
||||
{file.name}:
|
||||
<ul>
|
||||
{errors.map(e => (
|
||||
<li key={e.code}><Text>{e.message}</Text></li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div {...getRootProps()} className={styles.dropzone} style={{
|
||||
borderColor: palette.accents_3,
|
||||
}}>
|
||||
<input {...getInputProps()} />
|
||||
{!isDragActive && <Text p>Drag some files here, or click to select files</Text>}
|
||||
{isDragActive && <Text p>Release to drop the files here</Text>}
|
||||
</div>
|
||||
{fileRejections.length > 0 && <ul className={styles.error}>
|
||||
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
|
||||
<Text h5>There was a problem with some of your files.</Text>
|
||||
{fileRejectionItems}
|
||||
</ul>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileDropzone
|
|
@ -1,112 +0,0 @@
|
|||
import { Button, ButtonDropdown, useToasts } from '@geist-ui/core'
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useState } from 'react'
|
||||
import generateUUID from '../../lib/generate-uuid';
|
||||
import Document from '../document';
|
||||
import FileDropzone from './drag-and-drop';
|
||||
import styles from './post.module.css'
|
||||
import Title from './title';
|
||||
|
||||
export type Document = {
|
||||
title: string
|
||||
content: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const Post = () => {
|
||||
const { setToast } = useToasts()
|
||||
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState<string>()
|
||||
const [docs, setDocs] = useState<Document[]>([{
|
||||
title: '',
|
||||
content: '',
|
||||
id: generateUUID()
|
||||
}])
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
|
||||
const remove = (id: string) => {
|
||||
setDocs(docs.filter((doc) => doc.id !== id))
|
||||
}
|
||||
|
||||
const onSubmit = async (visibility: string) => {
|
||||
setSubmitting(true)
|
||||
const response = await fetch('/api/posts/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("drift-token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
files: docs,
|
||||
visibility,
|
||||
userId: localStorage.getItem("drift-userid"),
|
||||
})
|
||||
})
|
||||
|
||||
const json = await response.json()
|
||||
setSubmitting(false)
|
||||
if (json.id)
|
||||
router.push(`/post/${json.id}`)
|
||||
else {
|
||||
setToast({ text: json.error.message, type: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const updateTitle = useCallback((title: string, id: string) => {
|
||||
setDocs(docs.map((doc) => doc.id === id ? { ...doc, title } : doc))
|
||||
}, [docs])
|
||||
|
||||
const updateContent = useCallback((content: string, id: string) => {
|
||||
setDocs(docs.map((doc) => doc.id === id ? { ...doc, content } : doc))
|
||||
}, [docs])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title title={title} setTitle={setTitle} />
|
||||
<FileDropzone docs={docs} setDocs={setDocs} />
|
||||
{
|
||||
docs.map(({ id }) => {
|
||||
const doc = docs.find((doc) => doc.id === id)
|
||||
return (
|
||||
<Document
|
||||
remove={() => remove(id)}
|
||||
key={id}
|
||||
editable={true}
|
||||
setContent={(content) => updateContent(content, id)}
|
||||
setTitle={(title) => updateTitle(title, id)}
|
||||
content={doc?.content}
|
||||
title={doc?.title}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
setDocs([...docs, {
|
||||
title: '',
|
||||
content: '',
|
||||
id: generateUUID()
|
||||
}])
|
||||
}}
|
||||
style={{ flex: .5, lineHeight: '40px' }}
|
||||
type="default"
|
||||
>
|
||||
Add a File
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default Post
|
|
@ -1,21 +0,0 @@
|
|||
.buttons {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: var(--gap-double);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import { Text, Input } from '@geist-ui/core'
|
||||
import { memo } from 'react'
|
||||
import ShiftBy from '../../shift-by'
|
||||
import styles from '../post.module.css'
|
||||
|
||||
const titlePlaceholders = [
|
||||
"How to...",
|
||||
"Status update for ...",
|
||||
"My new project",
|
||||
"My new idea",
|
||||
"Let's talk about...",
|
||||
"What's up with ...",
|
||||
"I'm thinking about ...",
|
||||
]
|
||||
|
||||
type props = {
|
||||
setTitle: (title: string) => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
const Title = ({ setTitle, title }: props) => {
|
||||
return (<div className={styles.title}>
|
||||
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
|
||||
<ShiftBy y={-3}>
|
||||
<Input
|
||||
placeholder={titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]}
|
||||
value={title || ""}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
height={"55px"}
|
||||
font={1.5}
|
||||
label="Post title"
|
||||
marginLeft={'var(--gap)'}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</ShiftBy>
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default memo(Title)
|
|
@ -1,40 +0,0 @@
|
|||
import { 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"
|
||||
|
||||
type Props = {
|
||||
posts: any
|
||||
error: any
|
||||
}
|
||||
|
||||
const PostList = ({ posts, error }: Props) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{error && <Text type='error'>Failed to load.</Text>}
|
||||
{!posts && <ul>
|
||||
<li>
|
||||
<ListItemSkeleton />
|
||||
</li>
|
||||
<li>
|
||||
<ListItemSkeleton />
|
||||
</li>
|
||||
</ul>}
|
||||
{posts?.length === 0 && <Text>You have no posts. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||
{
|
||||
posts?.length > 0 && <div>
|
||||
<ul>
|
||||
{posts.map((post: any) => {
|
||||
return <ListItem post={post} key={post.id} />
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default PostList
|
|
@ -1,19 +0,0 @@
|
|||
import { Card, Spacer, Grid, Divider } from "@geist-ui/core";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
const ListItemSkeleton = () => (<Card>
|
||||
<Spacer height={1 / 2} />
|
||||
<Grid.Container justify={'space-between'} marginBottom={1 / 2}>
|
||||
<Grid xs={8} paddingLeft={1 / 2}><Skeleton width={150} /></Grid>
|
||||
<Grid xs={7}><Skeleton width={100} /></Grid>
|
||||
<Grid xs={4}><Skeleton width={70} /></Grid>
|
||||
</Grid.Container>
|
||||
|
||||
<Divider h="1px" my={0} />
|
||||
|
||||
<Card.Content >
|
||||
<Skeleton width={200} />
|
||||
</Card.Content>
|
||||
</Card>)
|
||||
|
||||
export default ListItemSkeleton
|
|
@ -1,58 +0,0 @@
|
|||
import { Card, Spacer, Grid, Divider, Link, Text, Input, Tooltip } from "@geist-ui/core"
|
||||
import NextLink from "next/link"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import timeAgo from "../../lib/time-ago"
|
||||
import ShiftBy from "../shift-by"
|
||||
import VisibilityBadge from "../visibility-badge"
|
||||
|
||||
const FilenameInput = ({ title }: { title: string }) => <Input
|
||||
value={title}
|
||||
marginTop="var(--gap-double)"
|
||||
size={1.2}
|
||||
font={1.2}
|
||||
label="Filename"
|
||||
readOnly
|
||||
width={"100%"}
|
||||
/>
|
||||
|
||||
const ListItem = ({ post }: { post: any }) => {
|
||||
const createdDate = useMemo(() => new Date(post.createdAt), [post.createdAt])
|
||||
const [time, setTimeAgo] = useState(timeAgo(createdDate))
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeAgo(timeAgo(createdDate))
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [createdDate])
|
||||
|
||||
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
|
||||
return (<li key={post.id}>
|
||||
<Card style={{ overflowY: 'scroll' }}>
|
||||
<Spacer height={1 / 2} />
|
||||
<Grid.Container justify={'space-between'}>
|
||||
<Grid xs={8}>
|
||||
<Text h3 paddingLeft={1 / 2}>
|
||||
<NextLink passHref={true} href={`/post/${post.id}`}>
|
||||
<Link color>{post.title}
|
||||
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
|
||||
</Link>
|
||||
</NextLink>
|
||||
</Text></Grid>
|
||||
<Grid xs={7}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></Text></Grid>
|
||||
<Grid xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid>
|
||||
</Grid.Container>
|
||||
|
||||
<Divider h="1px" my={0} />
|
||||
|
||||
<Card.Content >
|
||||
{post.files.map((file: any) => {
|
||||
return <FilenameInput key={file.id} title={file.title} />
|
||||
})}
|
||||
</Card.Content>
|
||||
|
||||
</Card>
|
||||
</li>)
|
||||
}
|
||||
|
||||
export default ListItem
|
|
@ -1,26 +0,0 @@
|
|||
.container ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container ul li {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.container ul li::before {
|
||||
content: "";
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.postHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap);
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: inherit;
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import { memo, useEffect, useState } from "react"
|
||||
import ReactMarkdownPreview from "./react-markdown-preview"
|
||||
|
||||
type Props = {
|
||||
content?: string
|
||||
height?: number | string
|
||||
// file extensions we can highlight
|
||||
type?: string
|
||||
}
|
||||
|
||||
const MarkdownPreview = ({ content = '', height = 500, type = 'markdown' }: Props) => {
|
||||
const [contentToRender, setContent] = useState(content)
|
||||
useEffect(() => {
|
||||
// 'm' so it doesn't flash code when you change the type to md
|
||||
const renderAsMarkdown = ['m', 'markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', '']
|
||||
if (!renderAsMarkdown.includes(type)) {
|
||||
setContent(`~~~${type}
|
||||
${content}
|
||||
~~~
|
||||
`)
|
||||
} else {
|
||||
setContent(content)
|
||||
}
|
||||
}, [type, content])
|
||||
return (<ReactMarkdownPreview height={height} content={contentToRender} />)
|
||||
}
|
||||
|
||||
export default memo(MarkdownPreview)
|
|
@ -1,60 +0,0 @@
|
|||
.markdownPreview pre {
|
||||
border-radius: 3px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.42857143;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdownPreview h1,
|
||||
.markdownPreview h2,
|
||||
.markdownPreview h3,
|
||||
.markdownPreview h4,
|
||||
.markdownPreview h5,
|
||||
.markdownPreview h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdownPreview h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.markdownPreview h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdownPreview h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdownPreview h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.markdownPreview h5 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdownPreview h6 {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.markdownPreview ul {
|
||||
list-style: inside;
|
||||
}
|
||||
|
||||
.markdownPreview ul li::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdownPreview ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.markdownPreview ul ul li {
|
||||
margin-left: var(--gap);
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
|
||||
// @ts-ignore because of no types in remark-a11y-emoji
|
||||
import a11yEmoji from '@fec/remark-a11y-emoji';
|
||||
import styles from './preview.module.css'
|
||||
import { duotoneDark, duotoneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism'
|
||||
import useSharedState from "../../lib/hooks/use-shared-state";
|
||||
|
||||
type Props = {
|
||||
content: string | undefined
|
||||
height: number | string
|
||||
}
|
||||
|
||||
const ReactMarkdownPreview = ({ content, height }: Props) => {
|
||||
const [themeType] = useSharedState<string>('theme')
|
||||
return (<div style={{ height }}>
|
||||
<ReactMarkdown className={styles.markdownPreview} remarkPlugins={[remarkGfm, a11yEmoji]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.25rem"
|
||||
}}
|
||||
customStyle={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
background: 'transparent'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent' }
|
||||
}}
|
||||
style={themeType === 'dark' ? duotoneDark : duotoneLight}
|
||||
showLineNumbers={true}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>{String(children).replace(/\n$/, '')}</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}>
|
||||
{content || ""}
|
||||
</ReactMarkdown></div>)
|
||||
}
|
||||
|
||||
export default ReactMarkdownPreview
|
|
@ -1,20 +0,0 @@
|
|||
// https://www.joshwcomeau.com/snippets/react-components/shift-by/
|
||||
type Props = {
|
||||
x?: number
|
||||
y?: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function ShiftBy({ x = 0, y = 0, children }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ShiftBy
|
|
@ -1,24 +0,0 @@
|
|||
import { Badge } from "@geist-ui/core"
|
||||
|
||||
type Visibility = "unlisted" | "private" | "public"
|
||||
|
||||
type Props = {
|
||||
visibility: Visibility
|
||||
}
|
||||
|
||||
const VisibilityBadge = ({ visibility }: Props) => {
|
||||
const getBadgeType = () => {
|
||||
switch (visibility) {
|
||||
case "public":
|
||||
return "success"
|
||||
case "private":
|
||||
return "warning"
|
||||
case "unlisted":
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
return (<Badge marginLeft={'var(--gap)'} type={getBadgeType()}>{visibility}</Badge>)
|
||||
}
|
||||
|
||||
export default VisibilityBadge
|
|
@ -1,30 +0,0 @@
|
|||
export default function generateUUID() {
|
||||
if (typeof crypto === 'object') {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||
const callback = (c: string) => {
|
||||
const num = Number(c);
|
||||
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);
|
||||
};
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback);
|
||||
}
|
||||
}
|
||||
let timestamp = new Date().getTime();
|
||||
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0;
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
let random = Math.random() * 16;
|
||||
if (timestamp > 0) {
|
||||
random = (timestamp + random) % 16 | 0;
|
||||
timestamp = Math.floor(timestamp / 16);
|
||||
} else {
|
||||
random = (perforNow + random) % 16 | 0;
|
||||
perforNow = Math.floor(perforNow / 16);
|
||||
}
|
||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import useSWR from "swr"
|
||||
|
||||
// https://2020.paco.me/blog/shared-hook-state-with-swr
|
||||
const useSharedState = <T>(key: string, initial?: T) => {
|
||||
const { data: state, mutate: setState } = useSWR(key, {
|
||||
fallbackData: initial
|
||||
})
|
||||
return [state, setState] as const
|
||||
}
|
||||
|
||||
export default useSharedState
|
|
@ -1,44 +0,0 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect } from "react"
|
||||
import useSharedState from "./use-shared-state";
|
||||
|
||||
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
|
||||
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
|
||||
const [isLoading, setLoading] = useSharedState('isLoading', true)
|
||||
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
|
||||
|
||||
const router = useRouter();
|
||||
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
|
||||
router.push('/signin')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function checkToken() {
|
||||
const token = localStorage.getItem('drift-token')
|
||||
if (token) {
|
||||
const response = await fetch('/api/auth/verify-token', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
setSignedIn(true)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(true)
|
||||
checkToken()
|
||||
|
||||
const interval = setInterval(() => {
|
||||
checkToken()
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [setLoading, setSignedIn])
|
||||
|
||||
return { isSignedIn, isLoading, signout }
|
||||
}
|
||||
|
||||
export default useSignedIn
|
|
@ -1,41 +0,0 @@
|
|||
// Modified from https://gist.github.com/IbeVanmeenen/4e3e58820c9168806e57530563612886
|
||||
// which is based on https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
|
||||
|
||||
const epochs = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
['second', 1]
|
||||
] as const;
|
||||
|
||||
// Get duration
|
||||
const getDuration = (timeAgoInSeconds: number) => {
|
||||
for (let [name, seconds] of epochs) {
|
||||
const interval = Math.floor(timeAgoInSeconds / seconds);
|
||||
|
||||
if (interval >= 1) {
|
||||
return {
|
||||
interval: interval,
|
||||
epoch: name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interval: 0,
|
||||
epoch: 'second'
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate
|
||||
const timeAgo = (date: Date) => {
|
||||
const timeAgoInSeconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000);
|
||||
const { interval, epoch } = getDuration(timeAgoInSeconds);
|
||||
const suffix = interval === 1 ? '' : 's';
|
||||
|
||||
return `${interval} ${epoch}${suffix} ago`;
|
||||
};
|
||||
|
||||
export default timeAgo
|
|
@ -1,20 +0,0 @@
|
|||
const dotenv = require("dotenv");
|
||||
dotenv.config();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
outputStandalone: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.API_URL}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"name": "drift",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fec/remark-a11y-emoji": "^3.1.0",
|
||||
"@geist-ui/core": "^2.3.5",
|
||||
"@geist-ui/icons": "^1.0.1",
|
||||
"comlink": "^4.3.1",
|
||||
"dotenv": "^16.0.0",
|
||||
"next": "12.1.0",
|
||||
"prismjs": "^1.27.0",
|
||||
"react": "17.0.2",
|
||||
"react-debounce-render": "^8.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^12.0.4",
|
||||
"react-loading-skeleton": "^3.0.3",
|
||||
"react-markdown": "^8.0.0",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-stringify": "^9.0.3",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"swr": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.21",
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-syntax-highlighter": "^13.5.2",
|
||||
"eslint": "8.10.0",
|
||||
"eslint-config-next": "12.1.0",
|
||||
"typescript": "4.6.2"
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
import '../styles/globals.css'
|
||||
import { GeistProvider, CssBaseline, useTheme } from '@geist-ui/core'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import useSharedState from '../lib/hooks/use-shared-state';
|
||||
|
||||
import 'react-loading-skeleton/dist/skeleton.css'
|
||||
import { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import Head from 'next/head';
|
||||
|
||||
export type ThemeProps = {
|
||||
theme: "light" | "dark" | string,
|
||||
changeTheme: () => void
|
||||
}
|
||||
|
||||
type AppProps<P = any> = {
|
||||
pageProps: P;
|
||||
} & Omit<NextAppProps<P>, "pageProps">;
|
||||
|
||||
export type DriftProps = ThemeProps
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps<ThemeProps>) {
|
||||
const [themeType, setThemeType] = useSharedState<string>('theme', 'light')
|
||||
const theme = useTheme();
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
const storedTheme = window.localStorage.getItem('drift-theme')
|
||||
if (storedTheme) setThemeType(storedTheme)
|
||||
// TODO: useReducer?
|
||||
}, [setThemeType, themeType])
|
||||
|
||||
const changeTheme = () => {
|
||||
const newTheme = themeType === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('drift-theme', newTheme)
|
||||
setThemeType(last => (last === 'dark' ? 'light' : 'dark'))
|
||||
}
|
||||
|
||||
const skeletonBaseColor = useMemo(() => {
|
||||
if (themeType === 'dark') return '#333'
|
||||
return '#eee'
|
||||
}, [themeType])
|
||||
const skeletonHighlightColor = useMemo(() => {
|
||||
if (themeType === 'dark') return '#555'
|
||||
return '#ddd'
|
||||
}, [themeType])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/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" />
|
||||
</Head>
|
||||
<GeistProvider themeType={themeType} >
|
||||
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} theme={themeType || 'light'} changeTheme={changeTheme} />
|
||||
</SkeletonTheme>
|
||||
</GeistProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyApp
|
|
@ -1,31 +0,0 @@
|
|||
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
|
||||
import { CssBaseline } from '@geist-ui/core'
|
||||
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>)
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument
|
|
@ -1,66 +0,0 @@
|
|||
import Head from 'next/head'
|
||||
import styles from '../styles/Home.module.css'
|
||||
import { Page, Spacer, Text } from '@geist-ui/core'
|
||||
|
||||
import Header from '../components/header'
|
||||
import { ThemeProps } from './_app'
|
||||
import Document from '../components/document'
|
||||
import Image from 'next/image'
|
||||
import ShiftBy from '../components/shift-by'
|
||||
|
||||
export function getStaticProps() {
|
||||
const introDoc = `### Drift is a self-hostable clone of GitHub Gist.
|
||||
#### It is a simple way to share code and text snippets with your friends, with support for the following:
|
||||
|
||||
- Render GitHub Extended Markdown (including images)
|
||||
- User authentication
|
||||
- Private, public, and secret posts
|
||||
|
||||
If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don't need for this demo).
|
||||
**This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**
|
||||
You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).
|
||||
|
||||
Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):
|
||||
> What is the absolute closest thing to GitHub Gist that can be self-hosted?
|
||||
In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration.
|
||||
I have looked at dozens of pastebin-like things.
|
||||
`
|
||||
return {
|
||||
props: {
|
||||
introContent: introDoc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = ThemeProps & {
|
||||
introContent: string
|
||||
}
|
||||
|
||||
const Home = ({ theme, changeTheme, introContent }: Props) => {
|
||||
return (
|
||||
<Page className={styles.container} width="100%">
|
||||
<Head>
|
||||
<title>Drift</title>
|
||||
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||
</Head>
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content width={"var(--main-content-width)"} margin="auto" paddingTop={"var(--gap)"}>
|
||||
<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> Welcome to Drift</Text>
|
||||
</div>
|
||||
<Document
|
||||
editable={false}
|
||||
content={introContent}
|
||||
title={`Welcome to Drift.md`}
|
||||
initialTab={`preview`}
|
||||
/>
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
|
@ -1,25 +0,0 @@
|
|||
import Head from 'next/head'
|
||||
import styles from '../styles/Home.module.css'
|
||||
import { Page } from '@geist-ui/core'
|
||||
|
||||
import Header from '../components/header'
|
||||
import MyPosts from '../components/my-posts'
|
||||
|
||||
const Home = ({ theme, changeTheme }: { theme: "light" | "dark", changeTheme: () => void }) => {
|
||||
return (
|
||||
<Page className={styles.container} width="100%">
|
||||
<Head>
|
||||
<title>Drift</title>
|
||||
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||
</Head>
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
||||
<MyPosts />
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
|
@ -1,33 +0,0 @@
|
|||
import Head from 'next/head'
|
||||
import styles from '../styles/Home.module.css'
|
||||
import NewPost from '../components/new-post'
|
||||
import { Page } from '@geist-ui/core'
|
||||
import useSignedIn from '../lib/hooks/use-signed-in'
|
||||
import Header from '../components/header'
|
||||
import { ThemeProps } from './_app'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const Home = ({ theme, changeTheme }: ThemeProps) => {
|
||||
const router = useRouter()
|
||||
const { isSignedIn, isLoading } = useSignedIn({ redirectIfNotAuthed: true })
|
||||
if (!isSignedIn && !isLoading) {
|
||||
router.push("/signin")
|
||||
}
|
||||
return (
|
||||
<Page className={styles.container} width="100%">
|
||||
<Head>
|
||||
<title>Drift</title>
|
||||
</Head>
|
||||
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
|
||||
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
||||
{isSignedIn && <NewPost />}
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
|
@ -1,82 +0,0 @@
|
|||
import { Page, Text } from "@geist-ui/core";
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Document from '../../components/document'
|
||||
import Header from "../../components/header";
|
||||
import VisibilityBadge from "../../components/visibility-badge";
|
||||
import { ThemeProps } from "../_app";
|
||||
import Head from "next/head";
|
||||
|
||||
const Post = ({ theme, changeTheme }: ThemeProps) => {
|
||||
const [post, setPost] = useState<any>()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string>()
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
setIsLoading(true);
|
||||
if (router.query.id) {
|
||||
const post = await fetch(`/api/posts/${router.query.id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${localStorage.getItem("drift-token")}`
|
||||
}
|
||||
})
|
||||
|
||||
if (post.ok) {
|
||||
const res = await post.json()
|
||||
if (res)
|
||||
setPost(res)
|
||||
else
|
||||
setError("Post not found")
|
||||
} else {
|
||||
if (post.status.toString().startsWith("4")) {
|
||||
router.push("/signin")
|
||||
} else {
|
||||
setError(post.statusText)
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchPost()
|
||||
}, [router, router.query.id])
|
||||
|
||||
return (
|
||||
<Page width={"100%"}>
|
||||
<Head>
|
||||
{isLoading && <title>loading - Drift</title>}
|
||||
{!isLoading && <title>{post.title} - Drift</title>}
|
||||
{!isLoading && post.visibility !== 'private' && <meta name="description" content={post.description} />}
|
||||
</Head>
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content width={"var(--main-content-width)"} margin="auto">
|
||||
{error && <Text type="error">{error}</Text>}
|
||||
{/* {!error && (isLoading || !post?.files) && <Loading />} */}
|
||||
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
|
||||
<Document skeleton={true} />
|
||||
</>}
|
||||
{!isLoading && post && <><Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
|
||||
<Document
|
||||
key={id}
|
||||
content={content}
|
||||
title={title}
|
||||
editable={false}
|
||||
initialTab={'preview'}
|
||||
/>
|
||||
))}
|
||||
</>}
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
}
|
||||
|
||||
export default Post
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Page } from "@geist-ui/core";
|
||||
import Head from 'next/head'
|
||||
import Auth from "../components/auth";
|
||||
import Header from "../components/header";
|
||||
import { ThemeProps } from "./_app";
|
||||
|
||||
const SignIn = ({ theme, changeTheme }: ThemeProps) => (
|
||||
<Page width={"100%"}>
|
||||
<Head>
|
||||
<title>Drift - Sign In</title>
|
||||
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||
</Head>
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="auto">
|
||||
<Auth page="signin" />
|
||||
</Page.Content>
|
||||
</Page>
|
||||
)
|
||||
|
||||
export default SignIn
|
|
@ -1,22 +0,0 @@
|
|||
import { Page } from "@geist-ui/core";
|
||||
import Head from "next/head";
|
||||
import Auth from "../components/auth";
|
||||
import Header from "../components/header";
|
||||
import { ThemeProps } from "./_app";
|
||||
|
||||
const SignUp = ({ theme, changeTheme }: ThemeProps) => (
|
||||
<Page width="100%">
|
||||
<Head>
|
||||
<title>Drift - Sign Up</title>
|
||||
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||
</Head>
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content width={"var(--main-content-width)"} paddingTop={"var(--gap)"} margin="auto">
|
||||
<Auth page="signup" />
|
||||
</Page.Content>
|
||||
</Page>
|
||||
)
|
||||
|
||||
export default SignUp
|
|
@ -1,124 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="72.000008"
|
||||
height="72"
|
||||
viewBox="0 0 19.05 19.05"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:export-filename="/home/reese/git/github.com/maxleiter/drift/logo.png"
|
||||
inkscape:export-xdpi="682.66669"
|
||||
inkscape:export-ydpi="682.66669"
|
||||
inkscape:version="1.1.2 (1:1.1+202202050950+0a00cf5339)"
|
||||
sodipodi:docname="logo.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
showguides="false"
|
||||
inkscape:zoom="13.877295"
|
||||
inkscape:cx="10.448722"
|
||||
inkscape:cy="34.444753"
|
||||
inkscape:current-layer="g3632"
|
||||
units="px"
|
||||
viewbox-width="19.05" />
|
||||
<defs
|
||||
id="defs2">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath7860">
|
||||
<circle
|
||||
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
||||
id="circle7862"
|
||||
cx="115.27311"
|
||||
cy="135.3275"
|
||||
r="9.1405506" />
|
||||
</clipPath>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath7864">
|
||||
<circle
|
||||
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
||||
id="circle7866"
|
||||
cx="115.27311"
|
||||
cy="135.3275"
|
||||
r="9.1405506" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="source strokes"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
style="display:none"
|
||||
transform="translate(-106.13256,-126.18696)">
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 114.7741,133.0871 c 0,0 2.24373,3.38322 0.005,7.06735 -2.23896,3.68413 -8.84476,5.87171 -8.84476,5.87171"
|
||||
id="path1824"
|
||||
sodipodi:nodetypes="csc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 99.71221,140.61603 c 0,0 6.55112,-0.26544 10.1251,-2.27285 3.57398,-2.00741 4.93679,-5.25608 4.93679,-5.25608"
|
||||
id="path857"
|
||||
sodipodi:nodetypes="czc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 114.7741,133.0871 c 0,0 3.22515,3.50294 1.78507,7.47454 -1.44009,3.97159 -7.66948,7.78507 -7.66948,7.78507"
|
||||
id="path949"
|
||||
sodipodi:nodetypes="czc" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 114.7741,133.0871 c 0,0 6.66681,-0.12736 17.37373,8.90799"
|
||||
id="path1345"
|
||||
sodipodi:nodetypes="cc" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1 copy"
|
||||
inkscape:groupmode="layer"
|
||||
id="g3632"
|
||||
transform="translate(-106.13256,-126.18696)">
|
||||
<rect
|
||||
style="display:inline;fill:#1b1b1b;fill-opacity:1;stroke:none;stroke-width:2.81834;stroke-linecap:round"
|
||||
id="rect6284"
|
||||
width="18.28112"
|
||||
height="18.28112"
|
||||
x="106.13255"
|
||||
y="126.18695"
|
||||
clip-path="url(#clipPath7864)"
|
||||
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)" />
|
||||
<g
|
||||
id="g937"
|
||||
inkscape:label="drift"
|
||||
clip-path="url(#clipPath7860)"
|
||||
mask="none"
|
||||
style="display:inline;stroke-width:0.959638"
|
||||
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)">
|
||||
<path
|
||||
id="path935"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 132.14783,141.99509 c -10.70692,-9.03535 -17.37373,-8.90799 -17.37373,-8.90799 0,0 2.38807,3.48286 0.94799,7.45446 -1.44009,3.97159 -7.66636,7.74668 -7.66636,7.74668 z"
|
||||
sodipodi:nodetypes="csccc" />
|
||||
<path
|
||||
id="path931"
|
||||
style="fill:#e7e7e7;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 108.88969,148.34671 c 0,0 6.22939,-3.81348 7.66948,-7.78507 1.44008,-3.9716 -1.78507,-7.47454 -1.78507,-7.47454 0,0 1.22037,3.09102 -1.01836,6.77515 -2.23896,3.68413 -8.92258,4.9787 -8.92258,4.9787 z"
|
||||
sodipodi:nodetypes="cscccc" />
|
||||
<path
|
||||
id="path933"
|
||||
style="fill:#c6c6c6;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 105.93434,146.02616 c 0,0 6.6058,-2.18758 8.84476,-5.87171 2.23873,-3.68413 -0.005,-7.06735 -0.005,-7.06735 0,0 -1.36281,3.24867 -4.93679,5.25608 -3.57398,2.00741 -10.1251,2.27285 -10.1251,2.27285 z"
|
||||
sodipodi:nodetypes="csccc" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 5.1 KiB |
|
@ -1,28 +0,0 @@
|
|||
.main {
|
||||
min-height: 100vh;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
width: var(--main-content-width);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
width: 100%;
|
||||
margin: 0 auto !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
:root {
|
||||
--main-content-width: 800px;
|
||||
--page-nav-height: 60px;
|
||||
--gap: 8px;
|
||||
--gap-half: calc(var(--gap) / 2);
|
||||
--gap-double: calc(var(--gap) * 2);
|
||||
--border-radius: 4px;
|
||||
--font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
:root {
|
||||
--main-content-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
2948
client/yarn.lock
16
components.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@components",
|
||||
"utils": "@utils"
|
||||
}
|
||||
}
|
34
docker-compose.yml
Normal file
|
@ -0,0 +1,34 @@
|
|||
services:
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
args:
|
||||
- NODE_ENV=production
|
||||
container_name: server
|
||||
restart: unless-stopped
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- PORT
|
||||
- JWT_SECRET=jwt_secret # change_me! # use `openssl rand -hex 32` to generate a strong secret
|
||||
- SECRET_KEY=secret # change me!
|
||||
- MEMORY_DB
|
||||
- REGISTRATION_PASSWORD
|
||||
- WELCOME_CONTENT
|
||||
- WELCOME_TITLE
|
||||
- ENABLE_ADMIN
|
||||
- DRIFT_HOME
|
||||
ports:
|
||||
- "3000:3000"
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
args:
|
||||
- API_URL=http://server:3000
|
||||
container_name: client
|
||||
restart: unless-stopped
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- API_URL=http://server:3000
|
||||
- SECRET_KEY=secret # change me!
|
||||
ports:
|
||||
- "3001:3001"
|
16
jest.config.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
setupFiles: ["<rootDir>/src/test/setup-tests.ts"],
|
||||
// TODO: update to app dir
|
||||
moduleNameMapper: {
|
||||
"@lib/(.*)": "<rootDir>/src/lib/$1",
|
||||
"@components/(.*)": "<rootDir>/src/app/components/$1",
|
||||
"\\.(css)$": "identity-obj-proxy"
|
||||
},
|
||||
testPathIgnorePatterns: ["/node_modules/", "/.next/"],
|
||||
transform: {
|
||||
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest"
|
||||
}
|
||||
}
|
1
client/next-env.d.ts → next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
46
next.config.mjs
Normal file
|
@ -0,0 +1,46 @@
|
|||
import bundleAnalyzer from "@next/bundle-analyzer"
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
appDir: true
|
||||
},
|
||||
rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/file/raw/:id",
|
||||
destination: `/api/raw/:id`
|
||||
},
|
||||
{
|
||||
source: "/signout",
|
||||
destination: `/api/auth/signout`
|
||||
}
|
||||
]
|
||||
},
|
||||
images: {
|
||||
domains: ["avatars.githubusercontent.com"]
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_DRIFT_URL:
|
||||
process.env.DRIFT_URL ||
|
||||
(process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "http://localhost:3000")
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: process.env.VERCEL_ENV !== "production"
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: process.env.VERCEL_ENV !== "production"
|
||||
},
|
||||
modularizeImports: {
|
||||
"react-feather": {
|
||||
transform: "react-feather/dist/icons/{{kebabCase member}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default process.env.ANALYZE === "true"
|
||||
? bundleAnalyzer({ enabled: true })(nextConfig)
|
||||
: nextConfig
|
113
package.json
Normal file
|
@ -0,0 +1,113 @@
|
|||
{
|
||||
"name": "drift",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3000",
|
||||
"lint": "next lint && prettier --list-different --config .prettierrc 'src/{components,lib,app,pages}/**/*.{ts,tsx}' --write",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"find:unused": "next-unused",
|
||||
"prisma": "prisma",
|
||||
"jest": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@next/eslint-plugin-next": "13.4.11-canary.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.0.5",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.5",
|
||||
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
"client-only": "^0.0.1",
|
||||
"client-zip": "2.3.1",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"jest": "^29.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "13.4.11-canary.1",
|
||||
"next-auth": "^4.22.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-cookie": "^4.1.1",
|
||||
"react-datepicker": "4.10.0",
|
||||
"react-day-picker": "^8.8.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "14.2.3",
|
||||
"react-error-boundary": "^4.0.4",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-hot-toast": "2.4.1",
|
||||
"server-only": "^0.0.1",
|
||||
"swr": "^2.2.0",
|
||||
"tailwind-merge": "^1.13.0",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"textarea-markdown-editor": "1.0.4",
|
||||
"ts-jest": "^29.1.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "13.4.11-canary.0",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/git-http-backend": "^1.0.1",
|
||||
"@types/jest": "^29.4.1",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.35",
|
||||
"@types/react-datepicker": "4.10.0",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
||||
"@typescript-eslint/parser": "^5.58.0",
|
||||
"@wcj/markdown-to-html": "^2.2.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"clsx": "^1.2.1",
|
||||
"cross-env": "7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "8.38.0",
|
||||
"eslint-config-next": "13.4.11-canary.1",
|
||||
"jest-mock-extended": "^3.0.3",
|
||||
"next-unused": "0.0.6",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-flexbugs-fixes": "^5.0.2",
|
||||
"postcss-hover-media-feature": "^1.0.2",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"postcss-preset-env": "^8.4.1",
|
||||
"prettier": "2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prisma": "^5.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "5.1.6",
|
||||
"typescript-plugin-css-modules": "5.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.32.0"
|
||||
},
|
||||
"next-unused": {
|
||||
"alias": {
|
||||
"@components": "components/",
|
||||
"@lib": "src/lib/",
|
||||
"@styles": "styles/"
|
||||
},
|
||||
"include": [
|
||||
"components",
|
||||
"lib"
|
||||
]
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/prisma/schema.prisma"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
}
|
9208
pnpm-lock.yaml
Normal file
7
postcss.config.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 930 B After Width: | Height: | Size: 930 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
3
renovate.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["config:base", "group:allNonMajor", "schedule:earlyMondays"]
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
3
server/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
.env
|
||||
node_modules/
|
||||
dist/
|
|
@ -1,38 +0,0 @@
|
|||
# Install dependencies only when needed
|
||||
FROM node:16-alpine AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock tsconfig.json tslint.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# If using npm with a `package-lock.json` comment out above and use below instead
|
||||
# COPY package.json package-lock.json ./
|
||||
# RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 drift
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
USER drift
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
|
@ -1,4 +0,0 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import './src/server';
|
|
@ -1,4 +0,0 @@
|
|||
export default {
|
||||
port: process.env.PORT || 3000,
|
||||
jwt_secret: process.env.JWT_SECRET || 'myjwtsecret',
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import { User as UserModel } from '../models/User';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UserJwtRequest extends Request {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export default function authenticateToken(req: UserJwtRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
|
||||
if (token == null) return res.sendStatus(401)
|
||||
|
||||
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
|
||||
if (err) return res.sendStatus(403)
|
||||
const userObj = await UserModel.findByPk(user.id);
|
||||
if (!userObj) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
req.user = user
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import { BelongsTo, Column, CreatedAt, DataType, ForeignKey, IsUUID, Model, PrimaryKey, Scopes, Table } from 'sequelize-typescript';
|
||||
import { Post } from './Post';
|
||||
import { User } from './User';
|
||||
|
||||
|
||||
@Scopes(() => ({
|
||||
full: {
|
||||
include: [{
|
||||
model: User,
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Post,
|
||||
through: { attributes: [] },
|
||||
}]
|
||||
}
|
||||
}))
|
||||
|
||||
@Table
|
||||
export class File extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
})
|
||||
id!: string
|
||||
|
||||
@Column
|
||||
title!: string;
|
||||
|
||||
@Column
|
||||
content!: string;
|
||||
|
||||
@Column
|
||||
sha!: string;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@BelongsTo(() => User, 'userId')
|
||||
user!: User;
|
||||
|
||||
@ForeignKey(() => Post)
|
||||
@BelongsTo(() => Post, 'postId')
|
||||
post!: Post;
|
||||
|
||||
@CreatedAt
|
||||
@Column
|
||||
createdAt!: Date;
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
import { BelongsToMany, Column, CreatedAt, DataType, HasMany, IsUUID, Model, PrimaryKey, Scopes, Table, UpdatedAt } from 'sequelize-typescript';
|
||||
import { PostAuthor } from './PostAuthor';
|
||||
import { User } from './User';
|
||||
import { File } from './File';
|
||||
|
||||
@Scopes(() => ({
|
||||
user: {
|
||||
include: [{
|
||||
model: User,
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
},
|
||||
full: {
|
||||
include: [{
|
||||
model: User,
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: File,
|
||||
through: { attributes: [] },
|
||||
}]
|
||||
}
|
||||
}))
|
||||
|
||||
@Table
|
||||
export class Post extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
})
|
||||
id!: string
|
||||
|
||||
@Column
|
||||
title!: string;
|
||||
|
||||
@BelongsToMany(() => User, () => PostAuthor)
|
||||
users?: User[];
|
||||
|
||||
@HasMany(() => File)
|
||||
files?: File[];
|
||||
|
||||
@CreatedAt
|
||||
@Column
|
||||
createdAt!: Date;
|
||||
|
||||
@Column
|
||||
visibility!: string;
|
||||
|
||||
@UpdatedAt
|
||||
@Column
|
||||
updatedAt!: Date;
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import { Model, Column, Table, ForeignKey, IsUUID, PrimaryKey, DataType } from "sequelize-typescript";
|
||||
import { Post } from "./Post";
|
||||
import { User } from "./User";
|
||||
|
||||
@Table
|
||||
export class PostAuthor extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
})
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Post)
|
||||
@Column
|
||||
postId!: number;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
authorId!: number;
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
import { Model, Column, Table, BelongsToMany, Scopes, CreatedAt, UpdatedAt, IsUUID, PrimaryKey, DataType } from "sequelize-typescript";
|
||||
import { Post } from "./Post";
|
||||
import { PostAuthor } from "./PostAuthor";
|
||||
|
||||
@Scopes(() => ({
|
||||
posts: {
|
||||
include: [
|
||||
{
|
||||
model: Post,
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
withoutPassword: {
|
||||
attributes: {
|
||||
exclude: ["password"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@Table
|
||||
export class User extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
})
|
||||
id!: string
|
||||
|
||||
@Column
|
||||
username!: string;
|
||||
|
||||
@Column
|
||||
password!: string;
|
||||
|
||||
@BelongsToMany(() => Post, () => PostAuthor)
|
||||
posts?: Post[];
|
||||
|
||||
@CreatedAt
|
||||
@Column
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
@Column
|
||||
updatedAt!: Date;
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import {Sequelize} from 'sequelize-typescript';
|
||||
|
||||
export const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
database: 'movies',
|
||||
storage: ':memory:',
|
||||
models: [__dirname + '/models']
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"name": "sequelize-typescript-starter",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node index.ts",
|
||||
"dev": "nodemon index.ts",
|
||||
"build": "tsc -p ."
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.0.1",
|
||||
"body-parser": "^1.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.16.2",
|
||||
"express-jwt": "^6.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"nodemon": "^2.0.15",
|
||||
"reflect-metadata": "^0.1.10",
|
||||
"sequelize": "^6.17.0",
|
||||
"sequelize-typescript": "^2.1.3",
|
||||
"sqlite3": "https://github.com/mapbox/node-sqlite3#918052b538b0effe6c4a44c74a16b2749c08a0d2",
|
||||
"strong-error-handler": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.0.39",
|
||||
"@types/express-jwt": "^6.0.4",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/node": "^17.0.21",
|
||||
"ts-node": "^10.6.0",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.6.2"
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import * as express from 'express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import * as errorhandler from 'strong-error-handler';
|
||||
import * as cors from 'cors';
|
||||
import { posts, users, auth } from './routes';
|
||||
|
||||
export const app = express();
|
||||
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(bodyParser.json({ limit: '5mb' }));
|
||||
|
||||
const corsOptions = {
|
||||
origin: `http://localhost:3001`,
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
app.use("/auth", auth)
|
||||
app.use("/posts", posts)
|
||||
app.use("/users", users)
|
||||
|
||||
app.use(errorhandler({
|
||||
debug: process.env.ENV !== 'production',
|
||||
log: true,
|
||||
}));
|