Compare commits

...

84 Commits

Author SHA1 Message Date
diced ae11a29057 feat(v4.6.0): version 2026-05-11 16:24:14 -07:00
diced 4776d9e85f fix: totp 2fa QOL #1073 2026-05-11 16:18:37 -07:00
diced 41b63e6f25 fix: #1081 2026-05-11 16:07:41 -07:00
diced 24b332c23e fix: build errors 2026-05-09 17:55:05 -07:00
diced 3fd9154e57 fix: #1072 2026-05-09 17:54:24 -07:00
diced 3f71769ec6 fix: #1069 2026-05-09 17:18:14 -07:00
diced a99b0f4f1d refactor: use access tokens for file/url passwords
no longer using cookies, since they are buggy and weird with caching
using a access token that "expires" in 10 minutes
2026-05-04 18:21:53 -07:00
diced 15f5279ddb fix: perf improvements 2026-04-27 17:51:00 -07:00
diced 87a2dfbda6 fix: thin text files 2026-04-27 16:47:57 -07:00
diced c7d2b3010f fix: add tags/folder editing to new viewer 2026-04-27 16:47:44 -07:00
diced 5119806147 fix: build errors 2026-04-25 19:10:14 -07:00
diced 33104ce1be fix: rework passwd protected file logic 2026-04-25 19:07:39 -07:00
diced eeb1c51fb2 fix: build errors 2026-04-24 22:11:17 -07:00
diced 756dee6bba fix: impl serverside pagination folders (#1052) 2026-04-24 22:06:46 -07:00
Tomasz Kołodziej a0907e8791 fix: return 404 status on not-found SPA fallback (#1061) (#1063)
The non-API branch of setNotFoundHandler served the Next.js index shell
without setting the status code, so clients saw HTTP 200 with a Not-Found
UI. This broke monitoring, ingress proxy_intercept_errors rules,
crawlers, and curl -f. Set status 404 explicitly before serving the
index.

Closes #1061

Co-authored-by: dicedtomato <git@diced.sh>
2026-04-23 23:29:10 -07:00
diced 5a58abeb51 fix: #1062 2026-04-23 23:26:35 -07:00
diced 72d8c693c7 chore: update packages 2026-04-19 21:58:06 -07:00
diced 7caf314ce1 fix: session serialization errors 2026-04-19 21:49:19 -07:00
diced 677927b4a6 fix: blob urls not persisting 2026-04-18 22:43:32 -07:00
diced ac0b718f77 fix: date format #1056 2026-04-18 22:42:16 -07:00
diced db3a1b88ad fix: passkey fields validation (#1047) 2026-04-18 22:35:09 -07:00
diced a97cf32682 feat: make 'fullscreen' viewer default 2026-04-18 16:24:41 -07:00
diced 7e2b4ed1bb fix: build errors 2026-04-18 16:24:14 -07:00
diced a7fdf5afed fix: fix issues and polish new fullscreen viewer 2026-04-18 16:08:42 -07:00
diced db8adcc768 fix: #384 again 2026-04-18 16:07:59 -07:00
diced 135cf1982a fix: build errors 2026-04-15 23:23:56 -07:00
diced 9925300e9d feat: add details drawer for fullscreen viewer 2026-04-15 23:19:56 -07:00
diced 3bf125b4b4 fix: leftover import 2026-04-15 00:16:47 -07:00
diced dc9abe4383 feat: new file viewer 2026-04-15 00:15:49 -07:00
diced 1ccbc878f8 fix: add arrow key control (#1049) 2026-04-14 17:39:10 -07:00
diced aa43f66570 fix: add missing metrics interval 2026-04-13 21:50:52 -07:00
diced 7e3bba5e55 feat: overhaul oauth + PKCE for OIDC
refactored a lot of oauth stuff, so there may be bugs
2026-04-13 21:33:11 -07:00
diced 82e1fe4824 fix: naming 2026-04-12 22:51:41 -07:00
diced 818d3f5518 refactor: clean up main file
split into different files for maintainability
2026-04-11 22:11:13 -07:00
diced 23c131f45a feat: add unix sockets binding support 2026-04-11 21:28:22 -07:00
diced 3c5fd8effe feat: file nav buttons (#1046)
<- and -> on file modal
2026-04-11 17:49:12 -07:00
diced 377e3dc73d fix: throw errors on uploads 2026-04-10 22:44:59 -07:00
diced f75457da1c fix: various md rendering errors 2026-04-10 22:12:36 -07:00
diced d6b0ba3b16 fix: better error handling for uploading 2026-04-09 14:51:43 -07:00
diced 1a1bc46667 fix: delete old cached views 2026-04-09 14:51:27 -07:00
diced eb1c39933a fix: #1040 2026-04-08 18:26:57 -07:00
diced b070dbf432 fix: build errors 2026-04-07 21:49:43 -07:00
diced 8af5ad05d6 fix: add register link on login page
other minor fixes as well incl.
2026-04-07 21:45:03 -07:00
diced f0bcb4a019 feat(v4.5.3): version 2026-04-06 22:14:56 -07:00
diced 4c86b7fc38 fix: packages update + various perf fixes 2026-04-06 22:14:15 -07:00
diced 9b7759520c fix: random typos 2026-04-06 15:23:44 -07:00
diced e3e77c7916 feat: new server settings layout 2026-04-05 22:55:42 -07:00
diced 13282988e8 feat: instantaneous thumb generation 2026-04-05 19:06:02 -07:00
diced 00f4254227 fix: typo 2026-04-05 12:30:53 -07:00
diced 669c61eae0 fix: don't shorten reserved urls 2026-04-05 12:30:33 -07:00
diced 1ee1aca589 fix: hide other logins when none available 2026-04-02 22:24:12 -07:00
diced d49fd6a1f0 fix: build error 2026-04-01 17:55:29 -07:00
diced 8128e3deb0 fix: #1029 2026-04-01 17:53:22 -07:00
diced 67a9fe34b4 fix: use devalue for ssr 2026-03-31 23:30:10 -07:00
diced 7a3c4223ec fix: add warning 2026-03-31 17:16:54 -07:00
diced cb2590aae5 feat(v4.5.2): version 2026-03-28 23:58:39 -07:00
diced 93ff18a120 fix: reformat routes to include catch-all 2026-03-28 23:57:51 -07:00
diced 4343f130fb fix: refine batch uploads 2026-03-28 23:36:52 -07:00
diced 5e9778d18a fix: mfa showing when disabled 2026-03-28 20:35:49 -07:00
diced 9bcccbc8aa fix: #1031 2026-03-28 19:41:02 -07:00
diced 00ddf86ea8 feat(v4.5.1): version 2026-03-27 23:13:22 -07:00
diced cc582f6d20 fix: clean up tampered settings scroller 2026-03-27 23:11:49 -07:00
diced 318b09feae fix: use query for file modals 2026-03-27 23:11:39 -07:00
diced d55e36375d fix: tags overflow 2026-03-27 23:11:25 -07:00
diced 40b917df30 refactor: put client stuff in lib/client 2026-03-27 13:58:35 -07:00
diced 053a50d1bc refactor: uploading files
may fix: #1019
2026-03-27 13:46:32 -07:00
diced 78b554cbe8 fix: #1021 2026-03-27 12:41:50 -07:00
diced bf7a4e92e3 chore: update packages 2026-03-25 20:56:18 -07:00
diced 3b56e7f1ce fix: maybe fix #1021 again 2026-03-24 15:52:20 -07:00
diced 1d91a008e1 fix: linting 2026-03-24 12:45:43 -07:00
diced ff1fc0eb75 fix: limit copying jpeg/webp qr codes 2026-03-24 12:43:48 -07:00
diced 430774082c fix: maybe fix #1021 2026-03-24 12:40:29 -07:00
Benjamin Jørgensen ef67fdd553 fix: encode password #1007 (#1023)
* Url Encode Password Query Parameter

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>

* Fix lint error

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>

* fix: use appendPassword

---------

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>
Co-authored-by: dicedtomato <git@diced.sh>
2026-03-24 12:27:29 -07:00
dicedtomato 73b5528586 Merge commit from fork 2026-03-24 12:19:02 -07:00
diced 3f65029464 fix: use title in folder page 2026-03-23 17:34:17 -07:00
diced 6da1719fda refactor: fix up DashboardFileType 2026-03-23 17:33:14 -07:00
diced 4d85b41ec3 feat: add qrcode gen to invites 2026-03-22 22:47:18 -07:00
diced ac5c0a1cb3 fix: table hydration error 2026-03-22 22:42:41 -07:00
diced eb22598f20 feat: add qrcode for urls (#812) 2026-03-22 22:37:54 -07:00
diced 7a4c29d9d4 fix: mutate folders on update 2026-03-22 21:58:55 -07:00
diced 255336d74f fix: version badge 2026-03-22 21:51:33 -07:00
diced dc625fc682 fix: add Domain setting (#1009) 2026-03-22 12:46:02 -07:00
diced d457cb8693 fix: don't render when not opened 2026-03-22 12:45:53 -07:00
diced 331c4b4a4e chore: update flake.nix 2026-03-21 22:39:56 -07:00
208 changed files with 8594 additions and 7209 deletions
+4 -1
View File
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
This is the recommended way to run Zipline:
```yml
```yaml
services:
postgresql:
image: postgres:16
@@ -91,6 +91,9 @@ volumes:
pgdata:
```
> [!WARNING]
> Zipline requires a cpu with AVX support. We don't provide binaries or images that have support for non-AVX cpus
### Volumes
- `./uploads` - The folder where all the user uploads are stored (the default is `./uploads`)
+1 -1
View File
@@ -101,7 +101,7 @@ export default defineConfig(
},
settings: {
react: { version: 'detect' },
react: { version: '19' },
},
},
);
Generated
+645 -39
View File
@@ -6,7 +6,8 @@
"devenv"
],
"flake-compat": [
"devenv"
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
@@ -18,11 +19,11 @@
]
},
"locked": {
"lastModified": 1748883665,
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
@@ -32,22 +33,142 @@
"type": "github"
}
},
"cachix_2": {
"inputs": {
"devenv": [
"devenv",
"crate2nix"
],
"flake-compat": [
"devenv",
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"cachix_3": {
"inputs": {
"devenv": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"git-hooks": "git-hooks_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix_2",
"crate2nix_stable": "crate2nix_stable",
"devshell": "devshell_2",
"flake-compat": "flake-compat_2",
"flake-parts": "flake-parts_2",
"nix-test-runner": "nix-test-runner_2",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"pre-commit-hooks": "pre-commit-hooks_2"
},
"locked": {
"lastModified": 1773440526,
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"crate2nix_stable": {
"inputs": {
"cachix": "cachix_3",
"crate2nix_stable": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": "nixpkgs_3",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1769627083,
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "0.15.0",
"repo": "crate2nix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"crate2nix": "crate2nix",
"flake-compat": "flake-compat_3",
"flake-parts": "flake-parts_3",
"git-hooks": "git-hooks_3",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
]
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1753888869,
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
"lastModified": 1774134162,
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
"owner": "cachix",
"repo": "devenv",
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
"type": "github"
},
"original": {
@@ -68,14 +189,87 @@
"url": "file:///dev/null"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"devshell_2": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-compat_2": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-compat_3": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
@@ -88,16 +282,17 @@
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
@@ -107,15 +302,58 @@
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_3": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_4": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1753121425,
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
@@ -128,20 +366,82 @@
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_2": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"flake-compat"
],
"gitignore": "gitignore_2",
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_3": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore_5",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1750779888,
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
"lastModified": 1772893680,
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
"type": "github"
},
"original": {
@@ -151,6 +451,102 @@
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_3": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_4": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_5": {
"inputs": {
"nixpkgs": [
"devenv",
@@ -178,7 +574,10 @@
"devenv",
"flake-compat"
],
"flake-parts": "flake-parts",
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
@@ -195,43 +594,101 @@
]
},
"locked": {
"lastModified": 1752773918,
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
"lastModified": 1774103430,
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
"owner": "cachix",
"repo": "nix",
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.30",
"ref": "devenv-2.32",
"repo": "nix",
"type": "github"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nix-test-runner_2": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1773634079,
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
"owner": "nix-community",
"repo": "nixd",
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752827260,
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
"owner": "nixos",
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "nixos",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"type": "github"
},
"original": {
@@ -240,12 +697,161 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1769433173,
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1773964973,
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable",
"flake-compat"
],
"gitignore": "gitignore_3",
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"pre-commit-hooks_2": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_4",
"nixpkgs": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs"
"flake-parts": "flake-parts_4",
"nixpkgs": "nixpkgs_4"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1773630837,
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1772660329,
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
+2 -8
View File
@@ -6,8 +6,8 @@
flake = false;
};
# node 24.4.1, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
# node 24.14, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/812b3986fd1568f7a858f97fcf425ad996ba7d25";
flake-parts.url = "github:hercules-ci/flake-parts";
devenv.url = "github:cachix/devenv";
@@ -58,7 +58,6 @@
ffmpeg
# for testing docker
colima
docker
docker-compose
];
@@ -75,11 +74,6 @@
downall.exec = ''
process-compose down
'';
# ensure that volumes are mounted with write access for docker containers
start_colima.exec = ''
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
'';
};
enterShell = ''
+57 -59
View File
@@ -2,15 +2,15 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.5.0",
"version": "4.6.0",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
"validate": "tsx scripts/validate.ts",
"openapi": "tsx scripts/openapi.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
@@ -22,108 +22,106 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@aws-sdk/client-s3": "3.1032.0",
"@aws-sdk/lib-storage": "3.1032.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"@fastify/swagger": "^9.6.1",
"@mantine/charts": "^8.3.9",
"@mantine/code-highlight": "^8.3.9",
"@mantine/core": "^8.3.9",
"@mantine/dates": "^8.3.9",
"@mantine/dropzone": "^8.3.9",
"@mantine/form": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantine/modals": "^8.3.9",
"@mantine/notifications": "^8.3.9",
"@fastify/static": "^9.1.1",
"@fastify/swagger": "^9.7.0",
"@mantine/charts": "^9.0.2",
"@mantine/code-highlight": "^9.0.2",
"@mantine/core": "^9.0.2",
"@mantine/dates": "^9.0.2",
"@mantine/dropzone": "^9.0.2",
"@mantine/form": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"@mantine/modals": "^9.0.2",
"@mantine/notifications": "^9.0.2",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@smithy/node-http-handler": "^4.1.1",
"@tabler/icons-react": "^3.35.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@smithy/node-http-handler": "^4.5.3",
"@tabler/icons-react": "^3.41.1",
"archiver": "^7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.12.1",
"asciinema-player": "^3.15.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.2",
"commander": "^14.0.3",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.19",
"dayjs": "^1.11.20",
"detect-browser": "^5.3.0",
"dotenv": "^17.2.3",
"devalue": "^5.7.1",
"fast-glob": "^3.3.3",
"fastify": "^5.6.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.33.0",
"katex": "^0.16.27",
"mantine-datatable": "^8.3.9",
"isomorphic-dompurify": "^3.9.0",
"katex": "^0.16.45",
"mantine-datatable": "^8.3.13",
"marked-react": "^4.0.0",
"ms": "^2.1.3",
"multer": "2.0.2",
"otplib": "^12.0.1",
"multer": "2.1.1",
"otplib": "^13.4.0",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"react-virtuoso": "^4.18.5",
"sharp": "^0.34.5",
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"swr": "^2.4.1",
"vite": "^8.0.9",
"zod": "^4.3.6",
"zustand": "^5.0.9"
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/he": "^1.2.3",
"@types/katex": "^0.16.7",
"@types/katex": "^0.16.8",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.6",
"postcss": "^8.5.10",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"prettier": "^3.8.3",
"sass": "^1.98.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.2"
},
"engines": {
"node": ">=22"
+2561 -3702
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
+4 -3
View File
@@ -58,9 +58,10 @@ model Zipline {
featuresOauthRegistration Boolean @default(false)
featuresDeleteOnMaxViews Boolean @default(true)
featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresThumbnailsInstantaneous Boolean @default(false)
featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false)
+1 -1
View File
@@ -25,7 +25,7 @@ export default function ReloadPage() {
Why am I seeing this?
</Button>
<Collapse in={view}>
<Collapse expanded={view}>
<GenericError
title='Failed to fetch dynamically imported module'
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
+1 -1
View File
@@ -1,4 +1,4 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
+64 -28
View File
@@ -6,9 +6,9 @@ import TotpModal from '@/components/pages/login/TotpModal';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/hooks/useLogin';
import useObjectState from '@/lib/hooks/useObjectState';
import { useTitle } from '@/lib/hooks/useTitle';
import useLogin from '@/lib/client/hooks/useLogin';
import useObjectState from '@/lib/client/hooks/useObjectState';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
Anchor,
Box,
@@ -33,16 +33,21 @@ import {
IconCircleKeyFilled,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { eitherTrue } from '@/lib/primitive';
export default function Login() {
useTitle('Login');
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin();
const { user, mutate } = useLogin({
swrConfig: {
shouldRetryOnError: false,
},
});
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
@@ -123,6 +128,12 @@ export default function Login() {
}
};
const handleTotpChange = async (val: string) => {
setTotp('pin', val);
if (val.length === 6) await handleLoginSubmit(form.values, val);
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
@@ -134,7 +145,7 @@ export default function Login() {
<TotpModal
state={totp}
onPinChange={(val) => setTotp('pin', val)}
onPinChange={(val) => handleTotpChange(val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
@@ -205,30 +216,55 @@ export default function Login() {
/>
)}
<Divider label='or' />
{eitherTrue(
config.mfa.passkeys && browserSupportsWebAuthn(),
config.oauthEnabled.discord,
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
config.features.userRegistration,
) && (
<>
<Divider label='or' />
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
{config.mfa.passkeys && browserSupportsWebAuthn() && (
<PasskeyAuthButton onAuthSuccess={mutate} />
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
provider='Discord'
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.github && (
<ExternalAuthButton provider='GitHub' leftSection={<IconBrandGithubFilled size='1.1rem' />} />
)}
{config.oauthEnabled.google && (
<ExternalAuthButton
provider='Google'
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.oidc && (
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
provider='Discord'
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.github && (
<ExternalAuthButton
provider='GitHub'
leftSection={<IconBrandGithubFilled size='1.1rem' />}
/>
)}
{config.oauthEnabled.google && (
<ExternalAuthButton
provider='Google'
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.oidc && (
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
{config.features.userRegistration && (
<Text ta='center' mt='md'>
Don&apos;t have an account?{' '}
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
Register
</Anchor>
</Text>
)}
</>
)}
</Stack>
</Paper>
</Center>
+11 -17
View File
@@ -1,6 +1,7 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import useUser from '@/lib/client/hooks/useUser';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
Button,
Center,
@@ -18,8 +19,8 @@ import {
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { getWebClient } from '@/lib/api/detect';
@@ -31,8 +32,6 @@ export function Component() {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const {
data: config,
error: configError,
@@ -59,6 +58,8 @@ export function Component() {
},
);
const { user, loading: userLoading } = useUser();
const form = useForm({
initialValues: {
username: '',
@@ -74,17 +75,6 @@ export function Component() {
}),
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
navigate('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!config) return;
@@ -138,7 +128,11 @@ export function Component() {
}
};
if (loading || configLoading) return <LoadingOverlay visible />;
if (userLoading || configLoading) return <LoadingOverlay visible />;
if (user) {
return <Navigate to='/dashboard' replace />;
}
if (!config || configError) {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
import { type Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
Anchor,
Button,
+1 -1
View File
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Container, LoadingOverlay } from '@mantine/core';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Terms of Service');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardInvites from '@/components/pages/invites';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Invites');
@@ -1,5 +1,5 @@
import DashboardServerSettings from '@/components/pages/serverSettings';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Server Settings');
@@ -1,5 +1,5 @@
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { Params, redirect, useLoaderData } from 'react-router-dom';
export async function loader({ params }: { params: Params<string> }) {
@@ -1,5 +1,5 @@
import DashboardUsers from '@/components/pages/users';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Users');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardFiles from '@/components/pages/files';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Files');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardFolders from '@/components/pages/folders';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Folders');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardHome from '@/components/pages/dashboard';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle();
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardMetrics from '@/components/pages/metrics';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { isAdministrator } from '@/lib/role';
import { redirect } from 'react-router-dom';
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardSettings from '@/components/pages/settings';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Settings');
+1 -1
View File
@@ -1,5 +1,5 @@
import UploadFile from '@/components/pages/upload/File';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Upload File');
+1 -1
View File
@@ -1,5 +1,5 @@
import UploadText from '@/components/pages/upload/Text';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Upload Text');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardURLs from '@/components/pages/urls';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('URLs');
+100 -10
View File
@@ -1,4 +1,8 @@
import { useApiPagination } from '@/components/pages/files/useApiPagination';
import { type Response } from '@/lib/api/response';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
@@ -8,6 +12,8 @@ import {
Card,
Container,
Group,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -15,18 +21,26 @@ import {
Title,
} from '@mantine/core';
import { IconFolder, IconUpload } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
import { lazy, Suspense, useEffect, useMemo } from 'react';
import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
export async function loader({ params, request }: { params: Params<string>; request: Request }) {
const url = new URL(request.url);
const page = url.searchParams.get('page') ?? '1';
const perpage = url.searchParams.get('perpage') ?? '15';
const res = await fetch(
`/api/server/folder/${params.id}?page=${encodeURIComponent(page)}&perpage=${encodeURIComponent(perpage)}`,
);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
initial: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
@@ -57,10 +71,34 @@ function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
);
}
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { initial } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const [page, setPage] = useQueryState('page', 1);
const [perpage] = useQueryState('perpage', 15);
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
{
route: `/api/server/folder/${initial.folder.id}`,
page,
perpage,
sort: 'createdAt',
order: 'desc',
},
{ fallbackData: initial, keepPreviousData: true, revalidateOnFocus: false },
);
const folder = data?.folder ?? initial.folder;
const files = data?.page ?? [];
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 0;
useTitle(folder.name ?? 'Folder');
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [];
@@ -77,10 +115,30 @@ export function Component() {
const breadcrumbs = buildBreadcrumbs();
const children = (folder.children ?? []) as Partial<Folder>[];
const from = totalRecords === 0 ? 0 : (page - 1) * perpage + 1;
const to = Math.min(page * perpage, totalRecords);
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (files.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => files.map((file) => file.id), [files]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<Container my='lg'>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => setCurrent(open ? (currentFile?.id ?? null) : null)}
file={currentFile}
reduce
sequenced
/>
{breadcrumbs.length > 1 && (
<Breadcrumbs mb='md'>
{breadcrumbs.map((item, index) => (
@@ -129,7 +187,7 @@ export function Component() {
</>
)}
{(folder.files?.length ?? 0) > 0 && (
{(files.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
@@ -142,20 +200,52 @@ export function Component() {
}}
spacing='md'
>
{folder.files?.map((file: any) => (
{files.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
<DashboardFile file={file} reduce onOpen={(fileId) => setCurrent(fileId)} />
</Suspense>
))}
</SimpleGrid>
</>
)}
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
{children.length === 0 && totalRecords === 0 && (
<Text c='dimmed' mt='md'>
This folder is empty.
</Text>
)}
<Group justify='space-between' align='center' mt='md'>
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
<Group gap='sm'>
<Select
value={perpage.toString()}
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
onChange={(value) => {
setSearchParams((prev) => {
prev.set('perpage', value ?? '15');
prev.set('page', '1');
return prev;
});
}}
w={80}
size='xs'
variant='filled'
disabled={isLoading}
/>
<Pagination
value={page}
onChange={setPage}
total={cachedPages}
size='sm'
withControls
withEdges
disabled={isLoading}
/>
</Group>
</Group>
</Container>
</>
);
+5 -2
View File
@@ -2,7 +2,7 @@ import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { type Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { data, Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
@@ -11,8 +11,11 @@ export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) throw data('Folder not found', { status: 404 });
const d = (await res.json()) as Response['/api/server/folder/[id]'];
if (!d.folder) throw data('Folder not found', { status: 404 });
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
folder: d.folder,
};
}
+15 -28
View File
@@ -1,5 +1,7 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
@@ -8,7 +10,6 @@ import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
@@ -24,9 +25,7 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
import { useTitle } from '@/lib/hooks/useTitle';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
@@ -34,7 +33,7 @@ type SsrData = {
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
token?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
@@ -43,14 +42,7 @@ export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
// Fix dates that were stringified during SSR
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
const { file, password, code, user, host, metrics, filesRoute, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -58,7 +50,7 @@ export default function ViewFileId() {
useTitle(file.originalName ?? file.name ?? 'View File');
return password && !pw ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -71,7 +63,8 @@ export default function ViewFileId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/${file.name}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
@@ -112,7 +105,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -121,7 +114,7 @@ export default function ViewFileId() {
</Group>
</Paper>
<Collapse in={detailsOpen}>
<Collapse expanded={detailsOpen}>
<Paper m='md' p='md' withBorder>
{user?.view!.content && (
<Typography>
@@ -150,15 +143,9 @@ export default function ViewFileId() {
</Paper>
</Collapse>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
<Center m='sm'>
<DashboardFileType file={file as unknown as File} token={token} show code={code} fullscreen />
</Center>
</>
) : (
<>
@@ -208,7 +195,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
to={`/raw/${file.name}${token ? `?token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
@@ -219,7 +206,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -228,7 +215,7 @@ export default function ViewFileId() {
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
{user?.view!.content && (
<Typography>
+5 -3
View File
@@ -6,10 +6,11 @@ export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
token?: string | null;
}>();
if (!data) return null;
const { url, password } = data;
const { url, password, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -18,7 +19,7 @@ export default function ViewUrlId() {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -31,7 +32,8 @@ export default function ViewUrlId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
+22 -28
View File
@@ -8,6 +8,11 @@ import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Root from './Root';
const fourOhFourCatchall = {
path: '*',
Component: FourOhFour,
};
export async function dashboardLoader() {
try {
const res = await fetch('/api/server/settings/web');
@@ -28,21 +33,21 @@ export const router = createBrowserRouter([
{
Component: Root,
path: '/',
HydrateFallback: () => null,
children: [
{
ErrorBoundary: RootErrorBoundary,
children: [
{ path: '*', Component: FourOhFour },
fourOhFourCatchall,
{
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{ path: 'auth/login', Component: Login },
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
path: 'auth/setup',
lazy: () => import('./pages/auth/setup'),
},
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
],
},
{
@@ -59,37 +64,26 @@ export const router = createBrowserRouter([
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
// admin routes
{
path: 'upload',
children: [
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
],
},
{
path: 'admin',
loader: async () => {
const res = await fetch('/api/user');
if (!res.ok) {
return redirect('/auth/login');
}
if (!res.ok) return redirect('/auth/login');
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
},
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
{
path: 'users',
children: [
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
{
path: ':id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
},
],
path: 'admin/users/:id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
},
],
},
+13 -21
View File
@@ -1,13 +1,11 @@
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { verifyAccessToken } from '@/lib/accessToken';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
import { FastifyRequest } from 'fastify';
import { createRoutes } from './routes';
export async function render(
{
@@ -17,13 +15,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -52,31 +48,27 @@ export async function render(
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const token = req.query.token;
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
token: valid ? token : null,
};
delete (data.url as any).password;
const routes = createRoutes(themes, defaultTheme);
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
if (!valid) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
+11 -14
View File
@@ -5,24 +5,23 @@ import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { verifyAccessToken } from '@/lib/accessToken';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import { stripHtml } from '@/lib/stripHtml';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
import { stripHtml } from '@/lib/stripHtml';
export const getFile = async (id: string) =>
prisma.file.findFirst({
@@ -44,11 +43,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -94,17 +93,15 @@ export async function render(
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const token = req.query.token;
const valid = token && file.password ? verifyAccessToken(token, 'file', file.id) : false;
const hasPassword = !!file.password;
delete (file as any).password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
console.log('File is password protected');
if (!valid) {
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
@@ -141,7 +138,7 @@ export async function render(
const data = {
file,
password: hasPassword,
pw: pw || null,
token: valid ? token : null,
code,
user,
host,
+1 -1
View File
@@ -1,4 +1,4 @@
import { ViewStore, ViewType, useViewStore } from '@/lib/store/view';
import { ViewStore, ViewType, useViewStore } from '@/lib/client/store/view';
import { Center, SegmentedControl } from '@mantine/core';
import { IconLayoutGrid, IconLayoutList } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
+77 -54
View File
@@ -1,11 +1,11 @@
import type { Response } from '@/lib/api/response';
import useAvatar from '@/lib/client/hooks/useAvatar';
import useLogin from '@/lib/client/hooks/useLogin';
import { useLogout } from '@/lib/client/hooks/useLogout';
import { useUserStore } from '@/lib/client/store/user';
import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { useLogout } from '@/lib/hooks/useLogout';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
AppShell,
Avatar,
@@ -47,10 +47,11 @@ import {
IconUsersGroup,
} from '@tabler/icons-react';
import { useState } from 'react';
import { Link, Outlet, useLoaderData, useLocation } from 'react-router-dom';
import { Link, NavigateFunction, Outlet, useLoaderData, useLocation, useNavigate } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { SETTINGS_EXTERNAL_LINKS } from './pages/serverSettings';
type NavLinks = {
label: string;
@@ -123,9 +124,15 @@ const navLinks: NavLinks[] = [
{
label: 'Settings',
icon: <IconAdjustments size='1rem' />,
active: (path: string) => path === '/dashboard/admin/settings',
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
label: name,
icon,
active: (path: string) => path === url,
href: url,
})),
},
{
label: 'Actions',
@@ -150,6 +157,66 @@ const navLinks: NavLinks[] = [
},
];
const renderLinks = (
links: NavLinks[],
pathname: string,
user: Response['/api/user']['user'],
config: SafeConfig,
navigate: NavigateFunction,
) => {
const visible = (link: NavLinks) => !link.if || link.if(user as Response['/api/user']['user'], config);
const active = (link: NavLinks): boolean => {
if (!visible(link)) return false;
if (link.active(pathname)) return true;
return (link.links || []).some((child) => active(child));
};
return links.map((link) => {
if (visible(link)) {
const sublinks = link.links;
const isActive = link.active(pathname);
if (!sublinks) {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={isActive}
component={Link}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={isActive && !sublinks.some((child) => active(child))}
defaultOpened={isActive || sublinks.some((child) => active(child))}
onClick={(event) => {
if (!link.href) return;
event.preventDefault();
navigate(link.href);
}}
>
{renderLinks(sublinks, pathname, user as Response['/api/user']['user'], config, navigate)}
</NavLink>
);
}
}
return null;
});
};
export default function Layout() {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -158,6 +225,7 @@ export default function Layout() {
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const navigate = useNavigate();
const logout = useLogout();
const loaderData = useLoaderData<typeof dashboardLoader>();
@@ -327,54 +395,9 @@ export default function Layout() {
</Title>
<Divider hiddenFrom='sm' />
{navLinks
.filter((link) => !link.if || link.if(user as Response['/api/user']['user'], config))
.map((link) => {
if (!link.links) {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={location.pathname === link.href}
component={Link}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(location.pathname)}
>
{link.links
.filter(
(sublink) => !sublink.if || sublink.if(user as Response['/api/user']['user'], config),
)
.map((sublink) => (
<NavLink
key={sublink.label}
label={sublink.label}
leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={location.pathname === sublink.href}
component={Link}
to={sublink.href || ''}
prefetch='intent'
/>
))}
</NavLink>
);
}
})}
<ScrollArea mah='calc(100vh - 200px)'>
{renderLinks(navLinks, location.pathname, user as Response['/api/user']['user'], config, navigate)}
</ScrollArea>
<div style={{ marginTop: 'auto' }}>
<VersionBadge />
+118
View File
@@ -0,0 +1,118 @@
import { getDomain } from '@/lib/client/webDomain';
import { Button, Group, Image, Modal, Select, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconClipboardCheck, IconClipboardX, IconCopy, IconDownload } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
type Type = 'image/png' | 'image/jpeg' | 'image/webp';
const UNSUPPORTED_COPY = ['image/jpeg', 'image/webp'];
export default function QRCodeModal({
opened,
onClose,
url,
}: {
opened: boolean;
onClose: () => void;
url: string;
}) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [type, setType] = useState<Type>('image/png');
useEffect(() => {
if (!opened) return;
import('qrcode')
.then((QRCode) => QRCode.toDataURL(getDomain(url), { width: 500, type }))
.then(setDataUrl)
.catch(() => setDataUrl(null));
}, [opened, url, type]);
const copyImageToClipboard = async () => {
if (!dataUrl) return;
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
showNotification({
message: 'QR code image copied to clipboard',
color: 'green',
icon: <IconClipboardCheck size='1rem' />,
});
} catch (error) {
showNotification({
title: 'Failed to copy QR code image',
message: error instanceof Error ? error.message : String(error),
color: 'red',
icon: <IconClipboardX size='1rem' />,
});
}
};
const downloadImage = () => {
if (!dataUrl) return;
const link = document.createElement('a');
link.href = dataUrl;
link.style.display = 'none';
link.download = `qr-code.${type.split('/')[1]}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Modal title='QR Code' opened={opened} onClose={onClose} size='sm' centered>
{dataUrl ? (
<Image src={dataUrl} alt='QR Code' />
) : (
<Text c='red' ta='center'>
Failed to generate QR code.
</Text>
)}
<Select
mt='md'
label='Format'
value={type}
onChange={(value) => setType(value as Type)}
data={[
{ value: 'image/png', label: 'png' },
{ value: 'image/jpeg', label: 'jpeg' },
{ value: 'image/webp', label: 'webp' },
]}
size='xs'
/>
{dataUrl && (
<Group gap='xs' mt='md' grow>
<Tooltip
label={
UNSUPPORTED_COPY.includes(type)
? 'Copying this format is not supported in some browsers. You can copy the image normally via right-click or holding it.'
: ''
}
hidden={!UNSUPPORTED_COPY.includes(type)}
>
<Button
onClick={copyImageToClipboard}
leftSection={<IconCopy size='1rem' />}
disabled={UNSUPPORTED_COPY.includes(type)}
>
Copy Image
</Button>
</Tooltip>
<Button onClick={downloadImage} leftSection={<IconDownload size='1rem' />}>
Download
</Button>
</Group>
)}
</Modal>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { Config } from '@/lib/config/validate';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
import { useSettingsStore } from '@/lib/client/store/settings';
import { useUserStore } from '@/lib/client/store/user';
import { ZiplineTheme, findTheme, themeComponents } from '@/lib/theme';
import dark_blue from '@/lib/theme/builtins/dark_blue';
import { MantineProvider, createTheme } from '@mantine/core';
+14 -5
View File
@@ -1,4 +1,4 @@
import useVersion from '@/lib/hooks/useVersion';
import useVersion from '@/lib/client/hooks/useVersion';
import {
Anchor,
Badge,
@@ -14,7 +14,11 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
function DataDisplay({
items,
}: {
items: { label: string; value: string; href?: string; color?: string }[];
}) {
return (
<Paper withBorder p='sm'>
<Stack gap='xs'>
@@ -29,7 +33,7 @@ function DataDisplay({ items }: { items: { label: string; value: string; href?:
{item.value}
</Anchor>
) : (
<Text>{item.value}</Text>
<Text c={item.color ?? undefined}>{item.value}</Text>
)}
</Flex>
))}
@@ -105,10 +109,14 @@ export default function VersionBadge() {
},
{
label: 'Commit',
value: version.version.sha!,
value: version.version.sha!.slice(0, 7)!,
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
},
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
{
label: 'Upstream?',
value: version.isUpstream ? 'Yes' : 'No',
color: version.isUpstream ? 'orange' : 'green',
},
]}
/>
@@ -131,6 +139,7 @@ export default function VersionBadge() {
{
label: 'Available to update',
value: version.latest.commit.pull ? 'Yes' : 'No',
color: version.latest.commit.pull ? 'green' : 'red',
},
]}
/>
@@ -0,0 +1,22 @@
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File } from '@/lib/db/models/file';
import FileModal from './FileModal';
import FileViewer from './FileViewer';
export default function DashboardFileModal(props: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const fileModal = useSettingsStore((state) => state.settings.fileViewer);
if (fileModal === 'default') {
return <FileModal {...props} />;
}
return <FileViewer {...props} />;
}
@@ -1,6 +1,6 @@
import { File } from '@/lib/db/models/file';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/hooks/useObjectState';
import useObjectState from '@/lib/client/hooks/useObjectState';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
@@ -120,7 +120,7 @@ export default function EditFileDetailsModal({
};
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Modal zIndex={400} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
+106 -5
View File
@@ -2,14 +2,16 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
@@ -31,6 +33,8 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
@@ -50,8 +54,9 @@ import {
IconUpload,
IconUserQuestion,
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
@@ -73,15 +78,16 @@ function ActionButton({
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
}) {
} & ActionIconProps) {
return (
<Tooltip label={tooltip}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick} {...props}>
<Icon size='1rem' />
</ActionIcon>
</Tooltip>
@@ -94,15 +100,18 @@ export default function FileModal({
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const [editFileOpen, setEditFileOpen] = useState(false);
@@ -181,6 +190,34 @@ export default function FileModal({
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) {
return [state.goPrev, state.goNext, false, false];
}
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open || !sequenced) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && hasPrev) {
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
goNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
@@ -431,6 +468,70 @@ export default function FileModal({
<></>
)}
</Modal>
{open && sequenced && fileNavButtons && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
</>
)}
</>
);
}
@@ -0,0 +1,640 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
Combobox,
Drawer,
Group,
Input,
InputBase,
Paper,
Pill,
PillsInput,
Stack,
Text,
Title,
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
IconExternalLink,
IconEyeFilled,
IconFileInfo,
IconFolderMinus,
IconInfoCircle,
IconPencil,
IconRefresh,
IconStar,
IconStarFilled,
IconTags,
IconTagsOff,
IconTextRecognition,
IconTrashFilled,
IconUpload,
IconUserQuestion,
IconX,
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
addToFolder,
copyFile,
createFolderAndAdd,
deleteFile,
downloadFile,
favoriteFile,
mutateFiles,
removeFromFolder,
viewFile,
} from '../actions';
import EditFileDetailsModal from './EditFileDetailsModal';
import FileStat from './FileStat';
function ActionButton({
Icon,
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
} & ActionIconProps) {
return (
<Tooltip label={tooltip} zIndex='200'>
<ActionIcon
size='xl'
variant='subtle'
bd='1px solid var(--mantine-color-dark-4)'
color={color ?? 'gray'}
onClick={onClick}
{...props}
>
<Icon size='1.15rem' />
</ActionIcon>
</Tooltip>
);
}
export default function FileViewer({
open,
setOpen,
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const { data: folders } = useFolders(user);
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const folderCombobox = useCombobox();
const [search, setSearch] = useState('');
const handleAdd = async (value: string) => {
if (value === '$create') {
await createFolderAndAdd(file!, search.trim());
} else {
await addToFolder(file!, value);
}
};
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
user ? `/api/users/${user}/tags` : '/api/user/tags',
);
const tagsCombobox = useCombobox();
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
const handleValueRemove = (val: string) => {
setValue((current) => current.filter((v) => v !== val));
};
const handleTagsUpdate = async () => {
if (value.length === file?.tags?.length && value.every((v) => file?.tags?.map((x) => x.id).includes(v))) {
return;
}
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/files/${file!.id}`,
'PATCH',
{
tags: value,
},
);
if (error) {
showNotification({
title: 'Failed to save tags',
message: error.error,
color: 'red',
icon: <IconTagsOff size='1rem' />,
});
} else {
showNotification({
title: 'Saved tags',
message: `Saved ${data!.tags!.length} tags for file ${data!.name}`,
color: 'green',
icon: <IconTags size='1rem' />,
});
}
mutateFiles();
mutate('/api/user/tags');
};
const triggerSave = async () => {
tagsCombobox.closeDropdown();
handleTagsUpdate();
};
const values = value.map((id) => <TagPill key={id} tag={tags?.find((t) => t.id === id) || null} />);
const [editFileOpen, setEditFileOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) return [state.goPrev, state.goNext, false, false];
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
return;
}
if (!sequenced) return;
if (event.key === 'ArrowLeft' && hasPrev) {
event.preventDefault();
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
event.preventDefault();
goNext();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext, setOpen]);
const headerActionGroup = file ? (
<ActionIcon.Group>
{!reduce && (
<>
<ActionButton
Icon={IconPencil}
onClick={() => setEditFileOpen(true)}
tooltip='Edit file details'
color='orange'
/>
<ActionButton
Icon={IconTrashFilled}
onClick={() => deleteFile(warnDeletion, file, setOpen)}
tooltip='Delete file'
color='red'
/>
<ActionButton
Icon={file.favorite ? IconStarFilled : IconStar}
onClick={() => favoriteFile(file)}
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
color={file.favorite ? 'gray' : 'yellow'}
/>
</>
)}
<ActionButton
Icon={IconInfoCircle}
onClick={() => setInfoOpen((v) => !v)}
tooltip={infoOpen ? 'Hide details' : 'Show details'}
color={infoOpen ? 'cyan' : 'gray'}
/>
<ActionButton
Icon={IconExternalLink}
onClick={() => viewFile(file)}
tooltip='Open in new tab'
color='blue'
/>
<ActionButton
Icon={IconClipboardTypography}
onClick={() => copyFile(file, clipboard, true)}
tooltip='Copy raw file link'
/>
<ActionButton Icon={IconCopy} onClick={() => copyFile(file, clipboard)} tooltip='Copy file link' />
<ActionButton Icon={IconDownload} onClick={() => downloadFile(file)} tooltip='Download' />
</ActionIcon.Group>
) : null;
return (
<>
{file && (
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file} />
)}
<Drawer
opened={infoOpen}
onClose={() => setInfoOpen(false)}
position='right'
title={<Title order={2}>Details</Title>}
radius='md'
offset={20}
overlayProps={{ blur: 6 }}
>
{file && (
<Stack gap='md'>
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
<FileStat Icon={IconDeviceSdCard} title='Size' value={bytes(file.size)} />
<FileStat
Icon={IconUpload}
title='Created at'
value={new Date(file.createdAt).toLocaleString()}
/>
<FileStat
Icon={IconRefresh}
title='Updated at'
value={new Date(file.updatedAt).toLocaleString()}
/>
{file.deletesAt && !reduce && (
<FileStat
Icon={IconBombFilled}
title='Deletes at'
value={new Date(file.deletesAt).toLocaleString()}
/>
)}
<FileStat
Icon={IconEyeFilled}
title='Views'
value={file.maxViews ? `${file.views} / ${file.maxViews}` : file.views}
/>
{file.originalName && (
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
)}
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
{!reduce && (
<>
<Box>
<Title order={4} mb='xs'>
Tags
</Title>
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
pointer
onClick={() => tagsCombobox.openDropdown()}
>
<Pill.Group>
{values.length > 0 ? (
values
) : (
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
)}
<Combobox.EventsTarget>
<PillsInput.Field
type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => {
if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
event.preventDefault();
handleValueRemove(value[value.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
{tags?.length ? (
tags.map((tag) => (
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
<Group gap='sm'>
<Checkbox
checked={value.includes(tag.id)}
onChange={() => {}}
aria-hidden
tabIndex={-1}
style={{ pointerEvents: 'none' }}
/>
<TagPill tag={tag} />
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Box>
<Box>
<Title order={4} mb='xs'>
Folder
</Title>
{file.folderId ? (
<Button
color='red'
leftSection={<IconFolderMinus size='1rem' />}
onClick={() => removeFromFolder(file)}
fullWidth
>
Remove from folder &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={search}
onChange={(event) => {
folderCombobox.openDropdown();
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
{folders?.length === 0 && (
<Combobox.Empty>
You have no folders. Start typing to create a new folder for this file.
</Combobox.Empty>
)}
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
</Box>
</>
)}
</Stack>
)}
</Drawer>
<Box
onClick={() => setOpen(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 220ms cubic-bezier(0.33, 1, 0.68, 1)',
willChange: 'opacity',
}}
>
<Paper m={0} p={0} withBorder bdrs={0} style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Stack gap='sm' px='lg' py='sm' onClick={(e) => e.stopPropagation()}>
<Group justify='space-between' align='center' gap='sm' wrap='nowrap' visibleFrom='sm'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<Group gap='sm' wrap='nowrap' style={{ flexShrink: 0 }}>
{headerActionGroup}
<ActionButton Icon={IconX} tooltip='Close' onClick={() => setOpen(false)} />
</Group>
</Group>
<Stack gap='sm' hiddenFrom='sm'>
<Group justify='space-between' align='flex-start' gap='sm' wrap='nowrap'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<ActionButton
Icon={IconX}
tooltip='Close'
onClick={() => setOpen(false)}
style={{ flexShrink: 0 }}
/>
</Group>
<Group gap={0} wrap='nowrap'>
{headerActionGroup}
</Group>
</Stack>
</Stack>
</Paper>
<Box
ref={setScrollParent}
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
paddingTop: '1rem',
paddingBottom: '1rem',
marginLeft: '1rem',
marginRight: '1rem',
overflow: 'auto',
position: 'relative',
overscrollBehavior: 'contain',
}}
>
{file ? (
<Box
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
height: 'fit-content',
minWidth: 0,
minHeight: 0,
overflow: 'visible',
paddingLeft: '4rem',
paddingRight: '4rem',
}}
>
<DashboardFileType
key={file.id}
file={file}
show
fullscreen
allowZoom={false}
scrollParent={scrollParent}
/>
{open && sequenced && fileNavButtons && file && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
</>
)}
</Box>
) : null}
</Box>
</Box>
</>
);
}
+21 -4
View File
@@ -2,17 +2,34 @@ import type { File } from '@/lib/db/models/file';
import { Card } from '@mantine/core';
import { useState } from 'react';
import DashboardFileType from '../DashboardFileType';
import FileModal from './FileModal';
import DashboardFileModal from './DashboardFileModal';
import styles from './index.module.css';
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
export default function DashboardFile({
file,
reduce,
id,
onOpen,
}: {
file: File;
reduce?: boolean;
id?: string;
onOpen?: (fileId: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<>
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
<Card
shadow='md'
radius='md'
p={0}
onClick={() => (onOpen ? onOpen(file.id) : setOpen(true))}
className={styles.file}
>
<DashboardFileType key={file.id} file={file} />
</Card>
</>
-320
View File
@@ -1,320 +0,0 @@
import type { File as DbFile } from '@/lib/db/models/file';
import { useSettingsStore } from '@/lib/store/settings';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Asciinema from '../render/Asciinema';
import Pdf from '../render/Pdf';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { useUserStore } from '@/lib/store/user';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
);
}
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
);
}
function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(5px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</div>
);
}
export default function DashboardFileType({
file,
show,
password,
code,
allowZoom,
}: {
file: DbFile | File;
show?: boolean;
password?: string | null;
code?: boolean;
allowZoom?: boolean;
}) {
const user = useUserStore((state) => state.user);
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
const thumbnailRoute = user
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
: `/raw/${(file as DbFile).thumbnail?.path}`;
const dbFile = 'id' in file;
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
const [fileContent, setFileContent] = useState('');
const [type, setType] = useState(file.type.split('/')[0]);
const [open, setOpen] = useState(false);
const getText = useCallback(async () => {
try {
if (!dbFile) {
const reader = new FileReader();
reader.onload = () => {
if ((reader.result! as string).length > 1 * 1024 * 1024) {
setFileContent(
reader.result!.slice(0, 1 * 1024 * 1024) +
'\n...\nThe file is too big to display click the download icon to view/download it.',
);
} else {
setFileContent(reader.result as string);
}
};
reader.readAsText(file);
return;
}
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
});
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
);
return;
}
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text);
} catch {
setFileContent('Error loading file.');
}
}, [dbFile, file, password]);
useEffect(() => {
if (code) {
setType('text');
getText();
} else if (type === 'text') {
getText();
} else {
return;
}
}, []);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [open]);
if (disableMediaPreview && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (dbFile && file.password === true && !show)
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
if (dbFile && file.password === true && show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(`/view/${file.name}${password ? `?pw=${password}` : ''}`)}
/>
</Paper>
);
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
switch (true) {
case type === 'video':
return show ? (
<video
width='100%'
autoPlay
muted
controls
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
<Center
pos='absolute'
h='100%'
top='50%'
left='50%'
style={{
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
) : (
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'image':
return show ? (
<Center>
<MantineImage
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
maxWidth: '70vw',
maxHeight: '70vw',
}}
onClick={() => setOpen(true)}
/>
{allowZoom && open && (
<FileZoomModal setOpen={setOpen}>
<MantineImage
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</Center>
) : (
<MantineImage
fit='contain'
mah={400}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
/>
);
case type === 'audio':
return show ? (
<audio
autoPlay
muted
controls
style={{ width: '100%' }}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'text':
return show ? (
fileContent.trim() === '' ? (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
) : (
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
)
) : (
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
);
case isAsciicast === true:
return show && dbFile ? (
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
case file.type === 'application/pdf':
return show && dbFile ? (
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
default:
if (dbFile && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (dbFile && show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
else return <IconFileUnknown size={48} />;
}
}
@@ -0,0 +1,30 @@
import { Box } from '@mantine/core';
export default function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<Box
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</Box>
);
}
@@ -0,0 +1,34 @@
import { Box } from '@mantine/core';
export default function FullscreenFrame({
fullscreen,
parent,
children,
}: {
fullscreen?: boolean;
parent?: HTMLElement | null;
children: React.ReactNode;
}) {
if (!fullscreen) return <>{children}</>;
return (
<Box
style={
parent
? {
width: '100%',
height: 'auto',
maxHeight: 'none',
overflow: 'visible',
}
: {
width: 'min(96vw, calc(100vw - 3rem))',
maxHeight: 'none',
overflow: 'visible',
}
}
>
{children}
</Box>
);
}
@@ -0,0 +1,23 @@
import { Center, Stack, Text } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
export default function Placeholder({
text,
Icon,
...props
}: {
text: string;
Icon: Icon;
onClick?: () => void;
}) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
@@ -0,0 +1,268 @@
import Asciinema from '@/components/render/Asciinema';
import Pdf from '@/components/render/Pdf';
import Render from '@/components/render/Render';
import { renderMode } from '@/components/render/renderMode';
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File as DbFile } from '@/lib/db/models/file';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import fileIcon from '../fileIcon';
import FileZoomModal from './FileZoomModal';
import FullscreenFrame from './FullscreenFrame';
import useFileContents from './useFileContent';
import useFileUrls, { isDbFile } from './useFileUrls';
export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
export default function DashboardFileType({
file,
show,
token,
code,
allowZoom,
fullscreen,
scrollParent,
}: {
file: DbFile | File;
show?: boolean;
token?: string | null;
code?: boolean;
allowZoom?: boolean;
fullscreen?: boolean;
scrollParent?: HTMLElement | null;
}) {
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
const db = isDbFile(file) ? file : null;
const extension = file.name.split('.').pop() || '';
const renderIn = renderMode(extension);
const type = code ? 'text' : file.type.split('/')[0];
const fileContent = useFileContents({ enabled: type === 'text', file, fileUrl });
const [zoomOpen, setZoomOpen] = useState(false);
useEffect(() => {
if (zoomOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [zoomOpen]);
if (disableMediaPreview && !show) {
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
}
if (db?.password === true && !show) {
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
}
if (db?.password === true && show) {
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(viewUrl!)}
/>
</Paper>
);
}
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
const mediaMax = fullscreen
? { maxWidth: 'min(96vw, calc(100vw - 3rem))', maxHeight: 'calc(100vh - 7.5rem)' }
: undefined;
if (type === 'video') {
if (!fileUrl) return <Loader />;
if (!show) {
if (thumbnailUrl) {
return (
<Box pos='relative'>
<MantineImage src={thumbnailUrl} alt={file.name || 'Video thumbnail'} />
<Center pos='absolute' inset={0}>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
);
}
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
}
return (
<video
width='100%'
autoPlay
muted
controls
src={fileUrl}
style={{
cursor: 'pointer',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '85vw', maxHeight: '85vh' }),
}}
/>
);
}
if (type === 'image') {
if (!fileUrl) return <Loader />;
if (!show) {
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
}
return (
<Center>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
objectFit: 'contain',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '70vw', maxHeight: '70vw' }),
}}
onClick={() => allowZoom && setZoomOpen(true)}
/>
{allowZoom && zoomOpen && (
<FileZoomModal setOpen={setZoomOpen}>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</Center>
);
}
if (type === 'audio') {
if (!fileUrl) return <Loader />;
return show ? (
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (type === 'text') {
if (!show) return <Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />;
if (fileContent.trim() === '') {
return (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
);
}
return (
<FullscreenFrame fullscreen={fullscreen} parent={scrollParent}>
<Render
mode={renderIn}
language={extension}
code={fileContent}
noClamp={fullscreen}
scrollParent={scrollParent}
/>
</FullscreenFrame>
);
}
if (isAsciicast) {
if (!fileUrl) return <Loader />;
return show ? (
<FullscreenFrame fullscreen={fullscreen}>
<Asciinema src={fileUrl} />
</FullscreenFrame>
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
}
if (file.type === 'application/pdf') {
if (!fileUrl) return <Loader />;
return show ? (
fullscreen ? (
<Box style={{ height: 'calc(100vh - 7.5rem)', width: 'min(96vw, calc(100vw - 3rem))' }}>
<Pdf src={fileUrl} />
</Box>
) : (
<Pdf src={fileUrl} />
)
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(fileUrl)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
}
@@ -0,0 +1,67 @@
import type { File as DbFile } from '@/lib/db/models/file';
import useSWR from 'swr';
import { isDbFile } from './useFileUrls';
const MAX_BYTES = 1 * 1024 * 1024;
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
async function readBlobText(file: File) {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => resolve((reader.result ?? '') as string);
reader.readAsText(file);
});
return raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) + FILE_BIG : raw;
}
async function readText(fileUrl: string) {
const res = await fetch(fileUrl, {
headers: {
Range: `bytes=0-${MAX_BYTES}`,
},
});
if (!res.ok) throw new Error('Failed to fetch file');
return await res.text();
}
export default function useFileContent({
enabled,
file,
fileUrl,
}: {
enabled: boolean;
file: DbFile | File;
fileUrl: string;
}) {
const { data, error } = useSWR<string>(
() => {
if (!enabled) return null;
if (isDbFile(file)) return ['dbfile', file.id] as const;
const f = file as File;
return ['blobfile', f.name] as const;
},
async () => {
if (!isDbFile(file)) return readBlobText(file as File);
if (file.size > MAX_BYTES) {
const text = await readText(fileUrl);
return text + FILE_BIG;
}
return readText(fileUrl);
},
{
revalidateOnFocus: false,
shouldRetryOnError: false,
},
);
if (error) return 'Error loading file.';
return data ?? '';
}
@@ -0,0 +1,36 @@
import { useUserStore } from '@/lib/client/store/user';
import type { File as DbFile } from '@/lib/db/models/file';
import { useMemo } from 'react';
function appendToken(url: string, token?: string | null) {
if (!token) return url;
return `${url}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
export function isDbFile(file: DbFile | File): file is DbFile {
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
}
export default function useFileUrls({ file, token }: { file: DbFile | File; token?: string | null }): {
fileUrl: string;
thumbnailUrl: string | null;
viewUrl: string | null;
} {
const user = useUserStore((state) => state.user);
const blobUrl = useMemo(() => (isDbFile(file) ? null : URL.createObjectURL(file as File)), [file]);
return useMemo(() => {
if (!isDbFile(file)) return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
const thumb = file.thumbnail?.path;
const thumbnailUrl = thumb ? (user ? `/api/user/files/${thumb}/raw` : `/raw/${thumb}`) : null;
return {
fileUrl: appendToken(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, token),
viewUrl: appendToken(`/view/${file.name}`, token),
thumbnailUrl,
};
}, [token, blobUrl, file, user]);
}
+5 -6
View File
@@ -3,7 +3,8 @@ import { Response } from '@/lib/api/response';
import type { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { conditionalWarning } from '@/lib/warningModal';
import { conditionalWarning } from '@/lib/client/warningModal';
import { getDomain } from '@/lib/client/webDomain';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -29,13 +30,11 @@ export function downloadFile(file: File) {
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = raw
? `${domain}/raw/${file.name}`
? getDomain(`/raw/${file.name}`)
: file.url
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
? getDomain(`${file.url}`)
: getDomain(`/view/${file.name}`);
clipboard.copy(url);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useConfig } from '@/components/ConfigProvider';
import Stat from '@/components/Stat';
import type { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/hooks/useLogin';
import useLogin from '@/lib/client/hooks/useLogin';
import { isAdministrator } from '@/lib/role';
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import {
@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
import { IncompleteFileStatus } from '@/prisma/client';
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
@@ -1,4 +1,4 @@
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
import {
closestCenter,
DndContext,
+41 -8
View File
@@ -1,6 +1,6 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import useObjectState from '@/lib/hooks/useObjectState';
import { useViewStore } from '@/lib/store/view';
import useObjectState, { type UpdateFn } from '@/lib/client/hooks/useObjectState';
import { useViewStore } from '@/lib/client/store/view';
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
import {
IconDots,
@@ -10,7 +10,7 @@ import {
IconTableOptions,
IconTags,
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from './tags/TagsModal';
import FavoriteFiles from './views/FavoriteFiles';
@@ -26,14 +26,47 @@ export type DashboardFilesModals = {
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [searchParams, setSearchParams] = useSearchParams();
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
const [modals, setModals] = useObjectState<DashboardFilesModals>({
table: false,
idSearch: false,
tags: false,
pending: false,
const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true';
const [modals, setModalState] = useObjectState<DashboardFilesModals>({
table: modalQS('table'),
idSearch: modalQS('idSearch'),
tags: modalQS('tags'),
pending: modalQS('pending'),
});
const updateModalQuery = (updates: Partial<DashboardFilesModals>) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const key of modalKeys) {
if (!(key in updates)) continue;
if (updates[key]) next.set(key, 'true');
else next.delete(key);
}
return next;
},
{ replace: true },
);
};
const setModals: UpdateFn<DashboardFilesModals> = (keyOrObj: any, value?: any) => {
if (typeof keyOrObj === 'object' && value === undefined) {
setModalState(keyOrObj);
updateModalQuery(keyOrObj);
return;
}
setModalState(keyOrObj, value);
updateModalQuery({ [keyOrObj]: value });
};
return (
<>
<TagsModal modals={modals} setModals={setModals} />
+7 -2
View File
@@ -11,8 +11,13 @@ export default function TagPill({
if (!tag) return null;
return (
<Pill bg={tag.color || undefined} c={isLightColor(tag.color) ? 'black' : 'white'} {...other}>
{tag.name}
<Pill
bg={tag.color || undefined}
c={isLightColor(tag.color) ? 'black' : 'white'}
title={tag.name}
{...other}
>
{tag.name.length <= 24 ? tag.name : tag.name.slice(0, 21) + '...'}
</Pill>
);
}
@@ -2,7 +2,7 @@ import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
@@ -1,7 +1,8 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import useSWR from 'swr';
type ApiPaginationOptions = {
route?: string;
page?: number;
filter?: string;
perpage?: number;
@@ -26,14 +27,15 @@ type ApiPaginationOptions = {
};
};
const fetcher = async (
const fetcher = async <T,>(
{ options }: { options: ApiPaginationOptions; key: string } = {
options: {
page: 1,
},
key: '/api/user/files',
},
): Promise<Response['/api/user/files']> => {
): Promise<T> => {
const route = options.route ?? '/api/user/files';
const searchParams = new URLSearchParams();
if (options.page) searchParams.append('page', options.page.toString());
if (options.filter) searchParams.append('filter', options.filter);
@@ -48,7 +50,7 @@ const fetcher = async (
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
const res = await fetch(`${route}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
if (!res.ok) {
const json = await res.json();
@@ -59,14 +61,18 @@ const fetcher = async (
return res.json();
};
export function useApiPagination(
export function useApiPagination<T = Response['/api/user/files']>(
options: ApiPaginationOptions = {
page: 1,
},
swrConfig?: Parameters<typeof useSWR<T>>[2],
) {
const { data, error, isLoading, mutate } = useSWR<Response['/api/user/files']>(
{ key: '/api/user/files', options },
{ fetcher },
const { data, error, isLoading, mutate } = useSWR<T>(
{ key: options.route ?? '/api/user/files', options },
{
fetcher: (k) => fetcher<T>(k),
...swrConfig,
},
);
return {
@@ -1,4 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import {
Accordion,
Button,
@@ -1,4 +1,5 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import {
Button,
Center,
@@ -13,11 +14,14 @@ import {
Title,
} from '@mantine/core';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import DashboardFile from '@/components/file/DashboardFile';
import { useApiPagination } from '../useApiPagination';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
@@ -37,8 +41,28 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 1;
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => {
if (!open) setCurrent(null);
}}
file={currentFile}
user={id}
sequenced
/>
<SimpleGrid
my='sm'
cols={{
@@ -54,7 +78,7 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} id={id} />
<DashboardFile file={file} id={id} onOpen={(fileId) => setCurrent(fileId)} />
</Suspense>
))
) : (
@@ -3,13 +3,14 @@ import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/compo
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
import { useSettingsStore } from '@/lib/client/store/settings';
import { type File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
Box,
@@ -30,7 +31,7 @@ import {
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useClipboard, useDebouncedValue } from '@mantine/hooks';
import {
IconCopy,
IconDownload,
@@ -43,15 +44,16 @@ import { DataTable } from 'mantine-datatable';
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
import { DashboardFilesModals } from '..';
import TableEditModal from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -231,7 +233,7 @@ export default function FileTable({
}),
{ name: '', originalName: '', type: '', tags: '', id: '' },
);
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -266,10 +268,15 @@ export default function FileTable({
}),
});
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
const selectedFile = selectedFileId
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
: null;
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const selectedFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
const FIELDS = [
{
@@ -374,21 +381,16 @@ export default function FileTable({
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
return (
<>
<FileModal
<DashboardFileModal
open={!!selectedFile}
setOpen={(open) => {
if (!open) setSelectedFile(null);
if (!open) setCurrent(null);
}}
file={selectedFile}
user={id}
sequenced
/>
{modals && setModals && (
@@ -396,7 +398,7 @@ export default function FileTable({
)}
<Box>
<Collapse in={selectedFiles.length > 0}>
<Collapse expanded={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
<Text size='sm' c='dimmed' mb='xs'>
Selections are saved across page changes
@@ -487,7 +489,7 @@ export default function FileTable({
</Collapse>
{modals && setModals && modals.idSearch && (
<Collapse in={modals.idSearch}>
<Collapse expanded={modals.idSearch}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
@@ -587,7 +589,7 @@ export default function FileTable({
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record.id)}
onCellClick={({ record }) => setCurrent(record.id)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
@@ -1,4 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import {
Accordion,
Button,
+6 -4
View File
@@ -1,6 +1,7 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { getDomain } from '@/lib/client/webDomain';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -9,13 +10,14 @@ import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
const url = getDomain(`/folder/${folder.id}`);
clipboard.copy(url);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} to={`/folder/${folder.id}`}>
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
{url}
</Anchor>
),
color: 'green',
@@ -48,7 +50,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
});
}
mutateFolder(folder.id);
mutateFolder();
}
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
@@ -76,7 +78,7 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
});
}
mutateFolder(folder.id);
mutateFolder();
}
export async function mutateFolder(folderId?: string) {
+3 -3
View File
@@ -3,8 +3,8 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
import { useViewStore } from '@/lib/store/view';
import { SEPARATOR, useTitle } from '@/lib/client/hooks/useTitle';
import { useViewStore } from '@/lib/client/store/view';
import {
Alert,
Anchor,
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse in={filesOpen}>
<Collapse expanded={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconFolderSymlink } from '@tabler/icons-react';
+13 -4
View File
@@ -1,12 +1,18 @@
import RelativeDate from '@/components/RelativeDate';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/client/store/settings';
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react';
import { copyInviteUrl, deleteInvite } from './actions';
import { useClipboard } from '@mantine/hooks';
import { useSettingsStore } from '@/lib/store/settings';
import { IconCopy, IconDots, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
import { copyInviteUrl, deleteInvite } from './actions';
export default function InviteCard({ invite }: { invite: Invite }) {
export default function InviteCard({
invite,
setQrOpen,
}: {
invite: Invite;
setQrOpen: (invite: Invite) => void;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -36,6 +42,9 @@ export default function InviteCard({ invite }: { invite: Invite }) {
>
Copy URL
</Menu.Item>
<Menu.Item leftSection={<IconQrcode size='1rem' />} onClick={() => setQrOpen(invite)}>
Show QR Code
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
+5 -3
View File
@@ -1,7 +1,8 @@
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { conditionalWarning } from '@/lib/warningModal';
import { conditionalWarning } from '@/lib/client/warningModal';
import { getDomain } from '@/lib/client/webDomain';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -18,13 +19,14 @@ export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
}
export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useClipboard>) {
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
const url = getDomain(`/invite/${invite.code}`);
clipboard.copy(url);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} to={`/invite/${invite.code}`}>
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
{url}
</Anchor>
),
color: 'green',
+1 -1
View File
@@ -2,7 +2,7 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import { useViewStore } from '@/lib/client/store/view';
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
@@ -4,13 +4,23 @@ import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '
import { IconLink } from '@tabler/icons-react';
import useSWR from 'swr';
import InviteCard from '../InviteCard';
import { useState } from 'react';
import QRCodeModal from '@/components/QRCodeModal';
export default function InviteGridView() {
const { data: folders, isLoading } =
useSWR<Extract<Response['/api/auth/invites'], Invite[]>>('/api/auth/invites');
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
return (
<>
<QRCodeModal
opened={!!qrOpen}
onClose={() => setQrOpen(null)}
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
/>
{isLoading ? (
<SimpleGrid
my='sm'
@@ -38,7 +48,7 @@ export default function InviteGridView() {
pos='relative'
>
{folders?.map((invite) => (
<InviteCard key={invite.id} invite={invite} />
<InviteCard setQrOpen={setQrOpen} key={invite.id} invite={invite} />
))}
</SimpleGrid>
) : (
@@ -1,14 +1,15 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/store/settings';
import { useSettingsStore } from '@/lib/client/store/settings';
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
import { IconCopy, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyInviteUrl, deleteInvite } from '../actions';
import QRCodeModal from '@/components/QRCodeModal';
export default function InviteTableView() {
const clipboard = useClipboard();
@@ -36,8 +37,16 @@ export default function InviteTableView() {
});
}, [data, sortStatus]);
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
return (
<>
<QRCodeModal
opened={!!qrOpen}
onClose={() => setQrOpen(null)}
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
/>
<Box my='sm'>
<DataTable
borderRadius='sm'
@@ -101,6 +110,16 @@ export default function InviteTableView() {
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Show QR code'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setQrOpen(invite);
}}
>
<IconQrcode size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete invite'>
<ActionIcon
color='red'
+32 -25
View File
@@ -14,32 +14,39 @@ export default function TotpModal({
}) {
return (
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<form onSubmit={onVerify}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
Verify
</Button>
</Group>
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button
leftSection={<IconShieldQuestion size='1rem' />}
loading={state.disabled}
onClick={onVerify}
type='submit'
>
Verify
</Button>
</Group>
</form>
</Modal>
);
}
@@ -139,7 +139,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse in={osOpened}>
<Collapse expanded={osOpened}>
<HighlightCode language='json' code={JSON.stringify(export3.request.os, null, 2)} />
</Collapse>
@@ -147,7 +147,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse in={envOpened}>
<Collapse expanded={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -195,7 +195,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse in={osOpened}>
<Collapse expanded={osOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -217,7 +217,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse in={envOpened}>
<Collapse expanded={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -32,7 +32,7 @@ export default function Export4ImportSettings({
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
<Collapse in={showSettings}>
<Collapse expanded={showSettings}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -1,5 +1,5 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { useUserStore } from '@/lib/store/user';
import { useUserStore } from '@/lib/client/store/user';
import { Box, Checkbox, Group, Text } from '@mantine/core';
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
@@ -1,17 +1,17 @@
import { Response } from '@/lib/api/response';
import { useUserStore } from '@/lib/client/store/user';
import { fetchApi } from '@/lib/fetchApi';
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';
import Export4Details from './Export4Details';
import Export4ImportSettings from './Export4ImportSettings';
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
import Export4UserChoose from './Export4UserChoose';
import { useUserStore } from '@/lib/store/user';
import { modals } from '@mantine/modals';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
import { mutate } from 'swr';
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
export default function ImportV4Button() {
const [open, setOpen] = useState(false);
+320 -88
View File
@@ -1,8 +1,42 @@
import { Response } from '@/lib/api/response';
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
ActionIcon,
Alert,
Anchor,
Box,
Button,
Collapse,
Group,
LoadingOverlay,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAdjustmentsHorizontalFilled,
IconAppWindowFilled,
IconArrowBack,
IconAuth2fa,
IconBrandDiscordFilled,
IconClickFilled,
IconClockPause,
IconDatabase,
IconExclamationMark,
IconFiles,
IconHttpPost,
IconKeyFilled,
IconLayoutGrid,
IconLink,
IconSubtask,
IconTagsFilled,
IconWorldPlus,
} from '@tabler/icons-react';
import { lazy, Suspense, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import { lazy, Suspense, useMemo } from 'react';
const Core = lazy(() => import('./parts/Core'));
const Chunks = lazy(() => import('./parts/Chunks'));
@@ -20,112 +54,310 @@ const Tasks = lazy(() => import('./parts/Tasks'));
const Urls = lazy(() => import('./parts/Urls'));
const Website = lazy(() => import('./parts/Website'));
function SettingsSkeleton() {
return Array(17)
.fill(null)
.map((_, index) => <Skeleton key={index} height={280} animate />);
}
const InvalidSettingsSection = () => <Text>Invalid settings section</Text>;
const SETTINGS_COMPONENTS = {
core: {
component: Core,
name: 'Core',
key: 'core',
desc: 'General server settings',
Icon: IconDatabase,
},
chunks: {
component: Chunks,
name: 'Chunks',
key: 'chunks',
desc: 'Partial uploading',
Icon: IconLayoutGrid,
},
discord: {
component: Discord,
name: 'Discord',
key: 'discord',
desc: 'Discord webhook integration',
Icon: IconBrandDiscordFilled,
},
domains: {
component: Domains,
name: 'Domains',
key: 'domains',
desc: 'Add custom domains',
Icon: IconWorldPlus,
},
features: {
component: Features,
name: 'Features',
key: 'features',
desc: 'Configure various features',
Icon: IconAdjustmentsHorizontalFilled,
},
files: {
component: Files,
name: 'Files',
key: 'files',
desc: 'File uploading settings',
Icon: IconFiles,
},
httpWebhook: {
component: HttpWebhook,
name: 'HTTP Webhook',
key: 'httpWebhook',
desc: 'Send POST requests to a URL on certain events',
Icon: IconHttpPost,
},
invites: {
component: Invites,
name: 'Invites',
key: 'invites',
desc: 'Invite settings',
Icon: IconTagsFilled,
},
mfa: {
component: Mfa,
name: 'Multi-Factor Authentication',
key: 'mfa',
desc: 'Enable or disable passkeys and TOTP authentication',
Icon: IconAuth2fa,
},
oauth: {
component: Oauth,
name: 'OAuth',
key: 'oauth',
desc: 'Configure OAuth providers for authentication',
Icon: IconKeyFilled,
},
pwa: {
component: PWA,
name: 'PWA',
key: 'pwa',
desc: 'Progressive Web App settings',
Icon: IconAppWindowFilled,
},
ratelimit: {
component: Ratelimit,
name: 'Rate Limit',
key: 'ratelimit',
desc: 'Configure API rate limits',
Icon: IconClockPause,
},
tasks: {
component: Tasks,
name: 'Tasks',
key: 'tasks',
desc: 'Background task intervals',
Icon: IconSubtask,
},
urls: {
component: Urls,
name: 'URL Shortening',
key: 'urls',
desc: 'Configure URL shortening settings',
Icon: IconLink,
},
website: {
component: Website,
name: 'Website',
key: 'website',
desc: 'Website related settings like title and description',
Icon: IconClickFilled,
},
// placeholder
settings: {
component: null,
name: 'Server Settings',
key: '',
desc: '',
Icon: null,
},
};
export const SETTINGS_EXTERNAL_LINKS = Object.values(SETTINGS_COMPONENTS)
.filter((setting) => setting.component !== null)
.map((setting) => ({
name: setting.name,
url: `/dashboard/admin/settings/${setting.key}`,
icon: setting.Icon ? <setting.Icon size='1rem' /> : <IconAdjustmentsHorizontalFilled size='1rem' />,
}));
const SETTINGS_PART_KEYS = Object.keys(SETTINGS_COMPONENTS)
.filter((key) => key !== 'settings')
.sort((a, b) => b.length - a.length);
export default function DashboardServerSettings() {
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const location = useLocation();
const navigate = useNavigate();
const { data } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const [opened, { toggle }] = useDisclosure(false);
const scrollToSetting = useMemo(() => {
return (setting: string) => {
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
if (input) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
const parent = input.parentElement?.parentElement;
if (parent) {
parent.style.transition = 'transform 0.35s';
parent.style.transform = 'scale(1.2)';
setTimeout(() => {
parent.style.transform = 'scale(1)';
}, 350);
}
}
},
{ threshold: 1.0 },
);
observer.observe(input);
const toSettingSection = useCallback((settingKey: string) => {
const normalizedSetting = settingKey.toLowerCase();
const matched = SETTINGS_PART_KEYS.find((key) => normalizedSetting.startsWith(key.toLowerCase()));
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
input.focus();
}
};
return matched ?? 'settings';
}, []);
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
e.preventDefault();
const scrollToSetting = useCallback((setting: string) => {
const input = document.querySelector<HTMLElement>(`[data-path="${setting}"]`);
const parent = input?.parentElement?.parentElement;
if (!input || !parent) return false;
scrollToSetting(setting);
};
parent.style.transition = 'all 0.4s ease';
parent.style.borderRadius = 'var(--mantine-radius-xs)';
parent.style.outline = '2px solid var(--mantine-primary-color-filled)';
parent.style.outlineOffset = 'var(--mantine-spacing-xs)';
const observer = new IntersectionObserver(
(entries) => {
if (entries.length === 0) return;
if (!entries[0].isIntersecting) return;
observer.disconnect();
setTimeout(() => {
parent.style.outline = '0 solid transparent';
parent.style.outlineOffset = '0';
parent.style.borderRadius = '0';
}, 2000);
},
{ threshold: 1.0 },
);
observer.observe(input);
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
input.focus();
return true;
}, []);
const scrollToSettingWithRetry = useCallback(
(setting: string, attemptsLeft = 18) => {
const tryScroll = (remainingAttempts: number) => {
if (scrollToSetting(setting)) return;
if (remainingAttempts <= 0) return;
window.setTimeout(() => tryScroll(remainingAttempts - 1), 80);
};
tryScroll(attemptsLeft);
},
[scrollToSetting],
);
const onTamperedClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
e.preventDefault();
const section = toSettingSection(setting);
const url = `/dashboard/admin/settings/${section}`;
if (location.pathname === url) return scrollToSettingWithRetry(setting);
navigate(url);
setTimeout(() => {
scrollToSettingWithRetry(setting);
}, 0);
},
[location.pathname, navigate, scrollToSettingWithRetry, toSettingSection],
);
const pathPart = location.pathname.split('/')[4];
let part = 'settings';
if (pathPart && SETTINGS_COMPONENTS[pathPart as keyof typeof SETTINGS_COMPONENTS]) {
part = pathPart;
}
const setting = SETTINGS_COMPONENTS[part as keyof typeof SETTINGS_COMPONENTS];
const SettingsComponent = setting.component ?? InvalidSettingsSection;
useTitle(setting.name);
return (
<>
<Group gap='sm'>
<Title order={1}>Server Settings</Title>
<Group gap='sm' align='center' wrap='wrap'>
{part !== 'settings' && (
<ActionIcon component={Link} to='/dashboard/admin/settings' variant='outline'>
<IconArrowBack size='1rem' />
</ActionIcon>
)}
<Title order={1}>{setting.name}</Title>
{(data?.tampered?.length ?? 0) > 0 && (
<Button
variant='outline'
color={opened ? 'red' : 'blue'}
size='xs'
onClick={toggle}
leftSection={<IconExclamationMark size='1rem' />}
>
{opened ? 'Hide' : 'Show'} Tampered ({data!.tampered.length})
</Button>
)}
</Group>
{(data?.tampered?.length ?? 0) > 0 && (
<Alert color='red' title='Environment Variable Settings' mt='md'>
<strong>{data!.tampered.length}</strong> setting{data!.tampered.length > 1 ? 's' : ''} have been set
via environment variables, therefore any changes made to them on this page will not take effect
unless the environment variable corresponding to the setting is removed. If you prefer using
environment variables, you can ignore this message. Click{' '}
<Anchor onClick={toggle} size='sm'>
here
</Anchor>{' '}
to {opened ? 'close' : 'view'} the list of overridden settings.
<Collapse in={opened} transitionDuration={200}>
<ul>
<Collapse expanded={opened} transitionDuration={180}>
<Alert
my='md'
color='red'
title='Environment Variable Settings'
icon={<IconExclamationMark size='1rem' />}
variant='outline'
>
<Text size='sm' mb='xs'>
These settings are controlled by environment variables:
</Text>
<Group gap='xs'>
{data!.tampered.map((setting) => (
<li key={setting}>
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
</li>
<Anchor key={setting} onClick={(e) => onTamperedClick(e, setting)} size='sm'>
{setting}
</Anchor>
))}
</ul>
</Collapse>
</Alert>
</Group>
</Alert>
</Collapse>
)}
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
{error ? (
<div>Error loading server settings</div>
) : (
<Suspense fallback={<SettingsSkeleton />}>
<Core swr={{ data, isLoading }} />
<Chunks swr={{ data, isLoading }} />
<Tasks swr={{ data, isLoading }} />
<Mfa swr={{ data, isLoading }} />
<Features swr={{ data, isLoading }} />
<Files swr={{ data, isLoading }} />
<Stack>
<Urls swr={{ data, isLoading }} />
<Invites swr={{ data, isLoading }} />
</Stack>
<Ratelimit swr={{ data, isLoading }} />
<Stack>
<Website swr={{ data, isLoading }} />
<PWA swr={{ data, isLoading }} />
</Stack>
<Oauth swr={{ data, isLoading }} />
<HttpWebhook swr={{ data, isLoading }} />
<Domains swr={{ data, isLoading }} />
{part !== 'settings' ? (
<Box my='sm' p='xs' pos='relative' bdrs='lg'>
<Suspense
fallback={
<Box h={400} pos='relative'>
<LoadingOverlay visible bdrs='md' />
</Box>
}
>
<SettingsComponent />
</Suspense>
)}
</SimpleGrid>
</Box>
) : (
<Stack mt='md' gap='md'>
{Object.entries(SETTINGS_COMPONENTS)
.filter(([key]) => key !== 'settings')
.map(([k, { key, Icon, name, desc }]) => (
<Anchor
key={k}
component={Link}
to={`/dashboard/admin/settings/${key}`}
style={{ textDecoration: 'none' }}
>
<Paper withBorder p='sm'>
<Group gap='md'>
<ActionIcon variant='filled' radius='md' size='xl'>
{Icon ? <Icon size='1.75rem' /> : <IconAdjustmentsHorizontalFilled size='1.75rem' />}
</ActionIcon>
<Stack mt='md' gap='md'>
{error ? null : <Discord swr={{ data, isLoading }} />}
</Stack>
<div>
<Title order={4}>{name}</Title>
<Text c='dimmed'>{desc}</Text>
</div>
</Group>
</Paper>
</Anchor>
))}
</Stack>
)}
</>
);
}
@@ -1,27 +1,34 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Chunks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Chunks() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} bdrs='md' />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
chunksEnabled: true,
chunksMax: '95mb',
chunksSize: '25mb',
chunksEnabled: data.settings.chunksEnabled,
chunksMax: data.settings.chunksMax,
chunksSize: data.settings.chunksSize,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'chunksEnabled' && !form.values.chunksEnabled) ||
false,
}),
@@ -29,52 +36,35 @@ export default function Chunks({
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
chunksEnabled: data.settings.chunksEnabled ?? true,
chunksMax: data.settings.chunksMax ?? '',
chunksSize: data.settings.chunksSize ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>Chunks</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
mt='md'
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Max Chunk Size'
description='Maximum size of an upload before it is split into chunks.'
placeholder='95mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksMax')}
/>
<TextInput
label='Max Chunk Size'
description='Maximum size of an upload before it is split into chunks.'
placeholder='95mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksMax')}
/>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
</SimpleGrid>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,32 +1,34 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Core({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Core() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
coreTempDirectory: string;
coreTrustProxy: boolean;
}>({
const form = useForm({
initialValues: {
coreReturnHttpsUrls: false,
coreDefaultDomain: '',
coreTempDirectory: '/tmp/zipline',
coreTrustProxy: false,
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls,
coreDefaultDomain: data.settings.coreDefaultDomain,
coreTempDirectory: data.settings.coreTempDirectory,
coreTrustProxy: data.settings.coreTrustProxy,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -40,57 +42,40 @@ export default function Core({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
coreTrustProxy: data.settings.coreTrustProxy ?? false,
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<Title order={2}>Core</Title>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<TextInput
label='Temporary Directory'
description='The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.'
placeholder='/tmp/zipline'
{...form.getInputProps('coreTempDirectory')}
/>
</Stack>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
<TextInput
label='Temporary Directory'
description='The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.'
placeholder='/tmp/zipline'
{...form.getInputProps('coreTempDirectory')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,4 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Button,
Collapse,
@@ -13,24 +13,31 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
type DiscordEmbed = Record<string, any>;
export default function Discord({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Discord() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const formMain = useForm({
initialValues: {
discordWebhookUrl: '',
discordUsername: '',
discordAvatarUrl: '',
discordWebhookUrl: data.settings.discordWebhookUrl,
discordUsername: data.settings.discordUsername,
discordAvatarUrl: data.settings.discordAvatarUrl,
},
});
@@ -49,42 +56,46 @@ export default function Discord({
const formOnUpload = useForm({
initialValues: {
discordOnUploadWebhookUrl: '',
discordOnUploadUsername: '',
discordOnUploadAvatarUrl: '',
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl,
discordOnUploadUsername: data.settings.discordOnUploadUsername,
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl,
discordOnUploadContent: '',
discordOnUploadContent: data.settings.discordOnUploadContent,
discordOnUploadEmbed: false,
discordOnUploadEmbedTitle: '',
discordOnUploadEmbedDescription: '',
discordOnUploadEmbedFooter: '',
discordOnUploadEmbedColor: '',
discordOnUploadEmbedThumbnail: false,
discordOnUploadEmbedImageOrVideo: false,
discordOnUploadEmbedTimestamp: false,
discordOnUploadEmbedUrl: false,
discordOnUploadEmbed: Boolean(data.settings.discordOnUploadEmbed),
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.title || '',
discordOnUploadEmbedDescription:
(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.description || '',
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.footer || '',
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.color || '',
discordOnUploadEmbedThumbnail: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.thumbnail,
discordOnUploadEmbedImageOrVideo: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)
?.imageOrVideo,
discordOnUploadEmbedTimestamp: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.timestamp,
discordOnUploadEmbedUrl: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.url,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const formOnShorten = useForm({
initialValues: {
discordOnShortenWebhookUrl: '',
discordOnShortenUsername: '',
discordOnShortenAvatarUrl: '',
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl,
discordOnShortenUsername: data.settings.discordOnShortenUsername,
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl,
discordOnShortenContent: '',
discordOnShortenContent: data.settings.discordOnShortenContent,
discordOnShortenEmbed: false,
discordOnShortenEmbedTitle: '',
discordOnShortenEmbedDescription: '',
discordOnShortenEmbedFooter: '',
discordOnShortenEmbedColor: '',
discordOnShortenEmbedTimestamp: false,
discordOnShortenEmbedUrl: false,
discordOnShortenEmbed: Boolean(data.settings.discordOnShortenEmbed),
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.title || '',
discordOnShortenEmbedDescription:
(data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.description || '',
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.footer || '',
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.color || '',
discordOnShortenEmbedTimestamp: !!(data.settings.discordOnShortenEmbed as DiscordEmbed | null)
?.timestamp,
discordOnShortenEmbedUrl: !!(data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.url,
},
});
@@ -123,61 +134,10 @@ export default function Discord({
return settingsOnSubmit(navigate, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
};
useEffect(() => {
if (!data) return;
formMain.setValues({
discordWebhookUrl: data.settings.discordWebhookUrl ?? '',
discordUsername: data.settings.discordUsername ?? '',
discordAvatarUrl: data.settings.discordAvatarUrl ?? '',
});
formOnUpload.setValues({
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl ?? '',
discordOnUploadUsername: data.settings.discordOnUploadUsername ?? '',
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl ?? '',
discordOnUploadContent: data.settings.discordOnUploadContent ?? '',
discordOnUploadEmbed: data.settings.discordOnUploadEmbed ? true : false,
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
discordOnUploadEmbedDescription:
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
discordOnUploadEmbedThumbnail: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
discordOnUploadEmbedImageOrVideo:
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
discordOnUploadEmbedTimestamp: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
discordOnUploadEmbedUrl: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
});
formOnShorten.setValues({
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl ?? '',
discordOnShortenUsername: data.settings.discordOnShortenUsername ?? '',
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl ?? '',
discordOnShortenContent: data.settings.discordOnShortenContent ?? '',
discordOnShortenEmbed: data.settings.discordOnShortenEmbed ? true : false,
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
discordOnShortenEmbedDescription:
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
discordOnShortenEmbedTimestamp:
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
discordOnShortenEmbedUrl: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>Discord Webhook</Title>
<>
<form onSubmit={formMain.onSubmit(onSubmitMain)}>
<TextInput
mt='md'
label='Webhook URL'
description='The Discord webhook URL to send notifications to'
placeholder='https://discord.com/api/webhooks/...'
@@ -248,7 +208,7 @@ export default function Discord({
{...formOnUpload.getInputProps('discordOnUploadEmbed', { type: 'checkbox' })}
/>
<Collapse in={formOnUpload.values.discordOnUploadEmbed}>
<Collapse expanded={formOnUpload.values.discordOnUploadEmbed}>
<Paper withBorder p='sm' mt='md'>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
@@ -351,7 +311,7 @@ export default function Discord({
{...formOnShorten.getInputProps('discordOnShortenEmbed', { type: 'checkbox' })}
/>
<Collapse in={formOnShorten.values.discordOnShortenEmbed}>
<Collapse expanded={formOnShorten.values.discordOnShortenEmbed}>
<Paper withBorder p='sm' mt='md'>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
@@ -399,6 +359,6 @@ export default function Discord({
</form>
</Paper>
</SimpleGrid>
</Paper>
</>
);
}
@@ -1,19 +1,24 @@
import { Response } from '@/lib/api/response';
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { ActionIcon, LoadingOverlay, Paper, Table, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Domains({
swr: { data, isLoading },
}: {
swr: {
data: Response['/api/server/settings'] | undefined;
isLoading: boolean;
};
}) {
export default function Domains() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} /> : null}
</>
);
}
function Form({ data }: { data: Response['/api/server/settings'] }) {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
@@ -24,7 +29,7 @@ export default function Domains({
const submitSettings = settingsOnSubmit(navigate, form);
const domains = Array.isArray(data?.settings.domains) ? data!.settings.domains.map(String) : [];
const domains = data.settings.domains.map(String);
async function updateDomains(nextDomains: string[]) {
setSubmitting(true);
@@ -39,7 +44,7 @@ export default function Domains({
}
}
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
const addDomain = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const domain = form.values.domains.trim();
@@ -55,23 +60,20 @@ export default function Domains({
};
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading || submitting} />
<Title order={2}>Domains</Title>
<>
<LoadingOverlay visible={submitting} />
<form onSubmit={addDomain}>
<Group mt='md' align='flex-end'>
<TextInput
description='Enter a domain name'
placeholder='example.com'
flex={1}
{...form.getInputProps('domains')}
/>
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
<IconPlus size='1.25rem' />
</ActionIcon>
</Group>
<TextInput
description='Enter a domain name'
placeholder='example.com'
rightSection={
<ActionIcon type='submit' variant='transparent' disabled={submitting}>
<IconPlus size='1.25rem' />
</ActionIcon>
}
{...form.getInputProps('domains')}
/>
</form>
{domains.length > 0 ? (
@@ -106,6 +108,6 @@ export default function Domains({
No domains added yet.
</Text>
)}
</Paper>
</>
);
}
@@ -1,193 +1,187 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Anchor,
Button,
Divider,
LoadingOverlay,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Switch,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Features({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Features() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
featuresImageCompression: true,
featuresRobotsTxt: true,
featuresHealthcheck: true,
featuresUserRegistration: false,
featuresOauthRegistration: true,
featuresDeleteOnMaxViews: true,
featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg',
featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true,
featuresVersionChecking: true,
featuresVersionAPI: 'https://zipline-version.diced.sh/',
featuresImageCompression: data.settings.featuresImageCompression,
featuresRobotsTxt: data.settings.featuresRobotsTxt,
featuresHealthcheck: data.settings.featuresHealthcheck,
featuresUserRegistration: data.settings.featuresUserRegistration,
featuresOauthRegistration: data.settings.featuresOauthRegistration,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat,
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous,
featuresMetricsEnabled: data.settings.featuresMetricsEnabled,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific,
featuresVersionChecking: data.settings.featuresVersionChecking,
featuresVersionAPI: data.settings.featuresVersionAPI,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
featuresImageCompression: data.settings.featuresImageCompression ?? true,
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
/>
<Title order={2}>Features</Title>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
/>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Divider label='Thumbnails' />
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<div />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<NumberInput
label='Thumbnails Number Threads'
description='Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.'
placeholder='Enter a number...'
min={1}
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<div />
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
label='Instantaneous Thumbnails'
description='Generates thumbnails immediately after a file is uploaded, instead of waiting for the task to run.'
{...form.getInputProps('featuresThumbnailsInstantaneous', { type: 'checkbox' })}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<NumberInput
label='Thumbnails Number Threads'
description='Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.'
placeholder='Enter a number...'
min={1}
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<Divider label='Version Checking' />
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='https://zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,62 +1,44 @@
import { Response } from '@/lib/api/response';
import {
Button,
LoadingOverlay,
NumberInput,
Paper,
Select,
SimpleGrid,
Switch,
TextInput,
Title,
} from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Files({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Files() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
filesRoute: string;
filesLength: number;
filesDefaultFormat: string;
filesDisabledExtensions: string;
filesMaxFileSize: string;
filesDefaultExpiration: string | null;
filesMaxExpiration: string | null;
filesAssumeMimetypes: boolean;
filesDefaultDateFormat: string;
filesRemoveGpsMetadata: boolean;
filesRandomWordsNumAdjectives: number;
filesRandomWordsSeparator: string;
filesDefaultCompressionFormat: string;
filesMaxFilesPerUpload: number;
}>({
const form = useForm({
initialValues: {
filesRoute: '/u',
filesLength: 6,
filesDefaultFormat: 'random',
filesDisabledExtensions: '',
filesMaxFileSize: '100mb',
filesDefaultExpiration: '',
filesMaxExpiration: '',
filesAssumeMimetypes: false,
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: false,
filesRandomWordsNumAdjectives: 3,
filesRandomWordsSeparator: '-',
filesDefaultCompressionFormat: 'jpg',
filesMaxFilesPerUpload: 1000,
filesRoute: data.settings.filesRoute,
filesLength: data.settings.filesLength,
filesDefaultFormat: data.settings.filesDefaultFormat,
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '),
filesMaxFileSize: data.settings.filesMaxFileSize,
filesDefaultExpiration: data.settings.filesDefaultExpiration,
filesMaxExpiration: data.settings.filesMaxExpiration,
filesAssumeMimetypes: data.settings.filesAssumeMimetypes,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat,
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator,
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat,
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -95,145 +77,118 @@ export default function Files({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
filesRoute: data.settings.filesRoute ?? '/u',
filesLength: data.settings.filesLength ?? 6,
filesDefaultFormat: data.settings.filesDefaultFormat ?? 'random',
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
filesMaxExpiration: data.settings.filesMaxExpiration ?? '',
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat ?? 'jpg',
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload ?? 1000,
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
<Title order={2}>Files</Title>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<NumberInput
label='Max Files Per Upload'
description='The maximum number of files allowed per upload. Requires a server restart.'
min={1}
{...form.getInputProps('filesMaxFilesPerUpload')}
/>
</Stack>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<NumberInput
label='Max Files Per Upload'
description='The maximum number of files allowed per upload. Requires a server restart.'
min={1}
{...form.getInputProps('filesMaxFilesPerUpload')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,25 +1,32 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function HttpWebhook({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function HttpWebhook() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
httpWebhookOnUpload: '',
httpWebhookOnShorten: '',
httpWebhookOnUpload: data.settings.httpWebhookOnUpload,
httpWebhookOnShorten: data.settings.httpWebhookOnShorten,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -37,42 +44,27 @@ export default function HttpWebhook({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='On Upload'
description='The URL to send a POST request to when a file is uploaded.'
placeholder='https://example.com/upload'
{...form.getInputProps('httpWebhookOnUpload')}
/>
<Title order={2}>HTTP Webhooks</Title>
<TextInput
label='On Shorten'
description='The URL to send a POST request to when a URL is shortened.'
placeholder='https://example.com/shorten'
{...form.getInputProps('httpWebhookOnShorten')}
/>
</Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='On Upload'
description='The URL to send a POST request to when a file is uploaded.'
placeholder='https://example.com/upload'
{...form.getInputProps('httpWebhookOnUpload')}
/>
<TextInput
label='On Shorten'
description='The URL to send a POST request to when a URL is shortened.'
placeholder='https://example.com/shorten'
{...form.getInputProps('httpWebhookOnShorten')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,26 +1,33 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, Switch } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Invites({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Invites() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
invitesEnabled: true,
invitesLength: 6,
invitesEnabled: data.settings.invitesEnabled,
invitesLength: data.settings.invitesLength,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'invitesEnabled' && !form.values.invitesEnabled) ||
false,
}),
@@ -28,43 +35,28 @@ export default function Invites({
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
invitesEnabled: data.settings.invitesEnabled ?? true,
invitesLength: data.settings.invitesLength ?? 6,
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative' h='100%'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
<Title order={2}>Invites</Title>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,90 +1,81 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, Divider, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Mfa({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Mfa() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
mfaTotpEnabled: false,
mfaTotpIssuer: 'Zipline',
mfaPasskeysEnabled: false,
mfaPasskeysRpID: '',
mfaPasskeysOrigin: '',
mfaTotpEnabled: data.settings.mfaTotpEnabled,
mfaTotpIssuer: data.settings.mfaTotpIssuer,
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled,
mfaPasskeysRpID: data.settings.mfaPasskeysRpID,
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled ?? false,
mfaPasskeysRpID: data.settings.mfaPasskeysRpID ?? '',
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
/>
<Title order={2}>Multi-Factor Authentication</Title>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<Divider />
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<Switch
label='Enable TOTP'
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Issuer'
description='The issuer to use for the TOTP token.'
placeholder='Zipline'
{...form.getInputProps('mfaTotpIssuer')}
/>
</Stack>
<Switch
label='Enable TOTP'
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Issuer'
description='The issuer to use for the TOTP token.'
placeholder='Zipline'
{...form.getInputProps('mfaTotpIssuer')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,10 +1,11 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Anchor,
Button,
LoadingOverlay,
Paper,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
@@ -12,45 +13,52 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Oauth({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Oauth() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
oauthBypassLocalLogin: false,
oauthLoginOnly: false,
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin,
oauthLoginOnly: data.settings.oauthLoginOnly,
oauthDiscordClientId: '',
oauthDiscordClientSecret: '',
oauthDiscordRedirectUri: '',
oauthDiscordAllowedIds: '',
oauthDiscordDeniedIds: '',
oauthDiscordClientId: data.settings.oauthDiscordClientId,
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret,
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri,
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds.join(', '),
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds.join(', '),
oauthGoogleClientId: '',
oauthGoogleClientSecret: '',
oauthGoogleRedirectUri: '',
oauthGoogleClientId: data.settings.oauthGoogleClientId,
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret,
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri,
oauthGithubClientId: '',
oauthGithubClientSecret: '',
oauthGithubRedirectUri: '',
oauthGithubClientId: data.settings.oauthGithubClientId,
oauthGithubClientSecret: data.settings.oauthGithubClientSecret,
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri,
oauthOidcClientId: '',
oauthOidcClientSecret: '',
oauthOidcAuthorizeUrl: '',
oauthOidcTokenUrl: '',
oauthOidcUserinfoUrl: '',
oauthOidcRedirectUri: '',
oauthOidcClientId: data.settings.oauthOidcClientId,
oauthOidcClientSecret: data.settings.oauthOidcClientSecret,
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl,
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl,
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl,
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -89,56 +97,21 @@ export default function Oauth({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin ?? false,
oauthLoginOnly: data.settings.oauthLoginOnly ?? false,
oauthDiscordClientId: data.settings.oauthDiscordClientId ?? '',
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret ?? '',
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri ?? '',
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds
? data.settings.oauthDiscordAllowedIds.join(', ')
: '',
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds
? data.settings.oauthDiscordDeniedIds.join(', ')
: '',
oauthGoogleClientId: data.settings.oauthGoogleClientId ?? '',
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret ?? '',
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri ?? '',
oauthGithubClientId: data.settings.oauthGithubClientId ?? '',
oauthGithubClientSecret: data.settings.oauthGithubClientSecret ?? '',
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri ?? '',
oauthOidcClientId: data.settings.oauthOidcClientId ?? '',
oauthOidcClientSecret: data.settings.oauthOidcClientSecret ?? '',
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl ?? '',
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl ?? '',
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl ?? '',
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>OAuth</Title>
<Text size='sm' c='dimmed'>
For OAuth to work, the &quot;OAuth Registration&quot; setting must be enabled in the Features section.
If you have issues, try restarting Zipline after saving.
<>
<Text size='sm' c='dimmed' mb='md'>
For OAuth to work, the &quot;OAuth Registration&quot; setting must be enabled in the{' '}
<Anchor component={Link} to='/dashboard/admin/settings/features'>
Features
</Anchor>{' '}
section. If you have issues, try restarting Zipline after saving.
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Stack gap='lg'>
<Switch
label='Bypass Local Login'
description='Skips the local login page and redirects to the OAuth provider, this only works with one provider enabled.'
description='Skips the local login page and redirects to the OAuth provider, this will only work with one provider enabled.'
{...form.getInputProps('oauthBypassLocalLogin', { type: 'checkbox' })}
/>
@@ -147,35 +120,33 @@ export default function Oauth({
description='Disables registration and only allows login with OAuth, existing users can link providers for example.'
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
/>
</SimpleGrid>
<Paper withBorder p='sm' my='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
</Title>
</Anchor>
<Paper withBorder p='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
</Title>
</Anchor>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
<TextInput
label='Discord Allowed IDs'
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
{...form.getInputProps('oauthDiscordAllowedIds')}
/>
<TextInput
label='Discord Denied IDs'
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
{...form.getInputProps('oauthDiscordDeniedIds')}
/>
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</Paper>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
<TextInput
label='Discord Allowed IDs'
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
{...form.getInputProps('oauthDiscordAllowedIds')}
/>
<TextInput
label='Discord Denied IDs'
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
{...form.getInputProps('oauthDiscordDeniedIds')}
/>
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</Paper>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
<Anchor href='https://console.developers.google.com/' target='_blank'>
<Title order={4} mb='sm'>
@@ -207,29 +178,29 @@ export default function Oauth({
{...form.getInputProps('oauthGithubRedirectUri')}
/>
</Paper>
</SimpleGrid>
<Paper withBorder p='sm' my='md'>
<Title order={4}>OpenID Connect</Title>
<Paper withBorder p='sm'>
<Title order={4}>OpenID Connect</Title>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
<TextInput
label='OIDC Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthOidcRedirectUri')}
/>
</SimpleGrid>
</Paper>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
<TextInput
label='OIDC Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthOidcRedirectUri')}
/>
</SimpleGrid>
</Paper>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
</>
);
}
@@ -1,41 +1,37 @@
import { Response } from '@/lib/api/response';
import {
Button,
ColorInput,
Group,
LoadingOverlay,
Paper,
SimpleGrid,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, ColorInput, Group, LoadingOverlay, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function PWA({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function PWA() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
pwaEnabled: false,
pwaTitle: '',
pwaShortName: '',
pwaDescription: '',
pwaThemeColor: '',
pwaBackgroundColor: '',
pwaEnabled: data.settings.pwaEnabled,
pwaTitle: data.settings.pwaTitle,
pwaShortName: data.settings.pwaShortName,
pwaDescription: data.settings.pwaDescription,
pwaThemeColor: data.settings.pwaThemeColor,
pwaBackgroundColor: data.settings.pwaBackgroundColor,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'pwaEnabled' && !form.values.pwaEnabled) ||
false,
}),
@@ -59,38 +55,20 @@ export default function PWA({
});
};
useEffect(() => {
if (!data) return;
form.setValues({
pwaEnabled: data.settings.pwaEnabled ?? false,
pwaTitle: data.settings.pwaTitle ?? '',
pwaShortName: data.settings.pwaShortName ?? '',
pwaDescription: data.settings.pwaDescription ?? '',
pwaThemeColor: data.settings.pwaThemeColor ?? '',
pwaBackgroundColor: data.settings.pwaBackgroundColor ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative' h='100%'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>PWA</Title>
<Text size='sm' c='dimmed'>
<>
<Text size='sm' c='dimmed' mb='md'>
Refresh the page after enabling PWA to see any changes.
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='PWA Enabled'
description='Allow users to install the Zipline PWA on their devices.'
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
/>
<Stack gap='lg'>
<Switch
label='PWA Enabled'
description='Allow users to install the Zipline PWA on their devices.'
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
/>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Title'
description='The title for the PWA'
@@ -125,8 +103,7 @@ export default function PWA({
placeholder='#ffffff'
{...form.getInputProps('pwaBackgroundColor')}
/>
</SimpleGrid>
</Stack>
<Group mt='md'>
<Button type='submit' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
@@ -136,6 +113,6 @@ export default function PWA({
</Button>
</Group>
</form>
</Paper>
</>
);
}
@@ -1,45 +1,42 @@
import { Response } from '@/lib/api/response';
import {
Button,
LoadingOverlay,
NumberInput,
Paper,
SimpleGrid,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Ratelimit({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Ratelimit() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
ratelimitEnabled: boolean;
ratelimitMax: number;
ratelimitWindow: number | '';
ratelimitWindow: number | '' | null;
ratelimitAdminBypass: boolean;
ratelimitAllowList: string;
}>({
initialValues: {
ratelimitEnabled: true,
ratelimitMax: 10,
ratelimitWindow: '',
ratelimitAdminBypass: false,
ratelimitAllowList: '',
ratelimitEnabled: data.settings.ratelimitEnabled,
ratelimitMax: data.settings.ratelimitMax,
ratelimitWindow: data.settings.ratelimitWindow,
ratelimitAdminBypass: data.settings.ratelimitAdminBypass,
ratelimitAllowList: data.settings.ratelimitAllowList.join(', '),
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'ratelimitEnabled' && !form.values.ratelimitEnabled) ||
false,
}),
@@ -65,30 +62,14 @@ export default function Ratelimit({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
ratelimitEnabled: data.settings.ratelimitEnabled ?? true,
ratelimitMax: data.settings.ratelimitMax ?? 10,
ratelimitWindow: data.settings.ratelimitWindow ?? '',
ratelimitAdminBypass: data.settings.ratelimitAdminBypass ?? false,
ratelimitAllowList: data.settings.ratelimitAllowList.join(', ') ?? '',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>Ratelimit</Title>
<Text c='dimmed' size='sm'>
<>
<Text size='sm' c='dimmed' mb='md'>
All options require a restart to take effect.
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Stack gap='lg'>
<Switch
label='Enable Ratelimit'
description='Enable ratelimiting for the server.'
@@ -123,12 +104,12 @@ export default function Ratelimit({
placeholder='192.168.1.1, 127.0.0.1, 0.0.0.0'
{...form.getInputProps('ratelimitAllowList')}
/>
</SimpleGrid>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
</>
);
}
@@ -1,59 +1,49 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, Code, LoadingOverlay, Stack, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Tasks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Tasks() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
tasksDeleteInterval: '30m',
tasksClearInvitesInterval: '30m',
tasksMaxViewsInterval: '30m',
tasksThumbnailsInterval: '30m',
tasksMetricsInterval: '30m',
tasksCleanThumbnailsInterval: '1d',
tasksDeleteInterval: data.settings.tasksDeleteInterval,
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval,
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval,
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval,
tasksMetricsInterval: data.settings.tasksMetricsInterval,
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
tasksDeleteInterval: data.settings.tasksDeleteInterval ?? '30m',
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval ?? '30m',
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval ?? '1d',
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>Tasks</Title>
<Text c='dimmed' size='sm'>
All options require a restart to take effect.
<>
<Text size='sm' c='dimmed' mb='md'>
All options require a restart to take effect. Setting a value of <Code>0</Code> will disable the task.
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Stack gap='lg'>
<TextInput
label='Delete Files Interval'
description='How often to check and delete expired files.'
@@ -88,12 +78,19 @@ export default function Tasks({
placeholder='1d'
{...form.getInputProps('tasksCleanThumbnailsInterval')}
/>
</SimpleGrid>
<TextInput
label='Metrics Interval'
description='How often to collect metrics data. Setting this to a lower value will give you more up-to-date metrics, but may increase CPU usage.'
placeholder='30m'
{...form.getInputProps('tasksMetricsInterval')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
</>
);
}
@@ -1,68 +1,60 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Urls({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Urls() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
urlsRoute: '/go',
urlsLength: 6,
urlsRoute: data.settings.urlsRoute,
urlsLength: data.settings.urlsLength,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
urlsRoute: data.settings.urlsRoute ?? '/go',
urlsLength: data.settings.urlsLength ?? 6,
});
}, [data]);
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Route'
description='The route to use for short URLs. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
<Title order={2}>URL Shortener</Title>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Route'
description='The route to use for short URLs. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,45 +1,41 @@
import { Response } from '@/lib/api/response';
import { Button, Grid, JsonInput, Paper, Switch, TextInput, Title } from '@mantine/core';
import type { Response } from '@/lib/api/response';
import { Button, JsonInput, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
const defaultExternalLinks = [
{
name: 'GitHub',
url: 'https://github.com/diced/zipline',
},
{
name: 'Documentation',
url: 'https://zipline.diced.tech',
},
];
export default function Website() {
const { data, isLoading } = useServerSettings();
export default function Website({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
websiteTitle: 'Zipline',
websiteTitleLogo: '',
websiteExternalLinks: JSON.stringify(defaultExternalLinks),
websiteLoginBackground: '',
websiteLoginBackgroundBlur: true,
websiteDefaultAvatar: '',
websiteTos: '',
websiteTitle: data.settings.websiteTitle,
websiteTitleLogo: data.settings.websiteTitleLogo,
websiteExternalLinks: JSON.stringify(data.settings.websiteExternalLinks, null, 2),
websiteLoginBackground: data.settings.websiteLoginBackground,
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur,
websiteDefaultAvatar: data.settings.websiteDefaultAvatar,
websiteTos: data.settings.websiteTos,
websiteThemeDefault: 'system',
websiteThemeDark: 'builtin:dark_gray',
websiteThemeLight: 'builtin:light_gray',
websiteThemeDefault: data.settings.websiteThemeDefault,
websiteThemeDark: data.settings.websiteThemeDark,
websiteThemeLight: data.settings.websiteThemeLight,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -59,12 +55,19 @@ export default function Website({
}
sendValues.websiteTitleLogo =
values.websiteTitleLogo.trim() === '' ? null : values.websiteTitleLogo.trim();
values.websiteTitleLogo?.trim() === '' || !values.websiteTitleLogo?.trim()
? null
: values.websiteTitleLogo.trim();
sendValues.websiteLoginBackground =
values.websiteLoginBackground.trim() === '' ? null : values.websiteLoginBackground.trim();
values.websiteLoginBackground?.trim() === '' || !values.websiteLoginBackground?.trim()
? null
: values.websiteLoginBackground.trim();
sendValues.websiteDefaultAvatar =
values.websiteDefaultAvatar.trim() === '' ? null : values.websiteDefaultAvatar.trim();
sendValues.websiteTos = values.websiteTos.trim() === '' ? null : values.websiteTos.trim();
values.websiteDefaultAvatar?.trim() === '' || !values.websiteDefaultAvatar?.trim()
? null
: values.websiteDefaultAvatar.trim();
sendValues.websiteTos =
values.websiteTos?.trim() === '' || !values.websiteTos?.trim() ? null : values.websiteTos.trim();
sendValues.websiteThemeDefault = values.websiteThemeDefault.trim();
sendValues.websiteThemeDark = values.websiteThemeDark.trim();
@@ -76,132 +79,92 @@ export default function Website({
return settingsOnSubmit(navigate, form)(sendValues);
};
useEffect(() => {
if (!data) return;
form.setValues({
websiteTitle: data.settings.websiteTitle ?? 'Zipline',
websiteTitleLogo: data.settings.websiteTitleLogo ?? '',
websiteExternalLinks: JSON.stringify(
data.settings.websiteExternalLinks ?? defaultExternalLinks,
null,
2,
),
websiteLoginBackground: data.settings.websiteLoginBackground ?? '',
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur ?? true,
websiteDefaultAvatar: data.settings.websiteDefaultAvatar ?? '',
websiteTos: data.settings.websiteTos ?? '',
websiteThemeDefault: data.settings.websiteThemeDefault ?? 'system',
websiteThemeDark: data.settings.websiteThemeDark ?? 'builtin:dark_gray',
websiteThemeLight: data.settings.websiteThemeLight ?? 'builtin:light_gray',
});
}, [data]);
return (
<Paper withBorder p='sm'>
<Title order={2}>Website</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
{/* <SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'> */}
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
</Grid.Col>
<TextInput
label='Title Logo'
description='The URL to use for the title logo. This is placed to the left of the title.'
placeholder='https://example.com/logo.png'
{...form.getInputProps('websiteTitleLogo')}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Title Logo'
description='The URL to use for the title logo. This is placed to the left of the title.'
placeholder='https://example.com/logo.png'
{...form.getInputProps('websiteTitleLogo')}
/>
</Grid.Col>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON in the format of an array of objects with "name" and "url" properties. For example: [{"name": "GitHub", "url": "https://github.com/diced/zipline"}]'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(
[
{ name: 'GitHub', url: 'https://github.com/diced/zipline' },
{ name: 'Documentation', url: 'https://zipline.diced.sh' },
],
null,
2,
)}
{...form.getInputProps('websiteExternalLinks')}
/>
<Grid.Col span={12}>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON.'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
{...form.getInputProps('websiteExternalLinks')}
/>
</Grid.Col>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
</Grid.Col>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
</Grid.Col>
<TextInput
label='Default Avatar'
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
placeholder='/zipline/avatar.png'
{...form.getInputProps('websiteDefaultAvatar')}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Default Avatar'
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
placeholder='/zipline/avatar.png'
{...form.getInputProps('websiteDefaultAvatar')}
/>
</Grid.Col>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
</Grid.Col>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<Grid.Col span={12}>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
</Grid.Col>
<TextInput
label='Dark Theme'
description='The dark theme to use for the website when the default theme is "system".'
placeholder='builtin:dark_gray'
{...form.getInputProps('websiteThemeDark')}
/>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Dark Theme'
description='The dark theme to use for the website when the default theme is "system".'
placeholder='builtin:dark_gray'
{...form.getInputProps('websiteThemeDark')}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Light Theme'
description='The light theme to use for the website when the default theme is "system".'
placeholder='builtin:light_gray'
{...form.getInputProps('websiteThemeLight')}
/>
</Grid.Col>
</Grid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</Paper>
<TextInput
label='Light Theme'
description='The light theme to use for the website when the default theme is "system".'
placeholder='builtin:light_gray'
{...form.getInputProps('websiteThemeLight')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -42,7 +42,7 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
mutate('/api/server/settings', data);
mutate('/api/server/settings/web');
mutate('/api/server/public');
navigate('/dashboard/admin/settings', { replace: true });
navigate(window.location.pathname, { replace: true });
}
};
}
@@ -0,0 +1,6 @@
import type { Response } from '@/lib/api/response';
import useSWR from 'swr';
export default function useServerSettings() {
return useSWR<Response['/api/server/settings']>('/api/server/settings');
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default function DashboardSettings() {
config.oauthEnabled.oidc,
) && <SettingsOAuth />}
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />}
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys.enabled) && <SettingsMfa />}
<SettingsExports />
<SettingsGenerators />
@@ -1,8 +1,8 @@
import { Response } from '@/lib/api/response';
import { readToDataURL } from '@/lib/base64';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import { useUserStore } from '@/lib/store/user';
import useAvatar from '@/lib/client/hooks/useAvatar';
import { useUserStore } from '@/lib/client/store/user';
import {
Avatar,
Button,
@@ -25,7 +25,7 @@ import {
IconSettingsFilled,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
export default function SettingsAvatar() {
const user = useUserStore((state) => state.user);
@@ -36,14 +36,16 @@ export default function SettingsAvatar() {
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
useEffect(() => {
(async () => {
if (!avatar) return;
const onAvatarChange = async (file: File | null) => {
setAvatar(file);
const base64url = await readToDataURL(avatar);
setAvatarSrc(base64url);
})();
}, [avatar]);
if (!file) {
setAvatarSrc(null);
return;
}
setAvatarSrc(await readToDataURL(file));
};
const saveAvatar = async () => {
if (!avatar) return;
@@ -111,7 +113,7 @@ export default function SettingsAvatar() {
accept='image/*'
placeholder='Upload new avatar...'
value={avatar}
onChange={(file) => setAvatar(file)}
onChange={onAvatarChange}
leftSection={<IconPhotoUp size='1rem' />}
/>
@@ -1,5 +1,6 @@
import DomainSelect from '@/components/DomainSelect';
import { useThemes } from '@/components/ThemeProvider';
import { useSettingsStore } from '@/lib/store/settings';
import { useSettingsStore } from '@/lib/client/store/settings';
import { Group, Paper, Select, Stack, Switch, Text, Title } from '@mantine/core';
import { IconMoonFilled, IconPaintFilled, IconSunFilled } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
@@ -33,14 +34,14 @@ export default function SettingsDashboard() {
<Paper withBorder p='sm' h='100%'>
<Title order={2}>Dashboard Settings</Title>
<Text size='sm' c='dimmed' mt={3}>
These settings are saved in your browser.
These settings are saved automatically in your <b>browser.</b>
</Text>
<Stack gap='sm' my='xs'>
<Group grow>
<Stack>
<Switch
label='Disable Media Preview'
description='Disable previews of files in the dashboard. This is useful to save data as Zipline, by default, will load previews of files.'
description='Disable previews of files in the dashboard. This may help to save data and speed up the dashboard if you have a lot of media files, but it will also disable the file viewer and show a generic file icon instead of a preview for supported files.'
checked={settings.disableMediaPreview}
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
/>
@@ -50,7 +51,31 @@ export default function SettingsDashboard() {
checked={settings.warnDeletion}
onChange={(event) => update('warnDeletion', event.currentTarget.checked)}
/>
</Group>
<Switch
label='File navigation buttons'
description='Show previous/next on the right and left of the file viewer to easily navigate between files.'
checked={settings.fileNavButtons}
onChange={(event) => update('fileNavButtons', event.currentTarget.checked)}
/>
</Stack>
<Select
label='File viewer'
description='Choose which file viewer opens when you click a file.'
data={[
{ value: 'fullscreen', label: 'Fullscreen (beta)' },
{ value: 'default', label: 'Default (modal)' },
]}
value={settings.fileViewer}
onChange={(value) => update('fileViewer', (value as 'default' | 'fullscreen') ?? 'fullscreen')}
/>
<DomainSelect
label='Default Domain'
description='Set the default domain used for copied links anywhere in the dashboard. Leave blank or select "Default domain" to use the current domain that serves the dashboard.'
value={settings.domain}
onChange={(value) => update('domain', (value as string) ?? '')}
/>
<Select
label='Theme'
@@ -82,7 +82,6 @@ export default function SettingsExports() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading && <Table.Tr>Loading...</Table.Tr>}
{data?.map((exportDb) => (
<Table.Tr key={exportDb.id}>
<Table.Td maw={140}>

Some files were not shown because too many files have changed in this diff Show More