mirror of
https://github.com/diced/zipline.git
synced 2025-12-06 04:41:12 -08:00
Compare commits
344 Commits
v3.3.0
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7df4a578b | ||
|
|
38e30b2525 | ||
|
|
8e44b71614 | ||
|
|
11bca28ef5 | ||
|
|
4ef0c6021a | ||
|
|
4fbbd58ae9 | ||
|
|
81dea6cf90 | ||
|
|
9b57fb280b | ||
|
|
e804d0b31e | ||
|
|
76845fc7e4 | ||
|
|
decd7f7918 | ||
|
|
8c5ff4f230 | ||
|
|
535600edc8 | ||
|
|
0848702f65 | ||
|
|
5379374135 | ||
|
|
b7772128d7 | ||
|
|
95a1c7f92c | ||
|
|
2d69cd580a | ||
|
|
34552926d1 | ||
|
|
739f584921 | ||
|
|
04d8b6421a | ||
|
|
fdcd1f3d28 | ||
|
|
fc02dc02e8 | ||
|
|
6955d83b0c | ||
|
|
1b3d3a867b | ||
|
|
83718d7b31 | ||
|
|
e80627a3c3 | ||
|
|
e1003d4bb6 | ||
|
|
2ef4a52be0 | ||
|
|
93a63d3714 | ||
|
|
a8d9d98cf2 | ||
|
|
d70ddd1f53 | ||
|
|
283c7c5a26 | ||
|
|
fb5f50d5bd | ||
|
|
06e84b41aa | ||
|
|
e3f262322a | ||
|
|
70c2fa8ef4 | ||
|
|
9f534e18c8 | ||
|
|
55bd72aef8 | ||
|
|
c1a23faf1f | ||
|
|
3588c297f8 | ||
|
|
04d03cbc8f | ||
|
|
4e27efb6a1 | ||
|
|
59b3e5bb24 | ||
|
|
d8eee3d81a | ||
|
|
c8926682b2 | ||
|
|
9117a9d779 | ||
|
|
4ea1775f2c | ||
|
|
a8020ecebe | ||
|
|
2ace076fce | ||
|
|
45e897d475 | ||
|
|
98676f0573 | ||
|
|
c966ab9a52 | ||
|
|
ebaf11ad10 | ||
|
|
19c7ba03c6 | ||
|
|
894b5c5c6c | ||
|
|
516e93cee2 | ||
|
|
cc0ffc6e60 | ||
|
|
a97ace6e73 | ||
|
|
6d49463dad | ||
|
|
81e6e4e5f2 | ||
|
|
2adb355183 | ||
|
|
5e6c53432b | ||
|
|
873f77bc43 | ||
|
|
9bf098a93a | ||
|
|
388713a3c6 | ||
|
|
e94dd58542 | ||
|
|
d985a1c588 | ||
|
|
dbac6e8918 | ||
|
|
a481c0ee5e | ||
|
|
eef6fdaeb3 | ||
|
|
b8b1a5bba6 | ||
|
|
f06f52fce7 | ||
|
|
4a332bb77b | ||
|
|
eb1b202566 | ||
|
|
658f3a1a09 | ||
|
|
55eba480ac | ||
|
|
bbeea5b0ec | ||
|
|
ad454a94ef | ||
|
|
268215ff5f | ||
|
|
4e70daa4d8 | ||
|
|
bb28f49cf5 | ||
|
|
d85211a145 | ||
|
|
a7291d374d | ||
|
|
5c9b558ac2 | ||
|
|
36ede22d45 | ||
|
|
6528ec4056 | ||
|
|
56ee494c7d | ||
|
|
b21995a0b9 | ||
|
|
3c00575ecd | ||
|
|
27ccbcb54a | ||
|
|
fecbf394c1 | ||
|
|
91341e2d21 | ||
|
|
6349503b00 | ||
|
|
58e8c103b7 | ||
|
|
5d115afa71 | ||
|
|
d8b308a18c | ||
|
|
76267c00d5 | ||
|
|
9648856052 | ||
|
|
d87e465a8e | ||
|
|
2c07d6719e | ||
|
|
4c633eb60d | ||
|
|
ba6580e4ef | ||
|
|
c21d8f837e | ||
|
|
eadfa09570 | ||
|
|
ea1a0b7fc8 | ||
|
|
9f797613d2 | ||
|
|
b728ff33ec | ||
|
|
7dc036c6e4 | ||
|
|
78135aac02 | ||
|
|
950018673f | ||
|
|
cfdcf05135 | ||
|
|
ace474eb2c | ||
|
|
285ed8d56e | ||
|
|
738e25feda | ||
|
|
6d2d071293 | ||
|
|
725ce50608 | ||
|
|
78e884e97e | ||
|
|
cb123cb575 | ||
|
|
6f3081cb8e | ||
|
|
231f734fd5 | ||
|
|
fce7325a24 | ||
|
|
2bec45411f | ||
|
|
577195b578 | ||
|
|
a402227c4f | ||
|
|
a75b790654 | ||
|
|
f07cbeac52 | ||
|
|
dcfcce7803 | ||
|
|
659868181d | ||
|
|
d76e6444e0 | ||
|
|
0dbbf4840c | ||
|
|
1b6af9fc08 | ||
|
|
8e1541ea56 | ||
|
|
fd9908833a | ||
|
|
24f8300b2c | ||
|
|
8d510f5d90 | ||
|
|
6457680065 | ||
|
|
3175911105 | ||
|
|
00f26bdc75 | ||
|
|
9db95bb772 | ||
|
|
e1ba96784c | ||
|
|
f67d1d41cb | ||
|
|
bb7367615d | ||
|
|
f8be8fb583 | ||
|
|
e00393936f | ||
|
|
3c782de64d | ||
|
|
678dc9ef6b | ||
|
|
67bb9cd4a5 | ||
|
|
51cfb9062a | ||
|
|
1ecf979721 | ||
|
|
642b0fdc95 | ||
|
|
bc64d6886a | ||
|
|
81399c59f7 | ||
|
|
69d10ef429 | ||
|
|
3c616f4f6f | ||
|
|
988b61e459 | ||
|
|
3d4e0b8fc0 | ||
|
|
564fcfca61 | ||
|
|
709e1da768 | ||
|
|
a1f281d8b4 | ||
|
|
d2f3999cf1 | ||
|
|
87fc9f2fb9 | ||
|
|
8c9064fd93 | ||
|
|
561849ae5b | ||
|
|
0847802ce4 | ||
|
|
d5a8b3f1fb | ||
|
|
e6cebd8c46 | ||
|
|
f2be036bac | ||
|
|
f14448d40d | ||
|
|
bf719808f2 | ||
|
|
9dd82c91d7 | ||
|
|
535f84064a | ||
|
|
0c0a55d766 | ||
|
|
6e3ee29eb4 | ||
|
|
6a7a5dc7a3 | ||
|
|
e78d2d79d0 | ||
|
|
451027eaf3 | ||
|
|
e4491610fb | ||
|
|
f30e10f235 | ||
|
|
f9249b1380 | ||
|
|
3df94526b0 | ||
|
|
b30b7b1bd3 | ||
|
|
a9defd67d6 | ||
|
|
68d346e69d | ||
|
|
e2fd27cbba | ||
|
|
4c0532006c | ||
|
|
7ac574b230 | ||
|
|
7eb855de8f | ||
|
|
d5984f4141 | ||
|
|
b7c0c85639 | ||
|
|
84ba166aea | ||
|
|
bd79858681 | ||
|
|
0f10fa3991 | ||
|
|
74b1799d21 | ||
|
|
4552643ff8 | ||
|
|
d432b388f6 | ||
|
|
a8475602c7 | ||
|
|
f58d33af9e | ||
|
|
0150ea5e70 | ||
|
|
3bf43f1606 | ||
|
|
b8729a6ec7 | ||
|
|
1f44aa7e85 | ||
|
|
2bd5352fc5 | ||
|
|
a90130e8bf | ||
|
|
642e8796f0 | ||
|
|
615cbddc89 | ||
|
|
4ef82bdff4 | ||
|
|
dafde04c2c | ||
|
|
1be61b8d89 | ||
|
|
c3215c7425 | ||
|
|
af0cd26ea0 | ||
|
|
cb7dacd089 | ||
|
|
8c04971094 | ||
|
|
3a4802f09a | ||
|
|
d78db306c5 | ||
|
|
3f8790ece1 | ||
|
|
f9e6158144 | ||
|
|
05de3fed15 | ||
|
|
38cba9cb39 | ||
|
|
a4af980e11 | ||
|
|
940b844857 | ||
|
|
41b766216e | ||
|
|
402987baba | ||
|
|
3cb08c73d3 | ||
|
|
4cb92a7257 | ||
|
|
a095768eae | ||
|
|
1a5925d7e8 | ||
|
|
9147847710 | ||
|
|
05fe8bcaca | ||
|
|
b0c3c6f45a | ||
|
|
0f641aa852 | ||
|
|
2651bbe50c | ||
|
|
d31371eb6c | ||
|
|
ec0e7e5ec7 | ||
|
|
feb75a8a42 | ||
|
|
d4369d2503 | ||
|
|
d236589644 | ||
|
|
8044b7f623 | ||
|
|
9f0697dd34 | ||
|
|
78a6f3122d | ||
|
|
b460da74dd | ||
|
|
75a8bb7962 | ||
|
|
9ac876e30a | ||
|
|
26cb4ea034 | ||
|
|
0d65ee1a32 | ||
|
|
4a753376b7 | ||
|
|
dc926e9f5a | ||
|
|
722372c7f6 | ||
|
|
4589c6ee0a | ||
|
|
67ff93e640 | ||
|
|
bd055d704b | ||
|
|
2e8bee931c | ||
|
|
a454a4f4a8 | ||
|
|
45541a3cdd | ||
|
|
1d42d922bd | ||
|
|
4f631fbd0e | ||
|
|
e911db4c1a | ||
|
|
9b60147e11 | ||
|
|
acd0cabdff | ||
|
|
d41f6058f7 | ||
|
|
8f835eec4e | ||
|
|
ecab525ffd | ||
|
|
7c887e8ec1 | ||
|
|
f3a23a528b | ||
|
|
cdcb31130b | ||
|
|
3ea24ddf0c | ||
|
|
12baadd563 | ||
|
|
f5ae36d4e7 | ||
|
|
04ca738fb1 | ||
|
|
95e09e51e1 | ||
|
|
2f0af385c7 | ||
|
|
786e6d5799 | ||
|
|
61c5df750a | ||
|
|
eb30afcb83 | ||
|
|
cdf0f6e96c | ||
|
|
54158c5dbe | ||
|
|
56ff86db44 | ||
|
|
b7560c80aa | ||
|
|
03379943de | ||
|
|
2376fd8968 | ||
|
|
2f90193d7e | ||
|
|
964199f8a9 | ||
|
|
678ea20004 | ||
|
|
ea27fd8a45 | ||
|
|
38eef3f0ad | ||
|
|
22615e9ce9 | ||
|
|
a999abfbf8 | ||
|
|
20c1d3ef08 | ||
|
|
b06c8e4918 | ||
|
|
6edfdcefcc | ||
|
|
10b145b006 | ||
|
|
0ba9a9659d | ||
|
|
2dfa1b6b14 | ||
|
|
7a3f9f1fa4 | ||
|
|
f276fdc6a0 | ||
|
|
7963bdd1e4 | ||
|
|
195c57edc3 | ||
|
|
4442c85dc1 | ||
|
|
5bcac2a2b0 | ||
|
|
5303b67d11 | ||
|
|
af59e9abb8 | ||
|
|
fb098c9147 | ||
|
|
739974bef4 | ||
|
|
d21e48a1a3 | ||
|
|
8fea0cbe77 | ||
|
|
1e2b8efb13 | ||
|
|
8495963094 | ||
|
|
06d1c0bc3b | ||
|
|
5965c2e237 | ||
|
|
fb34dfadb0 | ||
|
|
13b0ac737b | ||
|
|
300430b3ec | ||
|
|
cf6f154e6e | ||
|
|
2ddf8c0cdb | ||
|
|
2a402f77b5 | ||
|
|
7b2c31658a | ||
|
|
7a91a60af9 | ||
|
|
bfa6c70bf3 | ||
|
|
73eff05180 | ||
|
|
74f3b3f13d | ||
|
|
181833d768 | ||
|
|
be9523304a | ||
|
|
b26fef3ad4 | ||
|
|
9f86674bbe | ||
|
|
095e57a037 | ||
|
|
66a8e3bb79 | ||
|
|
473137abdf | ||
|
|
740f1605e7 | ||
|
|
0922ec020e | ||
|
|
dbe8291f55 | ||
|
|
9dcc16277e | ||
|
|
aa611fa6ba | ||
|
|
083040e300 | ||
|
|
99e92e4594 | ||
|
|
870f6e88b1 | ||
|
|
16d2014bfb | ||
|
|
4d9a22e82c | ||
|
|
42d77e445b | ||
|
|
6506846207 | ||
|
|
2b9af0e0de | ||
|
|
762d2927f7 | ||
|
|
d9561f3b12 | ||
|
|
dde24848d4 | ||
|
|
e786482902 |
41
.devcontainer/devcontainer.json
Normal file
41
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Zipline Codespace",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"forwardPorts": [3000, 5432],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {},
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"files.autoSave": "afterDelay"
|
||||
},
|
||||
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
"remoteUser": "zipline",
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Zipline",
|
||||
"onAutoForward": "openBrowser"
|
||||
},
|
||||
"5432": {
|
||||
"label": "Postgres"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
.devcontainer/docker-compose.yml
Normal file
22
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
network_mode: service:db
|
||||
command: sleep infinity
|
||||
user: zipline
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
|
||||
@@ -2,3 +2,6 @@ node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
46
.env.local.example
Normal file
46
.env.local.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
|
||||
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
|
||||
|
||||
# if using s3/supabase make sure to comment out the other datasources
|
||||
|
||||
CORE_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
|
||||
CORE_LOGGER=false
|
||||
CORE_STATS_INTERVAL=1800
|
||||
|
||||
# default
|
||||
DATASOURCE_TYPE=local
|
||||
DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
|
||||
# or you can choose to use s3
|
||||
DATASOURCE_TYPE=s3
|
||||
DATASOURCE_S3_ACCESS_KEY_ID=key
|
||||
DATASOURCE_S3_SECRET_ACCESS_KEY=secret
|
||||
DATASOURCE_S3_BUCKET=bucket
|
||||
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
|
||||
DATASOURCE_S3_REGION=us-west-2
|
||||
DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
DATASOURCE_S3_USE_SSL=false
|
||||
|
||||
# or supabase
|
||||
DATASOURCE_TYPE=supabase
|
||||
DATASOURCE_SUPABASE_KEY=xxx
|
||||
# remember: no leading slash
|
||||
DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
|
||||
UPLOADER_DEFAULT_FORMAT=RANDOM
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
UPLOADER_ADMIN_LIMIT=104900000
|
||||
UPLOADER_USER_LIMIT=104900000
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext
|
||||
|
||||
URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
25
.eslintrc.js
25
.eslintrc.js
@@ -1,25 +0,0 @@
|
||||
module.exports = {
|
||||
'extends': ['next', 'next/core-web-vitals'],
|
||||
'rules': {
|
||||
'indent': ['error', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'@next/next/no-img-element': 'off',
|
||||
},
|
||||
};
|
||||
33
.eslintrc.json
Normal file
33
.eslintrc.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": ["next", "next/core-web-vitals", "plugin:prettier/recommended"],
|
||||
"rules": {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"jsx-quotes": ["error", "prefer-single"],
|
||||
"indent": "off",
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/jsx-uses-react": "warn",
|
||||
"react/jsx-uses-vars": "warn",
|
||||
"react/no-danger-with-children": "warn",
|
||||
"react/no-deprecated": "warn",
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-typos": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/require-render-return": "error",
|
||||
"react/style-prop-object": "warn",
|
||||
"@next/next/no-img-element": "off",
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"react/display-name": "off"
|
||||
}
|
||||
}
|
||||
14
.gitattributes
vendored
Normal file
14
.gitattributes
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text eol=lf
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.c text
|
||||
*.h text
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.sln text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
52
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Bug
|
||||
description: File a bug report
|
||||
title: 'Bug: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Provide steps to reproduce the bug, and some context.
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Zipline are you using?
|
||||
options:
|
||||
- upstream (ghcr.io/diced/zipline:trunk)
|
||||
- latest (ghcr.io/diced/zipline:latest)
|
||||
- other (provide version in additional info)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browser(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
|
||||
- Safari
|
||||
- Firefox Mobile
|
||||
- Safari Mobile
|
||||
- type: textarea
|
||||
id: zipline-logs
|
||||
attributes:
|
||||
label: Zipline Logs
|
||||
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=true` environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: browser-logs
|
||||
attributes:
|
||||
label: Browser Logs
|
||||
description: Please copy and paste any relevant log output.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Anything else that could be used to narrow down the issue, like your config.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Zipline Discord
|
||||
url: https://discord.gg/EAhCRfGxCF
|
||||
about: Ask for help with anything related to Zipline!
|
||||
- name: Zipline Docs
|
||||
url: https://zipline.diced.tech
|
||||
about: Maybe take a look a the docs?
|
||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'CI: Build'
|
||||
name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,23 +11,28 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16.x'
|
||||
node-version: '18.x'
|
||||
|
||||
- name: 'Restore dependency cache'
|
||||
id: cache-restore
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
path: |
|
||||
node_modules
|
||||
${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
run: yarn build
|
||||
env:
|
||||
ZIPLINE_DOCKER_BUILD: true
|
||||
51
.github/workflows/docker-release.yml
vendored
Normal file
51
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: 'Push Release Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Release Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:latest
|
||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
49
.github/workflows/docker.yml
vendored
49
.github/workflows/docker.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'CD: Push Docker Images'
|
||||
name: 'Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -16,30 +17,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: diced/zipline/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: diced/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:trunk
|
||||
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,6 +5,11 @@
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -36,4 +41,5 @@ yarn-error.log*
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
uploads*/
|
||||
dist/
|
||||
1
.husky/.gitignore
vendored
1
.husky/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 110
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
28
.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
vendored
Normal file
28
.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
823
.yarn/releases/yarn-3.3.1.cjs
vendored
Executable file
823
.yarn/releases/yarn-3.3.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
11
.yarnrc.yml
Normal file
11
.yarnrc.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
checksumBehavior: update
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.3.1.cjs
|
||||
23
CONTRIBUTING.md
Normal file
23
CONTRIBUTING.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Contributing
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
- The version of Zipline
|
||||
- Your OS & Browser including server OS
|
||||
- What you were expecting to see
|
||||
|
||||
## Feature requests
|
||||
|
||||
Create an discussion on GitHub, please include the following:
|
||||
|
||||
- Breif explanation of the feature in the title (very breif please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
|
||||
Please make sure your code also reflects the style of the rest of the code.
|
||||
96
Dockerfile
96
Dockerfile
@@ -1,33 +1,75 @@
|
||||
FROM node:16-alpine3.11 AS builder
|
||||
WORKDIR /build
|
||||
# Use the Prisma binaries image as the first stage
|
||||
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Use Alpine Linux as the second stage
|
||||
FROM node:18-alpine3.16 as base
|
||||
|
||||
COPY src ./src
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY prisma ./prisma
|
||||
|
||||
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# create a mock config.toml to spoof next build!
|
||||
RUN echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:16-alpine3.11 AS runner
|
||||
# Set the working directory
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
# Copy the necessary files from the project
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/server ./server
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/prisma ./prisma
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/package.json ./package.json
|
||||
FROM base as builder
|
||||
|
||||
CMD ["node", "server"]
|
||||
COPY .yarn ./.yarn
|
||||
COPY package*.json ./
|
||||
COPY yarn*.lock ./
|
||||
COPY .yarnrc.yml ./
|
||||
|
||||
# Copy the prisma binaries from prisma stage
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install production dependencies then temporarily save
|
||||
RUN yarn workspaces focus --production --all
|
||||
RUN cp -RL node_modules /tmp/node_modules
|
||||
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
# Run the build
|
||||
RUN yarn build
|
||||
|
||||
# Use Alpine Linux as the final image
|
||||
FROM base
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
|
||||
COPY --from=builder /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy only the necessary files from the previous stage
|
||||
COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
COPY --from=builder /zipline/package.json ./package.json
|
||||
|
||||
COPY --from=builder /zipline/node_modules ./node_modules
|
||||
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
|
||||
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
# Copy Startup Script
|
||||
COPY docker-entrypoint.sh /zipline
|
||||
# Make Startup Script Executable
|
||||
RUN chmod a+x /zipline/docker-entrypoint.sh
|
||||
# Set the entrypoint to the startup script
|
||||
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 dicedtomato
|
||||
Copyright (c) 2022 dicedtomato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
163
README.md
163
README.md
@@ -1,28 +1,159 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||
|
||||
A ShareX/file upload server that is easy to use, packed with features, and with an easy setup!
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||

|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Easy setup instructions on [docs](https://zipline.diced.cf) (One command install `docker-compose up`)
|
||||
- Image uploading
|
||||
- Image compression
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||
- Discord embeds (OG metadata)
|
||||
- Gallery viewer, and multiple file format support
|
||||
- Code highlighting
|
||||
- Fully customizable Discord webhook notifications
|
||||
- OAuth2 registration (Discord and GitHub)
|
||||
- Two-Factor authentication with Google Authenticator, Authy, etc (totp services).
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
|
||||
|
||||
## Installing
|
||||
[See how to install here](https://zipline.diced.cf/docs/get-started)
|
||||
<details>
|
||||
<summary><h2>Screenshots (click)</h2></summary>
|
||||
|
||||
View full album at [imgur](https://imgur.com/a/GzyowZ7)
|
||||
|
||||

|
||||

|
||||

|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
[See how to configure here](https://zipline.diced.cf/docs/config/overview)
|
||||
# Usage
|
||||
|
||||
## Theming
|
||||
[See how to theme here](https://zipline.diced.cf/docs/themes/reference)
|
||||
## Install & run with Docker
|
||||
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### After installing
|
||||
|
||||
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
|
||||
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
|
||||
|
||||
## Building & running from source
|
||||
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
# npm install
|
||||
yarn install
|
||||
# npm run build
|
||||
yarn build
|
||||
# npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
# NGINX Proxy
|
||||
|
||||
This section requires [NGINX](https://nginx.org/).
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80 default_server;
|
||||
client_max_body_size 100M;
|
||||
server_name <your domain (optional)>;
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Website
|
||||
|
||||
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
|
||||
|
||||
# ShareX (Windows)
|
||||
|
||||
This section requires [ShareX](https://www.getsharex.com/).
|
||||
|
||||
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux)
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
<details>
|
||||
<summary>Wayland instructions</summary>
|
||||
|
||||
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
|
||||
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
|
||||
After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
|
||||
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
|
||||
|
||||
```shell
|
||||
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
|
||||
flameshot gui -r > ~/Pictures/$DATE;
|
||||
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub and use the template, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
- The version of Zipline
|
||||
- Your OS & Browser including server OS
|
||||
- What you were expecting to see
|
||||
|
||||
## Feature requests
|
||||
|
||||
Create a discussion on GitHub, please include the following:
|
||||
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (Be detailed!)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.x.x | :white_check_mark: |
|
||||
| 3.6.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module.exports = {
|
||||
parserPreset: 'conventional-changelog-conventionalcommits',
|
||||
rules: {
|
||||
'body-leading-blank': [1, 'always'],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
'footer-leading-blank': [1, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'header-max-length': [2, 'always', 100],
|
||||
'subject-case': [
|
||||
2,
|
||||
'never',
|
||||
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'type-case': [2, 'always', 'lower-case'],
|
||||
'type-empty': [2, 'never'],
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test',
|
||||
],
|
||||
],
|
||||
'scope-enum': [
|
||||
1,
|
||||
'always',
|
||||
[
|
||||
'prisma',
|
||||
'scripts',
|
||||
'server',
|
||||
'pages',
|
||||
'config',
|
||||
'api',
|
||||
'hooks',
|
||||
'components',
|
||||
'middleware',
|
||||
'redux',
|
||||
'themes',
|
||||
'lib',
|
||||
'assets'
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
[core]
|
||||
secure = true
|
||||
secret = 'some secret'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extentions = ['jpg']
|
||||
32
docker-compose.dev.yml
Normal file
32
docker-compose.dev.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:3000'
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -1,12 +1,13 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
environment:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
@@ -15,28 +16,22 @@ services:
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
image: ghcr.io/diced/zipline
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
environment:
|
||||
- CORE_RETURN_HTTPS=false
|
||||
- CORE_SECRET=changethis
|
||||
- CORE_HOST=0.0.0.0
|
||||
- CORE_PORT=3000
|
||||
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
|
||||
- CORE_LOGGER=true
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- './uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
pg_data:
|
||||
|
||||
5
docker-entrypoint.sh
Normal file
5
docker-entrypoint.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
node --enable-source-maps dist/index.js
|
||||
1384
mimes.json
Normal file
1384
mimes.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,17 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
images: {
|
||||
domains: [
|
||||
// For sharex icon in manage user
|
||||
'getsharex.com',
|
||||
// For flameshot icon, and maybe in the future other stuff from github
|
||||
'raw.githubusercontent.com',
|
||||
// Google Icon
|
||||
'madeby.google.com',
|
||||
],
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
|
||||
127
package.json
127
package.json
@@ -1,59 +1,98 @@
|
||||
{
|
||||
"name": "zip3",
|
||||
"version": "3.3.0",
|
||||
"name": "zipline",
|
||||
"version": "3.7.0-rc4",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist",
|
||||
"build": "npm-run-all build:server build:schema build:next",
|
||||
"build-ci": "cross-env ZIPLINE_DOCKER_BUILD=1 npm-run-all build:server build:schema build:next",
|
||||
"build:server": "tsup",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"start": "node server",
|
||||
"format": "prettier --write ./src/**/*.{ts,tsx} ./*.{md,js,json,yml}",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node dist",
|
||||
"lint": "next lint",
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts"
|
||||
"docker:up": "docker-compose up",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
|
||||
"docker:up-dev": "docker-compose --file docker-compose.dev.yml up",
|
||||
"docker:down-dev": "docker-compose --file docker-compose.dev.yml down",
|
||||
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
|
||||
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
|
||||
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
|
||||
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
|
||||
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
|
||||
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/material": "^5.0.2",
|
||||
"@mui/styles": "^5.0.1",
|
||||
"@prisma/client": "^3.7.0",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"argon2": "^0.28.2",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"multer": "^1.4.2",
|
||||
"next": "^12.0.7",
|
||||
"prisma": "^3.7.0",
|
||||
"react": "17.0.2",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"yup": "^0.32.9"
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.10.5",
|
||||
"@mantine/dropzone": "^5.10.5",
|
||||
"@mantine/form": "^5.10.5",
|
||||
"@mantine/hooks": "^5.10.5",
|
||||
"@mantine/modals": "^5.10.5",
|
||||
"@mantine/next": "^5.10.5",
|
||||
"@mantine/notifications": "^5.10.5",
|
||||
"@mantine/prism": "^5.10.5",
|
||||
"@prisma/client": "^4.10.1",
|
||||
"@prisma/internals": "^4.10.1",
|
||||
"@prisma/migrate": "^4.10.1",
|
||||
"@sapphire/shapeshift": "^3.8.1",
|
||||
"@tanstack/react-query": "^4.24.10",
|
||||
"argon2": "^0.30.3",
|
||||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"exiftool-vendored": "^21.2.0",
|
||||
"fastify": "^4.13.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.5.0",
|
||||
"katex": "^0.16.4",
|
||||
"mantine-datatable": "^1.8.6",
|
||||
"minio": "^7.0.32",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^13.2.1",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^4.10.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-markdown": "^8.0.5",
|
||||
"recharts": "^2.4.3",
|
||||
"recoil": "^0.7.6",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/minio": "^7.0.16",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.14.2",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"release": "^6.3.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
"prettier": "^2.8.4",
|
||||
"tsup": "^6.6.3",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@3.3.1"
|
||||
}
|
||||
|
||||
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
5
prisma/migrations/20220221053815_format/migration.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImageFormat" AS ENUM ('UUID', 'DATE', 'RANDOM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "format" "ImageFormat" NOT NULL DEFAULT E'RANDOM';
|
||||
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
2
prisma/migrations/20220221055817_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
- You are about to drop the `Theme` table. If the table is not empty, all the data it contains will be lost.
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Theme";
|
||||
2
prisma/migrations/20220304004623_domains/migration.sql
Normal file
2
prisma/migrations/20220304004623_domains/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "password" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ratelimited` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "ratelimited",
|
||||
ADD COLUMN "ratelimit" TIMESTAMP(3);
|
||||
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invite" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
|
||||
ALTER COLUMN "expires_at" DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "expires_at" TIMESTAMP(3);
|
||||
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
2
prisma/migrations/20220816212407_avatar/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "oauth" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "oauthProvider" TEXT,
|
||||
ALTER COLUMN "password" DROP NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "superAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
2
prisma/migrations/20221028005627_max_views/migration.sql
Normal file
2
prisma/migrations/20221028005627_max_views/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER;
|
||||
31
prisma/migrations/20221030222208_oauth_reform/migration.sql
Normal file
31
prisma/migrations/20221030222208_oauth_reform/migration.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `oauth` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `oauthAccessToken` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `oauthProvider` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OauthProviders" AS ENUM ('DISCORD', 'GITHUB');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "oauth",
|
||||
DROP COLUMN "oauthAccessToken",
|
||||
DROP COLUMN "oauthProvider";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuth" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"provider" "OauthProviders" NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuth_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_key" ON "OAuth"("provider");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "OAuth_provider_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "refresh" TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OauthProviders" ADD VALUE 'GOOGLE';
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `username` to the `OAuth` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "username" TEXT NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_urlId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Invite" DROP CONSTRAINT "Invite_createdById_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[provider,oauthId]` on the table `OAuth` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `oauthId` to the `OAuth` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");
|
||||
13
prisma/migrations/20230111055303_embed/migration.sql
Normal file
13
prisma/migrations/20230111055303_embed/migration.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `embedColor` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedSiteName` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedTitle` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "embedColor",
|
||||
DROP COLUMN "embedSiteName",
|
||||
DROP COLUMN "embedTitle",
|
||||
ADD COLUMN "embed" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "file" TO "name";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Stats" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" RENAME TO "FileNameFormat";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME TO "File";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleImage" RENAME TO "InvisibleFile";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleFile" RENAME COLUMN "imageId" TO "fileId";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_imageId_fkey" TO "InvisibleFile_fileId_fkey";
|
||||
ALTER INDEX "InvisibleImage_imageId_key" RENAME TO "InvisibleFile_fileId_key";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_pkey" TO "InvisibleFile_pkey";
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_pkey" TO "File_pkey";
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "originalName" TEXT;
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_userId_fkey" TO "File_userId_fkey";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_invis_key" RENAME TO "InvisibleFile_invis_key";
|
||||
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Folder" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false;
|
||||
2
prisma/migrations/20230226051016_file_size/migration.sql
Normal file
2
prisma/migrations/20230226051016_file_size/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -8,76 +8,129 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
password String?
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("dark_blue")
|
||||
customTheme Theme?
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimited Boolean @default(false)
|
||||
images Image[]
|
||||
administrator Boolean @default(false)
|
||||
superAdmin Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embed Json @default("{}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
}
|
||||
|
||||
model Theme {
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
primary String
|
||||
secondary String
|
||||
error String
|
||||
warning String
|
||||
info String
|
||||
border String
|
||||
mainBackground String
|
||||
paperBackground String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
files File[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
invisible InvisibleImage?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
enum FileNameFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
originalName String?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
size Int @default(0)
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleFile?
|
||||
format FileNameFormat @default(RANDOM)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Url {
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity String?
|
||||
created_at DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
|
||||
model Invite {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
used Boolean @default(false)
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById Int
|
||||
}
|
||||
|
||||
model OAuth {
|
||||
id Int @id @default(autoincrement())
|
||||
provider OauthProviders
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
username String
|
||||
oauthId String?
|
||||
token String
|
||||
refresh String?
|
||||
|
||||
@@unique([provider, oauthId])
|
||||
}
|
||||
|
||||
enum OauthProviders {
|
||||
DISCORD
|
||||
GITHUB
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword, createToken } from '../src/lib/util';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`
|
||||
When logging into Zipline for the first time, use these credentials:
|
||||
|
||||
Username: "${user.username}"
|
||||
Password: "password"
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,13 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
const prismaRun = require('./prisma-run');
|
||||
|
||||
module.exports = async (config) => {
|
||||
try {
|
||||
await prismaRun(config.core.database_url, ['migrate', 'deploy']);
|
||||
await prismaRun(config.core.database_url, ['generate'], true);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
Logger.get('db').error('there was an error.. exiting..');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
const { readdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const validateConfig = require('../server/validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('./mimes');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
(async () => {
|
||||
const config = readConfig();
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
const mime = mimes[extname(x)] ?? 'application/octet-stream';
|
||||
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data
|
||||
});
|
||||
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
|
||||
process.exit();
|
||||
})();
|
||||
@@ -1,78 +0,0 @@
|
||||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.azw': 'application/vnd.amazon.ebook',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.bmp': 'image/bmp',
|
||||
'.bz': 'application/x-bzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.cda': 'application/x-cdf',
|
||||
'.csh': 'application/x-csh',
|
||||
'.css': 'text/css',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/vnd.microsoft.icon',
|
||||
'.ics': 'text/calendar',
|
||||
'.jar': 'application/java-archive',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.jsonld': 'application/ld+json',
|
||||
'.mid': 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.mjs': 'text/javascript',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.mpkg': 'application/vnd.apple.installer+xml',
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.oga': 'audio/ogg',
|
||||
'.ogv': 'video/ogg',
|
||||
'.ogx': 'application/ogg',
|
||||
'.opus': 'audio/opus',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.php': 'application/x-httpd-php',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.rtf': 'application/rtf',
|
||||
'.sh': 'application/x-sh',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.swf': 'application/x-shockwave-flash',
|
||||
'.tar': 'application/x-tar',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
'.ts': 'video/mp2t',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.vsd': 'application/vnd.visio',
|
||||
'.wav': 'audio/wav',
|
||||
'.weba': 'audio/webm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xml': 'application/xml',
|
||||
'.xul': 'application/vnd.mozilla.xul+xml',
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = (url, args, nostdout = false) => {
|
||||
return new Promise((res, rej) => {
|
||||
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
|
||||
env: {
|
||||
DATABASE_URL: url,
|
||||
...process.env
|
||||
},
|
||||
});
|
||||
|
||||
let a = '';
|
||||
|
||||
proc.stdout.on('data', d => {
|
||||
if (!nostdout) console.log(d.toString());
|
||||
a += d.toString();
|
||||
});
|
||||
proc.stderr.on('data', d => {
|
||||
if (!nostdout) console.log(d.toString());
|
||||
rej(d.toString());
|
||||
});
|
||||
proc.stdout.on('end', () => res(a));
|
||||
proc.stdout.on('close', () => res(a));
|
||||
});
|
||||
};
|
||||
260
server/index.js
260
server/index.js
@@ -1,260 +0,0 @@
|
||||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir, readdir } = require('fs/promises');
|
||||
const { execSync } = require('child_process');
|
||||
const { extname, join } = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const getFile = require('./static');
|
||||
const prismaRun = require('../scripts/prisma-run');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('../scripts/mimes');
|
||||
const deployDb = require('../scripts/deploy-db');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
Logger.get('server').info(`starting zipline@${version} server`);
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function log(url, status) {
|
||||
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||
return Logger.get('url').info(url);
|
||||
}
|
||||
|
||||
function shouldUseYarn() {
|
||||
try {
|
||||
execSync('yarnpkg --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const a = readConfig();
|
||||
const config = await validateConfig(a);
|
||||
|
||||
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
|
||||
if (data.match(/Following migrations? have not yet been applied/)) {
|
||||
Logger.get('database').info('some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
Logger.get('database').info('finished applying migrations');
|
||||
} else Logger.get('database').info('migrations up to date');
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev,
|
||||
}, config.core.port, config.core.host);
|
||||
|
||||
await app.prepare();
|
||||
await stat('./.next');
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/r')) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
embed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else if (image.embed) {
|
||||
handle(req, res);
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
if (config.core.logger) log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
Logger.get('server').error(e);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
srv.on('listening', () => {
|
||||
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
if (process.platform === 'linux' && dev) execSync(`xdg-open ${config.core.secure ? 'https' : 'http'}://${config.core.host === '0.0.0.0' ? 'localhost' : config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
setInterval(async () => {
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
if (config.core.logger) Logger.get('server').info('stats updated');
|
||||
}, config.core.stats_interval * 1000);
|
||||
} catch (e) {
|
||||
if (e.message && e.message.startsWith('Could not find a production')) {
|
||||
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else if (e.code && e.code === 'ENOENT') {
|
||||
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else {
|
||||
Logger.get('server').error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
async function sizeOfDir(directory) {
|
||||
const files = await readdir(directory);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(directory, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function bytesToRead(bytes) {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
|
||||
async function getStats(prisma, config) {
|
||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||
const byUser = await prisma.image.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
const count_users = await prisma.user.count();
|
||||
|
||||
const count_by_user = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: byUser[i].userId,
|
||||
},
|
||||
});
|
||||
|
||||
count_by_user.push({
|
||||
username: user.username,
|
||||
count: byUser[i]._count._all,
|
||||
});
|
||||
}
|
||||
|
||||
const count = await prisma.image.count();
|
||||
const viewsCount = await prisma.image.groupBy({
|
||||
by: ['views'],
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
|
||||
const typesCount = await prisma.image.groupBy({
|
||||
by: ['mimetype'],
|
||||
_count: {
|
||||
mimetype: true,
|
||||
},
|
||||
});
|
||||
const types_count = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||
|
||||
return {
|
||||
size: bytesToRead(size),
|
||||
size_num: size,
|
||||
count,
|
||||
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
||||
count_users,
|
||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||
types_count: types_count.sort((a,b) => b.count-a.count),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
const { readFile } = require('fs/promises');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = async (dir, file) => {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), dir, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
const yup = require('yup');
|
||||
|
||||
|
||||
const validator = yup.object({
|
||||
core: yup.object({
|
||||
secure: yup.bool().default(false),
|
||||
secret: yup.string().min(8).required(),
|
||||
host: yup.string().default('0.0.0.0'),
|
||||
port: yup.number().default(3000),
|
||||
database_url: yup.string().required(),
|
||||
logger: yup.boolean().default(true),
|
||||
stats_interval: yup.number().default(1800),
|
||||
}).required(),
|
||||
uploader: yup.object({
|
||||
route: yup.string().required(),
|
||||
length: yup.number().default(6),
|
||||
directory: yup.string().required(),
|
||||
admin_limit: yup.number().default(104900000),
|
||||
user_limit: yup.number().default(104900000),
|
||||
disabled_extensions: yup.array().default([]),
|
||||
}).required(),
|
||||
urls: yup.object({
|
||||
route: yup.string().required(),
|
||||
length: yup.number().default(6),
|
||||
}).required(),
|
||||
ratelimit: yup.object({
|
||||
user: yup.number().default(0),
|
||||
admin: yup.number().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
module.exports = config => {
|
||||
try {
|
||||
return validator.validateSync(config, { abortEarly: false });
|
||||
} catch (e) {
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
|
||||
}
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Snackbar, Alert as MuiAlert } from '@mui/material';
|
||||
|
||||
export default function Alert({ open, setOpen, severity, message }) {
|
||||
return (
|
||||
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
|
||||
<MuiAlert severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<MuiBackdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={open}
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</MuiBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
import { Card as MCard, Title } from '@mantine/core';
|
||||
|
||||
export default function Card({ name, children, ...other }) {
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: '100%' }} {...other}>
|
||||
<CardContent>
|
||||
<Typography variant='h3'>{name}</Typography>
|
||||
{children}
|
||||
</CardContent>
|
||||
</MuiCard>
|
||||
<MCard p='md' shadow='sm' {...other}>
|
||||
{name && <Title order={2}>{name}</Title>}
|
||||
{children}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function CenteredBox({ children, ...other }) {
|
||||
return (
|
||||
<Box
|
||||
justifyContent='center'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
37
src/components/CodeInput.tsx
Normal file
37
src/components/CodeInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createStyles, MantineSize, Textarea } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
|
||||
input: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
|
||||
height: '80vh',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function CodeInput({ ...props }) {
|
||||
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (document.activeElement?.tagName !== 'TEXTAREA') return;
|
||||
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
target.value = `${target.value.substring(0, start)} ${target.value.substring(end)}`;
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Textarea classNames={{ input: classes.input }} {...props} />;
|
||||
}
|
||||
398
src/components/File.tsx
Normal file
398
src/components/File.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
HardDriveIcon,
|
||||
FileIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
HashIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
StarIcon,
|
||||
} from './icons';
|
||||
import MutedText from './MutedText';
|
||||
import Type from './Type';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Tooltip label={other.tooltip}>
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages,
|
||||
reducedActions = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(image.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: image.id, favorite: !image.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const inFolder = image.folderId;
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + image.folderId, 'DELETE', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderMinusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(image.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={image}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<SimpleGrid
|
||||
my='md'
|
||||
cols={3}
|
||||
breakpoints={[
|
||||
{ maxWidth: 600, cols: 1 },
|
||||
{ maxWidth: 900, cols: 2 },
|
||||
{ maxWidth: 1200, cols: 3 },
|
||||
]}
|
||||
>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={image.name} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
|
||||
<FileMeta Icon={HardDriveIcon} title='Size' subtitle={bytesToHuman(image.size || 0)} />
|
||||
<FileMeta Icon={EyeIcon} title='Views' subtitle={image?.views?.toLocaleString()} />
|
||||
{image.maxViews && (
|
||||
<FileMeta
|
||||
Icon={EyeIcon}
|
||||
title='Max views'
|
||||
subtitle={image?.maxViews?.toLocaleString()}
|
||||
tooltip={`This file will be deleted after being viewed ${image?.maxViews?.toLocaleString()} times.`}
|
||||
/>
|
||||
)}
|
||||
<FileMeta
|
||||
Icon={CalendarIcon}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(image.createdAt))}
|
||||
tooltip={new Date(image?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{image.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={ClockIcon}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(image.expiresAt))}
|
||||
tooltip={new Date(image.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/dashboard/metadata/${image.id}`, '_blank')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${
|
||||
folders.data.find((f) => f.id === image.folderId)?.name ?? ''
|
||||
}"`}
|
||||
>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
variant='filled'
|
||||
onClick={removeFromFolder}
|
||||
loading={folders.isLoading}
|
||||
>
|
||||
<FolderMinusIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={image.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={image.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<StarIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(image.url, '_blank')}>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(image.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
onClick={() => setOpen(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
345
src/components/File/FileModal.tsx
Normal file
345
src/components/File/FileModal.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
HashIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
StarIcon,
|
||||
} from '../icons';
|
||||
import Type from '../Type';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
setOpen,
|
||||
file,
|
||||
loading,
|
||||
refresh,
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file: any;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: boolean;
|
||||
}) {
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const folders = useFolders();
|
||||
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(file.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
setOpen(false);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: file.id, favorite: !file.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const inFolder = file.folderId;
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + file.folderId, 'DELETE', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderMinusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(file.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{file.name}</Title>} size='xl'>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={file}
|
||||
src={`/r/${encodeURI(file.name)}`}
|
||||
alt={file.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<SimpleGrid
|
||||
my='md'
|
||||
cols={3}
|
||||
breakpoints={[
|
||||
{ maxWidth: 600, cols: 1 },
|
||||
{ maxWidth: 900, cols: 2 },
|
||||
{ maxWidth: 1200, cols: 3 },
|
||||
]}
|
||||
>
|
||||
<FileMeta Icon={FileIcon} title='Name' subtitle={file.name} />
|
||||
<FileMeta Icon={ImageIcon} title='Type' subtitle={file.mimetype} />
|
||||
<FileMeta Icon={EyeIcon} title='Views' subtitle={file?.views?.toLocaleString()} />
|
||||
{file.maxViews && (
|
||||
<FileMeta
|
||||
Icon={EyeIcon}
|
||||
title='Max views'
|
||||
subtitle={file?.maxViews?.toLocaleString()}
|
||||
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
|
||||
/>
|
||||
)}
|
||||
<FileMeta
|
||||
Icon={CalendarIcon}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(file.createdAt))}
|
||||
tooltip={new Date(file?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{file.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={ClockIcon}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(file.expiresAt))}
|
||||
tooltip={new Date(file.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={HashIcon} title='ID' subtitle={file.id} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
|
||||
>
|
||||
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
|
||||
<FolderMinusIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={file.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<StarIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
|
||||
<ExternalLinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
90
src/components/File/index.tsx
Normal file
90
src/components/File/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card, Group, LoadingOverlay, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { useState } from 'react';
|
||||
import MutedText from '../MutedText';
|
||||
import Type from '../Type';
|
||||
import FileModal from './FileModal';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Tooltip label={other.tooltip}>
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages,
|
||||
reducedActions = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
file={image}
|
||||
loading={loading}
|
||||
refresh={refresh}
|
||||
reducedActions={reducedActions}
|
||||
exifEnabled={exifEnabled}
|
||||
/>
|
||||
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${encodeURI(image.name)}`}
|
||||
alt={image.name}
|
||||
onClick={() => setOpen(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import AudioIcon from '@mui/icons-material/Audiotrack';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages(true);
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
};
|
||||
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
'image': <img {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}>
|
||||
<CardMedia
|
||||
sx={{ height: 320, fontSize: 70, width: '100%' }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='alert-dialog-title'>
|
||||
{image.file}
|
||||
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Type
|
||||
style={{ width: '100%' }}
|
||||
src={image.url}
|
||||
alt={image.url}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDelete} color='inherit'>Delete</Button>
|
||||
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
|
||||
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +1,453 @@
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Header,
|
||||
Image,
|
||||
Input,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Navbar,
|
||||
NavLink,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
Folder as FolderIcon,
|
||||
Upload as UploadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Autorenew as ResetIcon,
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon,
|
||||
Brush as BrushIcon,
|
||||
Link as URLIcon,
|
||||
} from '@mui/icons-material';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
import { friendlyThemeName, themes } from 'components/Theming';
|
||||
import Select from 'components/input/Select';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
ScrollArea,
|
||||
Select,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useVersion } from 'lib/queries/version';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
ActivityIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CrossIcon,
|
||||
DeleteIcon,
|
||||
DiscordIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PencilIcon,
|
||||
SettingsIcon,
|
||||
TagIcon,
|
||||
TypeIcon,
|
||||
UploadIcon,
|
||||
UserIcon,
|
||||
} from './icons';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
const items = [
|
||||
export type NavbarItems = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
link?: string;
|
||||
children?: NavbarItems[];
|
||||
if?: (user: any, props: any) => boolean;
|
||||
};
|
||||
|
||||
const items: NavbarItems[] = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
icon: <HomeIcon size={18} />,
|
||||
text: 'Home',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon />,
|
||||
icon: <FileIcon size={18} />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <URLIcon />,
|
||||
icon: <FolderIcon size={18} />,
|
||||
text: 'Folders',
|
||||
link: '/dashboard/folders',
|
||||
},
|
||||
{
|
||||
icon: <ActivityIcon size={18} />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <LinkIcon size={18} />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
icon: <UploadIcon size={18} />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload',
|
||||
children: [
|
||||
{
|
||||
icon: <UploadIcon size={18} />,
|
||||
text: 'File',
|
||||
link: '/dashboard/upload/file',
|
||||
},
|
||||
{
|
||||
icon: <TypeIcon size={18} />,
|
||||
text: 'Text',
|
||||
link: '/dashboard/upload/text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
text: 'Administration',
|
||||
if: (user, _) => user.administrator as boolean,
|
||||
children: [
|
||||
{
|
||||
icon: <UserIcon size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
if: () => true,
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
if: (_, props) => props.invites,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
export default function Layout({ children, props }) {
|
||||
const [user, setUser] = useRecoilState(userSelector);
|
||||
|
||||
function CopyTokenDialog({ open, setOpen, token }) {
|
||||
const handleCopyToken = () => {
|
||||
copy(token);
|
||||
setOpen(false);
|
||||
const { title, oauth_providers: unparsed } = props;
|
||||
const oauth_providers = JSON.parse(unparsed);
|
||||
const icons = {
|
||||
GitHub: GitHubIcon,
|
||||
Discord: DiscordIcon,
|
||||
Google: GoogleIcon,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='copy-dialog-title'>
|
||||
Copy Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='copy-dialog-description'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleCopyToken} color='inherit'>
|
||||
Copy
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
for (const provider of oauth_providers) {
|
||||
provider.Icon = icons[provider.name];
|
||||
}
|
||||
|
||||
function ResetTokenDialog({ open, setOpen, setToken }) {
|
||||
const handleResetToken = async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (a.success) setToken(a.success);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<DialogTitle id='reset-dialog-title'>
|
||||
Reset Token
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='reset-dialog-description'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button onClick={handleResetToken} color='inherit'>
|
||||
Reset
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const external_links = JSON.parse(props.external_links ?? '[]');
|
||||
|
||||
export default function Layout({ children, user, loading, noPaper }) {
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const version = useVersion();
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
|
||||
const avatar = user?.avatar ?? null;
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const theme = useMantineTheme();
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = e => setAnchorEl(e.currentTarget);
|
||||
const handleClose = (cmd: 'copy' | 'reset') => () => {
|
||||
switch (cmd) {
|
||||
case 'copy':
|
||||
setCopyOpen(true);
|
||||
break;
|
||||
case 'reset':
|
||||
setResetOpen(true);
|
||||
break;
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async event => {
|
||||
const handleUpdateTheme = async (value) => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: event.target.value || 'dark_blue',
|
||||
systemTheme: value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
dispatch(updateUser(newUser));
|
||||
|
||||
setUser(newUser);
|
||||
router.replace(router.pathname);
|
||||
|
||||
showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <PencilIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth },
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dashboard/manage' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem>
|
||||
<BrushIcon sx={{ mr: 2 }} />
|
||||
<Select
|
||||
variant='standard'
|
||||
label='Theme'
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
fullWidth
|
||||
>
|
||||
{Object.keys(themes).map(t => (
|
||||
<MenuItem value={t} key={t}>
|
||||
{friendlyThemeName[t]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{items.map((item, i) => (
|
||||
<Link key={i} href={item.link} passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
</Link>
|
||||
))}
|
||||
{user && user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
||||
<ListItemText primary='Users' />
|
||||
</ListItem>
|
||||
</Link>
|
||||
)}
|
||||
</List>
|
||||
|
||||
</div>
|
||||
);
|
||||
const openResetToken = () =>
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Reset Token?</Title>,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Token Reset',
|
||||
message:
|
||||
'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
const openCopyToken = () =>
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Copy Token</Title>,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your
|
||||
behalf.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: (
|
||||
<Text size='sm'>
|
||||
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
|
||||
the token manually.
|
||||
<br />
|
||||
<Group position='left' spacing='sm'>
|
||||
<Text>Your token is:</Text>
|
||||
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
|
||||
</Group>
|
||||
</Text>
|
||||
),
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Backdrop open={loading} />
|
||||
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant='h5'
|
||||
noWrap
|
||||
component='div'
|
||||
sx={{ display: { sm: 'none' } }}
|
||||
>
|
||||
Zipline
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
color='inherit'
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar pt='sm' hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
|
||||
<Navbar.Section grow component={ScrollArea}>
|
||||
{items
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link, children }) =>
|
||||
children ? (
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
defaultOpened={children.map((x) => x.link).includes(router.pathname)}
|
||||
>
|
||||
{children
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</NavLink>
|
||||
) : (
|
||||
<Link href={link} key={text} passHref legacyBehavior>
|
||||
<NavLink
|
||||
component='a'
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
{external_links.length
|
||||
? external_links.map(({ label, link }, i) => (
|
||||
<Link href={link} passHref key={i} legacyBehavior>
|
||||
<NavLink
|
||||
label={label}
|
||||
component='a'
|
||||
target='_blank'
|
||||
variant='light'
|
||||
icon={<ExternalLinkIcon />}
|
||||
/>
|
||||
</Link>
|
||||
))
|
||||
: null}
|
||||
</Navbar.Section>
|
||||
{version.isSuccess ? (
|
||||
<Navbar.Section>
|
||||
<Tooltip
|
||||
label={
|
||||
version.data.update
|
||||
? `There is a new ${version.data.updateToType} version: ${
|
||||
version.data.versions[version.data.updateToType]
|
||||
}`
|
||||
: `You are running the latest ${version.data.isUpstream ? 'upstream' : 'stable'} version`
|
||||
}
|
||||
>
|
||||
<AccountIcon />
|
||||
</Button>
|
||||
<Badge
|
||||
m='md'
|
||||
radius='md'
|
||||
size='lg'
|
||||
variant='dot'
|
||||
color={version.data.update ? 'red' : 'primary'}
|
||||
>
|
||||
{version.data.versions.current}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
) : null}
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={70} p='md'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size='sm'
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Title ml='sm'>{title}</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Menu
|
||||
id='zipline-user-menu'
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
styles={{
|
||||
item: {
|
||||
'@media (max-width: 768px)': {
|
||||
padding: '1rem',
|
||||
width: '80vw',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem disableRipple>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dash/manage' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem onClick={handleClose('copy')}>
|
||||
<CopyIcon sx={{ mr: 2 }} /> Copy Token
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose('reset')}>
|
||||
<ResetIcon sx={{ mr: 2 }} /> Reset Token
|
||||
</MenuItem>
|
||||
<Link href='/auth/logout' passHref>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
|
||||
sx={(t) => ({
|
||||
backgroundColor: 'inherit',
|
||||
'&:hover': {
|
||||
backgroundColor: t.other.hover,
|
||||
},
|
||||
color: t.colorScheme === 'dark' ? 'white' : 'black',
|
||||
})}
|
||||
size='xl'
|
||||
p='sm'
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{user.username} ({user.id}){' '}
|
||||
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
|
||||
</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<SettingsIcon />} href='/dashboard/manage'>
|
||||
Manage Account
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<CopyIcon />}
|
||||
onClick={() => {
|
||||
openCopyToken();
|
||||
}}
|
||||
>
|
||||
Copy Token
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => {
|
||||
openResetToken();
|
||||
}}
|
||||
color='red'
|
||||
>
|
||||
Reset Token
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} icon={<LogoutIcon />} href='/auth/logout' color='red'>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<>
|
||||
{oauth_providers
|
||||
.filter((x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase())
|
||||
)
|
||||
.map(({ name, Icon }, i) => (
|
||||
<>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
key={i}
|
||||
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
|
||||
>
|
||||
Logged in with {capitalize(name)}
|
||||
</Menu.Item>
|
||||
</>
|
||||
))}
|
||||
{oauth_providers.filter((x) =>
|
||||
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.Divider />
|
||||
) : null}
|
||||
</>
|
||||
<Menu.Item closeMenuOnClick={false} icon={<PencilIcon />}>
|
||||
<Select
|
||||
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
|
||||
data={Object.keys(themes).map((t) => ({
|
||||
value: t,
|
||||
label: friendlyThemeName[t],
|
||||
}))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component='nav'
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 },
|
||||
}}
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
p='md'
|
||||
shadow='xs'
|
||||
sx={(t) => ({
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.dark[0],
|
||||
})}
|
||||
>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
onClose={() => setMobileOpen(false)}
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true,
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
|
||||
{user && noPaper ? children : (
|
||||
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{children}
|
||||
</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,5 @@
|
||||
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
import { NextLink } from '@mantine/next';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
locale={locale}
|
||||
>
|
||||
<a ref={ref} {...other} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
// A styled version of the Next.js Link component:
|
||||
// https://nextjs.org/docs/#with-link
|
||||
const Link = forwardRef(function Link(props: any, ref) {
|
||||
const {
|
||||
activeClassName = 'active',
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles.
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = typeof href === 'string' ? href : href.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: router.pathname === pathname && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof href === 'string' && (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref} to={href} {...other} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
component={NextLinkComposed}
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={href}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Link;
|
||||
export default function Link(props) {
|
||||
return <NextLink legacyBehavior {...props} />;
|
||||
}
|
||||
|
||||
9
src/components/MutedText.tsx
Normal file
9
src/components/MutedText.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function MutedText({ children, ...props }) {
|
||||
return (
|
||||
<Text color='dimmed' size='xl' {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
75
src/components/PasswordStrength.tsx
Normal file
75
src/components/PasswordStrength.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// https://mantine.dev/core/password-input/
|
||||
|
||||
import { Box, PasswordInput, Popover, Progress, Text } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon, CrossIcon } from './icons';
|
||||
|
||||
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
return (
|
||||
<Text color={meets ? 'teal' : 'red'} sx={{ display: 'flex', alignItems: 'center' }} mt='sm' size='sm'>
|
||||
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const requirements = [
|
||||
{ re: /[0-9]/, label: 'Includes number' },
|
||||
{ re: /[a-z]/, label: 'Includes lowercase letter' },
|
||||
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
|
||||
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
|
||||
];
|
||||
|
||||
function getStrength(password: string) {
|
||||
let multiplier = password.length > 7 ? 0 : 1;
|
||||
|
||||
requirements.forEach((requirement) => {
|
||||
if (!requirement.re.test(password)) {
|
||||
multiplier += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
|
||||
}
|
||||
|
||||
export default function PasswordStrength({ value, setValue, setStrength, ...props }) {
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const checks = requirements.map((requirement, index) => (
|
||||
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
|
||||
));
|
||||
|
||||
const strength = getStrength(value);
|
||||
setStrength(strength);
|
||||
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
position='bottom'
|
||||
width='target'
|
||||
withArrow
|
||||
trapFocus={false}
|
||||
styles={{
|
||||
dropdown: {
|
||||
zIndex: 999999,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div onFocusCapture={() => setPopoverOpened(true)} onBlurCapture={() => setPopoverOpened(false)}>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='A strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Progress color={color} value={strength} size={7} mb='md' />
|
||||
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
|
||||
{checks}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
27
src/components/SmallTable.tsx
Normal file
27
src/components/SmallTable.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Box, Table } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
|
||||
export function SmallTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map((col) => (
|
||||
<td key={randomId()}>{col.format ? col.format(row[col.id]) : row[col.id]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
71
src/components/StatCard.tsx
Normal file
71
src/components/StatCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Card, createStyles, Group, Text } from '@mantine/core';
|
||||
import { ArrowDownRight, ArrowUpRight } from 'react-feather';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing.xl * 1.5,
|
||||
},
|
||||
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
},
|
||||
|
||||
diff: {
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
icon: {
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[4],
|
||||
},
|
||||
|
||||
title: {
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}));
|
||||
|
||||
interface StatsGridProps {
|
||||
stat: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
desc: string;
|
||||
diff?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function StatCard({ stat }: StatsGridProps) {
|
||||
const { classes } = useStyles();
|
||||
if (stat.diff) stat.diff = Math.round(stat.diff);
|
||||
|
||||
return (
|
||||
<Card p='md' radius='md' key={stat.title}>
|
||||
<Group position='apart'>
|
||||
<Text size='xs' color='dimmed' className={classes.title}>
|
||||
{stat.title}
|
||||
</Text>
|
||||
{stat.icon}
|
||||
</Group>
|
||||
|
||||
<Group align='flex-end' spacing='xs' mt='md'>
|
||||
<Text className={classes.value}>{stat.value}</Text>
|
||||
{typeof stat.diff == 'number' && (
|
||||
<>
|
||||
<Text color={stat.diff >= 0 ? 'teal' : 'red'} size='sm' weight={500} className={classes.diff}>
|
||||
<span>{stat.diff === Infinity ? '∞' : stat.diff}%</span>
|
||||
{stat.diff >= 0 ? <ArrowUpRight size={16} /> : <ArrowDownRight size={16} />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='xs' color='dimmed' mt='sm'>
|
||||
{stat.desc}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,142 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// themes
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dark from 'lib/themes/dark';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import nord from 'lib/themes/nord';
|
||||
import polar from 'lib/themes/polar';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import dark from 'lib/themes/dark';
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import nord from 'lib/themes/nord';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import createTheme from 'lib/themes';
|
||||
import { createEmotionCache, MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const themes = {
|
||||
'dark_blue': dark_blue,
|
||||
'dark': dark,
|
||||
'ayu_dark': ayu_dark,
|
||||
'ayu_mirage': ayu_mirage,
|
||||
'ayu_light': ayu_light,
|
||||
'nord': nord,
|
||||
'polar': polar,
|
||||
'dracula': dracula,
|
||||
system: (colorScheme: 'dark' | 'light') => (colorScheme === 'dark' ? dark_blue : light_blue),
|
||||
dark_blue,
|
||||
light_blue,
|
||||
dark,
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
qogir_dark,
|
||||
};
|
||||
|
||||
export const friendlyThemeName = {
|
||||
'dark_blue': 'Dark Blue',
|
||||
'dark': 'Very Dark',
|
||||
'ayu_dark': 'Ayu Dark',
|
||||
'ayu_mirage': 'Ayu Mirage',
|
||||
'ayu_light': 'Ayu Light',
|
||||
'nord': 'Nord',
|
||||
'polar': 'Polar',
|
||||
'dracula': 'Dracula',
|
||||
system: 'System Theme',
|
||||
dark_blue: 'Dark Blue',
|
||||
light_blue: 'Light Blue',
|
||||
dark: 'Very Dark',
|
||||
ayu_dark: 'Ayu Dark',
|
||||
ayu_mirage: 'Ayu Mirage',
|
||||
ayu_light: 'Ayu Light',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
matcha_dark_azul: 'Matcha Dark Azul',
|
||||
qogir_dark: 'Qogir Dark',
|
||||
};
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps }) {
|
||||
let t;
|
||||
const cache = createEmotionCache({ key: 'zipline' });
|
||||
|
||||
const user = useStoreSelector(state => state.user);
|
||||
if (!user) t = themes.dark_blue;
|
||||
else {
|
||||
if (user.customTheme) {
|
||||
t = createTheme({
|
||||
type: 'dark',
|
||||
primary: user.customTheme.primary,
|
||||
secondary: user.customTheme.secondary,
|
||||
error: user.customTheme.error,
|
||||
warning: user.customTheme.warning,
|
||||
info: user.customTheme.info,
|
||||
border: user.customTheme.border,
|
||||
background: {
|
||||
main: user.customTheme.mainBackground,
|
||||
paper: user.customTheme.paperBackground,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
||||
}
|
||||
}
|
||||
export default function ZiplineTheming({ Component, pageProps, ...props }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
let theme: MantineThemeOverride;
|
||||
|
||||
if (!user) theme = themes.system(colorScheme);
|
||||
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
|
||||
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
|
||||
}, [user, theme]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={t}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
emotionCache={cache}
|
||||
theme={{
|
||||
...theme,
|
||||
fontFamily: 'Ubuntu, sans-serif',
|
||||
fontFamilyMonospace: 'Ubuntu Mono, monospace',
|
||||
headings: {
|
||||
fontFamily: 'Ubuntu, sans-serif',
|
||||
},
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: (t) => ({
|
||||
root: {
|
||||
backgroundColor: t.other.AppShell_backgroundColor,
|
||||
},
|
||||
}),
|
||||
},
|
||||
NavLink: {
|
||||
styles: (t) => ({
|
||||
icon: {
|
||||
paddingLeft: t.spacing.sm,
|
||||
},
|
||||
}),
|
||||
},
|
||||
Modal: {
|
||||
defaultProps: {
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
exitTransitionDuration: 100,
|
||||
},
|
||||
},
|
||||
Popover: {
|
||||
defaultProps: {
|
||||
transition: 'pop',
|
||||
shadow: 'lg',
|
||||
},
|
||||
},
|
||||
LoadingOverlay: {
|
||||
defaultProps: {
|
||||
overlayBlur: 3,
|
||||
overlayColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
},
|
||||
Loader: {
|
||||
defaultProps: {
|
||||
variant: 'dots',
|
||||
},
|
||||
},
|
||||
Card: {
|
||||
styles: (t) => ({
|
||||
root: {
|
||||
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
|
||||
},
|
||||
}),
|
||||
},
|
||||
Image: {
|
||||
styles: (t) => ({
|
||||
placeholder: {
|
||||
backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider position='top-center' style={{ marginTop: -10 }}>
|
||||
{props.children ? props.children : <Component {...pageProps} />}
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
162
src/components/Type.tsx
Normal file
162
src/components/Type.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import exts from 'lib/exts';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
|
||||
import KaTeX from './render/KaTeX';
|
||||
import Markdown from './render/Markdown';
|
||||
import PrismCode from './render/PrismCode';
|
||||
|
||||
function PlaceholderContent({ text, Icon }) {
|
||||
return (
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ text, Icon, ...props }) {
|
||||
if (props.onClick)
|
||||
return (
|
||||
<UnstyledButton sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
|
||||
const type =
|
||||
(file.type ?? file.mimetype) === ''
|
||||
? file.name.split('.').pop()
|
||||
: (file.type ?? file.mimetype).split('/')[0];
|
||||
|
||||
const media = /^(video|audio|image|text)/.test(type);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const shouldRenderMarkdown = file.name.endsWith('.md');
|
||||
const shouldRenderTex = file.name.endsWith('.tex');
|
||||
const shouldRenderCode: boolean = Object.keys(exts).includes(file.name.split('.').pop());
|
||||
|
||||
const [loading, setLoading] = useState(type === 'text' && popup);
|
||||
|
||||
if ((type === 'text' || shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && popup) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + file.name);
|
||||
const text = await res.text();
|
||||
|
||||
setText(text);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
|
||||
const renderAlert = () => {
|
||||
return (
|
||||
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
|
||||
You are{props.overrideRender ? ' not ' : ' '}viewing a rendered version of the file
|
||||
<Button
|
||||
mx='md'
|
||||
onClick={() => props.setOverrideRender(!props.overrideRender)}
|
||||
compact
|
||||
variant='light'
|
||||
>
|
||||
View {props.overrideRender ? 'rendered' : 'raw'}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
|
||||
return (
|
||||
<>
|
||||
{renderAlert()}
|
||||
<Card p='md' my='sm'>
|
||||
{shouldRenderMarkdown && <Markdown code={text} />}
|
||||
{shouldRenderTex && <KaTeX code={text} />}
|
||||
{shouldRenderCode && !(shouldRenderTex || shouldRenderMarkdown) && (
|
||||
<PrismCode code={text} ext={type} />
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
if (media && disableMediaPreview) {
|
||||
return <Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />;
|
||||
}
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={FileIcon}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return popup ? (
|
||||
media ? (
|
||||
{
|
||||
video: <video width='100%' autoPlay muted controls {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <audio autoPlay muted controls {...props} style={{ width: '100%' }} />,
|
||||
text: (
|
||||
<>
|
||||
{loading ? (
|
||||
<LoadingOverlay visible={loading} />
|
||||
) : (
|
||||
<>
|
||||
{(shouldRenderMarkdown || shouldRenderTex) && renderAlert()}
|
||||
<PrismCode code={text} ext={file.name.split('.').pop()} {...props} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}[type]
|
||||
) : (
|
||||
<Text>Can't preview {file.type || file.mimetype}</Text>
|
||||
)
|
||||
) : media ? (
|
||||
{
|
||||
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${file.name})`} {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${file.name})`} {...props} />,
|
||||
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${file.name})`} {...props} />,
|
||||
}[type]
|
||||
) : (
|
||||
<Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />
|
||||
);
|
||||
}
|
||||
28
src/components/dropzone/Dropzone.tsx
Normal file
28
src/components/dropzone/Dropzone.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Box, Group, SimpleGrid, Text, useMantineTheme } from '@mantine/core';
|
||||
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
|
||||
import { ImageIcon } from 'components/icons';
|
||||
|
||||
export default function Dropzone({ loading, onDrop, children }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={2}
|
||||
breakpoints={[
|
||||
{ maxWidth: 'md', cols: 1 },
|
||||
{ maxWidth: 'xs', cols: 1 },
|
||||
]}
|
||||
>
|
||||
<MantineDropzone onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<ImageIcon size={80} />
|
||||
|
||||
<Text size='xl' inline>
|
||||
Drag files here or click to select files
|
||||
</Text>
|
||||
</Group>
|
||||
</MantineDropzone>
|
||||
<Box>{children}</Box>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
73
src/components/dropzone/DropzoneFile.tsx
Normal file
73
src/components/dropzone/DropzoneFile.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ActionIcon, Badge, Box, Card, Group, HoverCard, Table, useMantineTheme } from '@mantine/core';
|
||||
import Type from 'components/Type';
|
||||
import { X } from 'react-feather';
|
||||
|
||||
export function FilePreview({ file }: { file: File }) {
|
||||
return (
|
||||
<Type
|
||||
file={file}
|
||||
autoPlay
|
||||
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
disableMediaPreview={false}
|
||||
popup
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDropzone({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<HoverCard shadow='md'>
|
||||
<HoverCard.Target>
|
||||
{/* <Badge size='lg'>{file.name}</Badge> */}
|
||||
<Card shadow='sm' radius='sm' p='sm'>
|
||||
<Group position='center' spacing='xl'>
|
||||
{file.name}
|
||||
</Group>
|
||||
</Card>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
{/* x button that will remove file */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
color: theme.colorScheme === 'dark' ? 'white' : 'white',
|
||||
}}
|
||||
m='xs'
|
||||
>
|
||||
<ActionIcon onClick={onRemove} size='sm' color='red' variant='filled'>
|
||||
<X />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
|
||||
<Group grow>
|
||||
<FilePreview file={file} />
|
||||
|
||||
<Table sx={{ color: theme.colorScheme === 'dark' ? 'white' : 'white' }} ml='md'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{file.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{file.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Modified</td>
|
||||
<td>{new Date(file.lastModified).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
5
src/components/icons/ActivityIcon.tsx
Normal file
5
src/components/icons/ActivityIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Activity } from 'react-feather';
|
||||
|
||||
export default function ActivityIcon({ ...props }) {
|
||||
return <Activity size={15} {...props} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user