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
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
analyze
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/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).
|
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
|
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
|
||||||
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.
|
|
||||||
|
|
||||||
- [x] creating and sharing private, public, unlisted posts
|
<hr />
|
||||||
- [x] syntax highlighting (detected by file extension)
|
|
||||||
- [x] multiple files per post
|
**Contents:**
|
||||||
- [ ] uploading files via drag-and-drop
|
|
||||||
|
- [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] responsive UI
|
||||||
- [x] user auth
|
- [x] user auth
|
||||||
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
||||||
- [ ] downloading files (individually and entire posts)
|
- [x] SSO via GitHub OAuth
|
||||||
- [ ] password protected posts
|
- [x] downloading files (individually and entire posts)
|
||||||
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
- [x] password protected posts
|
||||||
- [ ] non-node backend
|
- [x] postgres database
|
||||||
- [ ] administrator account / settings
|
- [x] administrator account / settings
|
||||||
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
|
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
|
||||||
- [ ] publish docker builds
|
- [ ] publish docker builds
|
||||||
- [ ] user settings
|
- [ ] user settings
|
||||||
- [ ] works enough with JavaScript disabled
|
- [ ] works enough with JavaScript disabled
|
||||||
- [ ] documentation
|
- [ ] in-depth 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?
|
- [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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
46
next.config.mjs
Normal file
|
@ -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,
|
|
||||||
}));
|
|