mirror of
https://github.com/diced/zipline.git
synced 2026-06-26 07:53:54 -07:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd477d7aa | |||
| 26a16137b2 | |||
| d07c1fa99b | |||
| ae80d228b5 | |||
| b5c39bed47 | |||
| ca9bd41244 | |||
| 93f0210605 | |||
| 4329dc7cdf | |||
| ae6a6536f9 | |||
| 0fc7e7a06f | |||
| 18bc86c261 | |||
| 2e210da549 | |||
| 3639ec0dc2 | |||
| 754c54542e | |||
| 19b7e6f938 | |||
| cd22a8915e | |||
| 97a75c0f84 | |||
| d5e2bc3ec2 | |||
| 6f90339f17 | |||
| 833f8a30cc | |||
| e6382b3881 | |||
| e3789446c2 | |||
| 6c94abc73b | |||
| 8fb21988a7 | |||
| 72fc8116d4 | |||
| 177febf305 | |||
| a8c65c19b4 | |||
| 3fc3dcd1ed | |||
| 0c52b48c05 | |||
| 5c386a792e | |||
| c4f8aa52a4 | |||
| 5c0097fed5 | |||
| 6ebd8f68f9 | |||
| f6188cf15b | |||
| c9cbc2322f | |||
| ae11a29057 | |||
| 4776d9e85f | |||
| 41b63e6f25 | |||
| 24b332c23e | |||
| 3fd9154e57 | |||
| 3f71769ec6 | |||
| a99b0f4f1d | |||
| 15f5279ddb | |||
| 87a2dfbda6 | |||
| c7d2b3010f | |||
| 5119806147 | |||
| 33104ce1be | |||
| eeb1c51fb2 | |||
| 756dee6bba | |||
| a0907e8791 | |||
| 5a58abeb51 | |||
| 72d8c693c7 | |||
| 7caf314ce1 | |||
| 677927b4a6 | |||
| ac0b718f77 | |||
| db3a1b88ad | |||
| a97cf32682 | |||
| 7e2b4ed1bb | |||
| a7fdf5afed | |||
| db8adcc768 | |||
| 135cf1982a | |||
| 9925300e9d | |||
| 3bf125b4b4 | |||
| dc9abe4383 | |||
| 1ccbc878f8 | |||
| aa43f66570 | |||
| 7e3bba5e55 | |||
| 82e1fe4824 | |||
| 818d3f5518 | |||
| 23c131f45a | |||
| 3c5fd8effe | |||
| 377e3dc73d | |||
| f75457da1c | |||
| d6b0ba3b16 | |||
| 1a1bc46667 | |||
| eb1c39933a | |||
| b070dbf432 | |||
| 8af5ad05d6 | |||
| f0bcb4a019 | |||
| 4c86b7fc38 | |||
| 9b7759520c | |||
| e3e77c7916 | |||
| 13282988e8 | |||
| 00f4254227 | |||
| 669c61eae0 | |||
| 1ee1aca589 | |||
| d49fd6a1f0 | |||
| 8128e3deb0 | |||
| 67a9fe34b4 | |||
| 7a3c4223ec |
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [22.x, 24.x]
|
||||
node: [24.x, 26.x]
|
||||
arch: [amd64, arm64]
|
||||
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
||||
|
||||
|
||||
+10
-10
@@ -1,23 +1,25 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:24-alpine3.22 AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache ffmpeg tzdata
|
||||
RUN corepack enable \
|
||||
&& apk add --no-cache ffmpeg=6.1.2-r2 tzdata=2026b-r0
|
||||
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY prisma ./prisma
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY pnpm-workspace.yaml .
|
||||
|
||||
FROM base AS deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY .gitignore ./.gitignore
|
||||
@@ -44,10 +46,8 @@ COPY --from=builder /zipline/build ./build
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/code.json ./code.json
|
||||
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# clean
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
RUN pnpm prisma generate \
|
||||
&& rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ZIPLINE_ROOT=/zipline
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+2
-1
@@ -85,6 +85,7 @@ export default defineConfig(
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
@@ -101,7 +102,7 @@ export default defineConfig(
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
react: { version: '19' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+65
-561
@@ -19,69 +19,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"lastModified": 1777487137,
|
||||
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"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",
|
||||
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -92,60 +34,19 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1773440526,
|
||||
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
|
||||
"owner": "nix-community",
|
||||
"lastModified": 1772186516,
|
||||
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"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",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@@ -153,9 +54,10 @@
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"git-hooks": "git-hooks_3",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"ghostty": "ghostty",
|
||||
"git-hooks": "git-hooks",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
@@ -164,11 +66,11 @@
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774134162,
|
||||
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
|
||||
"lastModified": 1782331842,
|
||||
"narHash": "sha256-7CJ2EqNVPMq0ly39aaP6dGgdO627MqUtM/+Dm+QwNdU=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
|
||||
"rev": "885e1c9d62cfa12232802de77b36aaded1ca609b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -189,80 +91,7 @@
|
||||
"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": 1767039857,
|
||||
@@ -282,17 +111,15 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -302,58 +129,15 @@
|
||||
}
|
||||
},
|
||||
"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": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -362,86 +146,40 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ghostty": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1779069789,
|
||||
"narHash": "sha256-ojo+gso45/6CVSuqfSVnlWpQ4d0QeLgwok+v/g3yu0E=",
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"rev": "4b7bf0b20e3baf9c1ba10c63f2ad1fd853faea8f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"lastModified": 1778507602,
|
||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||
"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": 1772893680,
|
||||
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -451,102 +189,6 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -594,52 +236,20 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774103430,
|
||||
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
|
||||
"lastModified": 1779748925,
|
||||
"narHash": "sha256-meIhqGC04O5VXbKSFXSQoOKp+XCq5RMnwAk1Guo0VQo=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
|
||||
"rev": "0bc443c8ff235c3547d09327b48aaa2ab98b15f2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.32",
|
||||
"ref": "devenv-2.34",
|
||||
"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": [
|
||||
@@ -653,11 +263,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773634079,
|
||||
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
|
||||
"lastModified": 1778381404,
|
||||
"narHash": "sha256-FqhdOTA8vyoIpkHhbs2cCT7h6EWM7nsLeOYJc1ifQLE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
|
||||
"rev": "e3e45eb76663f522e196b7f0cf34cab201db7779",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -667,69 +277,6 @@
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"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-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"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=",
|
||||
@@ -745,61 +292,18 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_3",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"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",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@@ -807,8 +311,8 @@
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_4",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@@ -819,11 +323,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773630837,
|
||||
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
|
||||
"lastModified": 1779074409,
|
||||
"narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
|
||||
"rev": "2a77b5b1dc952f214e8102acdef1622b68515560",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -841,11 +345,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772660329,
|
||||
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
|
||||
"lastModified": 1775636079,
|
||||
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
|
||||
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+46
-47
@@ -2,15 +2,15 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.5.2",
|
||||
"version": "4.6.3",
|
||||
"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,27 +22,27 @@
|
||||
"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.1046.0",
|
||||
"@aws-sdk/lib-storage": "3.1046.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.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^8.3.18",
|
||||
"@mantine/code-highlight": "^8.3.18",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/dropzone": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@mantine/charts": "^9.2.0",
|
||||
"@mantine/code-highlight": "^9.2.0",
|
||||
"@mantine/core": "^9.2.0",
|
||||
"@mantine/dates": "^9.2.0",
|
||||
"@mantine/dropzone": "^9.2.0",
|
||||
"@mantine/form": "^9.2.0",
|
||||
"@mantine/hooks": "^9.2.0",
|
||||
"@mantine/modals": "^9.2.0",
|
||||
"@mantine/notifications": "^9.2.0",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
@@ -50,9 +50,9 @@
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"archiver": "^7.0.1",
|
||||
"@smithy/node-http-handler": "^4.7.2",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"bytes": "^3.1.2",
|
||||
@@ -63,35 +63,34 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"devalue": "^5.8.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.8.4",
|
||||
"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": "^3.7.1",
|
||||
"katex": "^0.16.42",
|
||||
"mantine-datatable": "^8.3.13",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"katex": "^0.16.46",
|
||||
"mantine-datatable": "^9.2.0",
|
||||
"marked-react": "^4.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.1.1",
|
||||
"nuqs": "^2.8.9",
|
||||
"otplib": "^13.4.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-window": "1.8.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.4.1",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
@@ -101,32 +100,32 @@
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^10.3.0",
|
||||
"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.8",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.98.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
|
||||
}
|
||||
|
||||
Generated
+1535
-3095
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
onlyBuiltDependencies:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "File_folderId_createdAt_idx" ON "public"."File"("folderId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "File_name_idx" ON "public"."File"("name");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDisabledTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "filesDisabledTypesDefault" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Zipline" ADD COLUMN "filesExtensionlessUrls" BOOLEAN NOT NULL DEFAULT false;
|
||||
+10
-3
@@ -36,6 +36,8 @@ model Zipline {
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledTypes String[] @default([])
|
||||
filesDisabledTypesDefault String?
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
@@ -47,6 +49,7 @@ model Zipline {
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesMaxFilesPerUpload Int @default(1000)
|
||||
filesExtensionlessUrls Boolean @default(false)
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -58,9 +61,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)
|
||||
@@ -294,6 +298,9 @@ model File {
|
||||
folderId String?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
|
||||
@@index([name])
|
||||
@@index([folderId, createdAt])
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
|
||||
+5
-1
@@ -6,6 +6,7 @@ import ThemeProvider from '@/components/ThemeProvider';
|
||||
import { type ZiplineTheme } from '@/lib/theme';
|
||||
import { type Config } from '@/lib/config/validate';
|
||||
import { Button, Text } from '@mantine/core';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
|
||||
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
|
||||
<>
|
||||
@@ -61,7 +62,10 @@ export default function Root({
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
<Outlet />
|
||||
|
||||
<NuqsAdapter>
|
||||
<Outlet />
|
||||
</NuqsAdapter>
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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't have an account?{' '}
|
||||
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
|
||||
Register
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useUser from '@/lib/client/hooks/useUser';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import DashboardAdminHome from '@/components/pages/admin';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Administrator');
|
||||
|
||||
return <DashboardAdminHome />;
|
||||
}
|
||||
|
||||
Component.displayName = 'Dashboard/Admin';
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useApiPagination } from '@/components/pages/files/useApiPagination';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
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 { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -16,25 +20,34 @@ 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';
|
||||
import { useQueryState, parseAsInteger } from 'nuqs';
|
||||
|
||||
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]'],
|
||||
};
|
||||
}
|
||||
|
||||
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
return (
|
||||
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
|
||||
<Card withBorder shadow='sm' style={{ cursor: 'pointer' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1.2rem' />
|
||||
@@ -58,11 +71,33 @@ 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();
|
||||
|
||||
useTitle(folder.name);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(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[] = [];
|
||||
@@ -80,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) => (
|
||||
@@ -132,7 +187,7 @@ export function Component() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(folder.files?.length ?? 0) > 0 && (
|
||||
{(files.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
@@ -145,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/client/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=${encodeURIComponent(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=${encodeURIComponent(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=${encodeURIComponent(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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ export const router = createBrowserRouter([
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'admin', lazy: () => import('./pages/dashboard/admin/index') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ 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') },
|
||||
{
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -5,37 +5,39 @@ 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 { findFileByName, 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({
|
||||
where: { name: decodeURIComponent(id) },
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
userId: true,
|
||||
thumbnail: { select: { path: true } },
|
||||
tags: { select: { id: true, name: true, color: true } },
|
||||
Folder: { select: { id: true, public: true, name: true } },
|
||||
},
|
||||
});
|
||||
findFileByName(id, (where, orderBy) =>
|
||||
prisma.file.findFirst({
|
||||
where,
|
||||
...(orderBy && { orderBy }),
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
userId: true,
|
||||
thumbnail: { select: { path: true } },
|
||||
tags: { select: { id: true, name: true, color: true } },
|
||||
Folder: { select: { id: true, public: true, name: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export async function render(
|
||||
{
|
||||
@@ -44,11 +46,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 +96,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 +141,7 @@ export async function render(
|
||||
const data = {
|
||||
file,
|
||||
password: hasPassword,
|
||||
pw: pw || null,
|
||||
token: valid ? token : null,
|
||||
code,
|
||||
user,
|
||||
host,
|
||||
@@ -171,9 +171,13 @@ export async function render(
|
||||
const safeOriginalName = stripHtml(file.originalName || '');
|
||||
const safeType = stripHtml(file.type || '');
|
||||
|
||||
const meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
const viewEnabled = !!user.view?.enabled;
|
||||
const showRichOg = viewEnabled && !!user.view.embed;
|
||||
const showMediaOg = viewEnabled && (!!user.view.embed || !!user.view.embedMediaOnly);
|
||||
const pageUrl = `${host}${url.split('?')[0]}`;
|
||||
|
||||
const richMeta = [
|
||||
showRichOg && user?.view?.embedTitle
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
parseString(user.view.embedTitle, {
|
||||
file: file as unknown as File,
|
||||
@@ -181,10 +185,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedDescription && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedDescription
|
||||
? `<meta property="og:description" content="${stripHtml(
|
||||
parseString(user.view.embedDescription, {
|
||||
file: file as unknown as File,
|
||||
@@ -192,10 +194,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedSiteName && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedSiteName
|
||||
? `<meta property="og:site_name" content="${stripHtml(
|
||||
parseString(user.view.embedSiteName, {
|
||||
file: file as unknown as File,
|
||||
@@ -203,10 +203,8 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedColor && user.view.embed
|
||||
: '',
|
||||
showRichOg && user?.view?.embedColor
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
@@ -214,67 +212,70 @@ export async function render(
|
||||
...metrics,
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
${
|
||||
file.type?.startsWith('image')
|
||||
const imageOg =
|
||||
showMediaOg && file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:title" content="${safeFilename}" />
|
||||
${showRichOg ? `<meta property="twitter:title" content="${safeFilename}" />` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('video')
|
||||
const videoOg =
|
||||
showMediaOg && file.type?.startsWith('video')
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('audio')
|
||||
const audioOg =
|
||||
showMediaOg && file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<meta name="twitter:player" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${safeType}" />
|
||||
<meta name="twitter:title" content="${safeFilename}" />
|
||||
${showRichOg ? `<meta name="twitter:title" content="${safeFilename}" />` : ''}
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="og:audio" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:type" content="${safeType}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
const otherOg =
|
||||
showRichOg && !file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
<title>${file.originalName ? safeOriginalName : safeFilename}</title>
|
||||
`;
|
||||
const docTitle = `<title>${file.originalName ? safeOriginalName : safeFilename}</title>`;
|
||||
|
||||
const includeHead = showRichOg || showMediaOg;
|
||||
const headMeta = includeHead
|
||||
? [richMeta, imageOg, videoOg, audioOg, otherOg, docTitle].filter(Boolean).join('\n')
|
||||
: '';
|
||||
|
||||
return {
|
||||
html,
|
||||
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
|
||||
meta: `${headMeta ? `${headMeta}\n` : ''}${createZiplineSsr(data)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,3 +5,11 @@
|
||||
font-weight: 700;
|
||||
font-size: var(--mantine-font-size-xl);
|
||||
}
|
||||
|
||||
.mantine-Table-th {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mantine-datatable {
|
||||
border-radius: var(--mantine-radius-default);
|
||||
}
|
||||
|
||||
+79
-50
@@ -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;
|
||||
@@ -120,12 +121,24 @@ const navLinks: NavLinks[] = [
|
||||
if: (user) => isAdministrator(user?.role),
|
||||
active: (path: string) => path.startsWith('/dashboard/admin'),
|
||||
links: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: <IconHome size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin',
|
||||
href: '/dashboard/admin',
|
||||
},
|
||||
{
|
||||
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(({ label, href, icon: Icon }) => ({
|
||||
label,
|
||||
icon: <Icon size='1rem' />,
|
||||
active: (path: string) => path === href,
|
||||
href,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
@@ -150,6 +163,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 +231,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 +401,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 />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActionIcon, Anchor, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function LinksList({
|
||||
links,
|
||||
}: {
|
||||
links: {
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: any;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
}) {
|
||||
const visibleLinks = links.filter((link) => !link.hidden);
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
{visibleLinks.map(({ label, description, href, icon: Icon }) => (
|
||||
<Anchor key={href} component={Link} to={href} style={{ textDecoration: 'none' }}>
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md'>
|
||||
<ActionIcon variant='filled' radius='md' size='xl'>
|
||||
<Icon size='1.75rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<div>
|
||||
<Title order={4}>{label}</Title>
|
||||
<Text c='dimmed'>{description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,6 @@ export default function ThemeProvider({
|
||||
forceColorScheme={theme.colorScheme as unknown as any}
|
||||
theme={createTheme({
|
||||
...themeComponents(theme),
|
||||
defaultRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -64,6 +64,96 @@ function VersionButton({ text, children, href }: { href: string; text: string; c
|
||||
);
|
||||
}
|
||||
|
||||
type VersionData = NonNullable<ReturnType<typeof useVersion>['version']>;
|
||||
|
||||
export function VersionInfo({ version }: { version: VersionData }) {
|
||||
return (
|
||||
<>
|
||||
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
|
||||
{version.isUpstream && (
|
||||
<Text>
|
||||
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
|
||||
may contain bugs.
|
||||
</Text>
|
||||
)}
|
||||
{!version.isLatest && !version.isUpstream && version.isRelease && (
|
||||
<Text>
|
||||
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
|
||||
<Anchor href={version.latest.url}>latest version</Anchor>.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Indicator processing position='middle-end' inline offset={-15} color='red' disabled={version.isLatest}>
|
||||
<Title order={3} my='sm'>
|
||||
Current Version
|
||||
</Title>
|
||||
</Indicator>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Version',
|
||||
value: version.version.tag!,
|
||||
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{!version.isLatest && version.isUpstream && version.latest.commit && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
Latest Commit Available
|
||||
</Title>
|
||||
<Text c='dimmed' size='sm' mb='sm'>
|
||||
This is only visible when running an upstream version.
|
||||
</Text>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.latest.commit.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!version.isLatest && version.isRelease && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
{version.latest.tag} is available
|
||||
</Title>
|
||||
|
||||
<VersionButton text='Changelogs' href={version.latest.url}>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
|
||||
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VersionBadge() {
|
||||
const { version, isLoading } = useVersion();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
@@ -74,93 +164,7 @@ export default function VersionBadge() {
|
||||
return (
|
||||
<>
|
||||
<Modal title='Zipline Version' opened={opened} onClose={close} size='lg'>
|
||||
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
|
||||
{version.isUpstream && (
|
||||
<Text>
|
||||
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
|
||||
may contain bugs.
|
||||
</Text>
|
||||
)}
|
||||
{!version.isLatest && !version.isUpstream && version.isRelease && (
|
||||
<Text>
|
||||
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
|
||||
<Anchor href={version.latest.url}>latest version</Anchor>.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Indicator
|
||||
processing
|
||||
position='middle-end'
|
||||
inline
|
||||
offset={-15}
|
||||
color='red'
|
||||
disabled={version.isLatest}
|
||||
>
|
||||
<Title order={3} my='sm'>
|
||||
Current Version
|
||||
</Title>
|
||||
</Indicator>
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Version',
|
||||
value: version.version.tag!,
|
||||
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{!version.isLatest && version.isUpstream && version.latest.commit && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
Latest Commit Available
|
||||
</Title>
|
||||
<Text c='dimmed' size='sm' mb='sm'>
|
||||
This is only visible when running an upstream version.
|
||||
</Text>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.latest.commit.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!version.isLatest && version.isRelease && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
{version.latest.tag} is available
|
||||
</Title>
|
||||
|
||||
<VersionButton text='Changelogs' href={version.latest.url}>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
|
||||
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
</>
|
||||
)}
|
||||
<VersionInfo version={version} />
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='Click to view more version information'>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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/client/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/client/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!} />
|
||||
@@ -200,7 +237,7 @@ export default function FileModal({
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<DashboardFileType file={file} show />
|
||||
{open && <DashboardFileType file={file} show />}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing='md' my='xs'>
|
||||
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
|
||||
@@ -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,641 @@
|
||||
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 "
|
||||
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</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 "{search}"
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
{open && file ? (
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
alignSelf: 'stretch',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
overflow: 'visible',
|
||||
paddingLeft: '4rem',
|
||||
paddingRight: '4rem',
|
||||
}}
|
||||
>
|
||||
<DashboardFileType
|
||||
key={file.id}
|
||||
file={file}
|
||||
show
|
||||
fullscreen
|
||||
allowZoom={false}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,35 @@ 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 FileContextMenu from '../FileContextMenu';
|
||||
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);
|
||||
|
||||
const handleView = () => (onOpen ? onOpen(file.id) : setOpen(true));
|
||||
|
||||
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}>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
|
||||
|
||||
<FileContextMenu file={file} reduce={reduce} user={id} onView={handleView}>
|
||||
<Card shadow='md' radius='md' p={0} onClick={handleView} className={styles.file}>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
</FileContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
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 { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Asciinema from '../render/Asciinema';
|
||||
import Pdf from '../render/Pdf';
|
||||
import Render from '../render/Render';
|
||||
import { renderMode } from '../render/renderMode';
|
||||
import fileIcon from './fileIcon';
|
||||
|
||||
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.';
|
||||
|
||||
function appendPassword(url: string, password?: string | null) {
|
||||
return `${url}${password ? `?pw=${encodeURIComponent(password)}` : ''}`;
|
||||
}
|
||||
|
||||
function isDbFile(file: DbFile | File): file is DbFile {
|
||||
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
|
||||
}
|
||||
|
||||
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 dbFile = isDbFile(file);
|
||||
|
||||
const fileRoute = dbFile ? (user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`) : '';
|
||||
|
||||
const thumbnailRoute = dbFile
|
||||
? file.thumbnail?.path
|
||||
? user
|
||||
? `/api/user/files/${file.thumbnail.path}/raw`
|
||||
: `/raw/${file.thumbnail.path}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const dbFileUrl = dbFile ? appendPassword(fileRoute, password) : '';
|
||||
const [blobUrl, setBlobUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (dbFile) return setBlobUrl('');
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setBlobUrl(objectUrl);
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [dbFile, file]);
|
||||
|
||||
const fileUrl = dbFile ? dbFileUrl : blobUrl;
|
||||
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const renderIn = renderMode(extension);
|
||||
const type = code ? 'text' : file.type.split('/')[0];
|
||||
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const getText = useCallback(async () => {
|
||||
try {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string;
|
||||
if (content.length > MAX_BYTES) {
|
||||
setFileContent(content.slice(0, MAX_BYTES) + FILE_BIG);
|
||||
} else {
|
||||
setFileContent(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_BYTES) {
|
||||
const res = await fetch(fileUrl, {
|
||||
headers: {
|
||||
Range: `bytes=0-${MAX_BYTES}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(text + FILE_BIG);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
}, [dbFile, file, fileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'text') getText();
|
||||
}, [type, getText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
return () => {
|
||||
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(appendPassword(`/view/${file.name}`, password))}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
|
||||
|
||||
switch (true) {
|
||||
case type === 'video':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<video
|
||||
width='100%'
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
src={fileUrl}
|
||||
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||
/>
|
||||
) : thumbnailRoute ? (
|
||||
<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':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vw',
|
||||
}}
|
||||
onClick={() => allowZoom && setOpen(true)}
|
||||
/>
|
||||
{allowZoom && open && (
|
||||
<FileZoomModal setOpen={setOpen}>
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
width: 'auto',
|
||||
}}
|
||||
/>
|
||||
</FileZoomModal>
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />
|
||||
);
|
||||
|
||||
case 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)} />
|
||||
);
|
||||
|
||||
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={extension} code={fileContent} />
|
||||
)
|
||||
) : (
|
||||
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case isAsciicast === true:
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Asciinema src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
Icon={fileIcon('application/x-asciicast')}
|
||||
/>
|
||||
);
|
||||
|
||||
case file.type === 'application/pdf':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Pdf src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
default:
|
||||
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (show)
|
||||
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>
|
||||
);
|
||||
|
||||
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,294 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenSizedMedia({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
alignSelf: 'stretch',
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 mediaAutoMuted = useSettingsStore((state) => state.settings.mediaAutoMuted);
|
||||
|
||||
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');
|
||||
|
||||
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)} />;
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
width={fullscreen ? undefined : '100%'}
|
||||
autoPlay
|
||||
muted={mediaAutoMuted}
|
||||
controls
|
||||
src={fileUrl}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
objectFit: 'contain',
|
||||
...(fullscreen
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '85vw', maxHeight: '85vh', width: '100%' }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return fullscreen ? <FullscreenSizedMedia>{video}</FullscreenSizedMedia> : video;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
|
||||
if (!show) {
|
||||
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
|
||||
}
|
||||
|
||||
const image = (
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
fit='contain'
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
...(fullscreen
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '70vw', maxHeight: '70vw' }),
|
||||
}}
|
||||
onClick={() => allowZoom && setZoomOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fullscreen ? <FullscreenSizedMedia>{image}</FullscreenSizedMedia> : <Center>{image}</Center>}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<audio autoPlay muted={mediaAutoMuted} 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]);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { Box, Combobox, InputBase, Menu, ScrollArea, Text, useCombobox } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFolderMinus,
|
||||
IconFolderSymlink,
|
||||
IconPencil,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
addToFolder,
|
||||
copyFile,
|
||||
createFolderAndAdd,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
favoriteFile,
|
||||
removeFromFolder,
|
||||
viewFile,
|
||||
} from './actions';
|
||||
import EditFileDetailsModal from './DashboardFile/EditFileDetailsModal';
|
||||
|
||||
const stop = (fn: () => void) => (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
function openCreateFolderModal(file: File) {
|
||||
modals.openConfirmModal({
|
||||
modalId: 'file-context-create-folder',
|
||||
title: 'Create folder',
|
||||
centered: true,
|
||||
children: (
|
||||
<InputBase
|
||||
id='file-context-new-folder'
|
||||
label='Folder name'
|
||||
placeholder='My folder'
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const name = event.currentTarget.value.trim();
|
||||
if (!name) return;
|
||||
createFolderAndAdd(file, name);
|
||||
modals.closeAll();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
labels: { confirm: 'Create', cancel: 'Cancel' },
|
||||
onConfirm: () => {
|
||||
const input = document.getElementById('file-context-new-folder') as HTMLInputElement | null;
|
||||
const name = input?.value?.trim();
|
||||
if (name) createFolderAndAdd(file, name);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function FileContextMenu({
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
onView,
|
||||
children,
|
||||
}: {
|
||||
file: File;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
onView?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
const { data: folders } = useFolders(user);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!folders) return [];
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const folderCombobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
folderCombobox.resetSelectedOption();
|
||||
setFolderSearch('');
|
||||
},
|
||||
});
|
||||
const [folderSearch, setFolderSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
const close = () => setOpened(false);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
};
|
||||
}, [opened]);
|
||||
|
||||
const closeMenu = () => setOpened(false);
|
||||
|
||||
const run = (fn: () => void) => () => {
|
||||
closeMenu();
|
||||
fn();
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setPosition({ x: event.clientX, y: event.clientY });
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const handleAddToFolder = async (value: string) => {
|
||||
closeMenu();
|
||||
folderCombobox.closeDropdown();
|
||||
|
||||
if (value === '$create') {
|
||||
await createFolderAndAdd(file, folderSearch.trim());
|
||||
} else {
|
||||
await addToFolder(file, value);
|
||||
}
|
||||
|
||||
setFolderSearch('');
|
||||
};
|
||||
|
||||
const filteredFolders = folderOptions.filter((folder) =>
|
||||
folder.path.toLowerCase().includes(folderSearch.toLowerCase().trim()),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditFileDetailsModal open={editOpen} onClose={() => setEditOpen(false)} file={file} />
|
||||
|
||||
<Box onContextMenu={handleContextMenu} style={{ display: 'contents' }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withinPortal
|
||||
shadow='md'
|
||||
radius='md'
|
||||
width={240}
|
||||
position='bottom-start'
|
||||
offset={4}
|
||||
closeOnItemClick
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown onClick={(event) => event.stopPropagation()}>
|
||||
<Menu.Label>
|
||||
<Text size='xs' fw={600} lineClamp={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed' lineClamp={1}>
|
||||
{file.type}
|
||||
</Text>
|
||||
</Menu.Label>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
{onView && (
|
||||
<Menu.Item leftSection={<IconEye size='1rem' />} onClick={stop(run(onView))}>
|
||||
Open
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item leftSection={<IconExternalLink size='1rem' />} onClick={stop(run(() => viewFile(file)))}>
|
||||
Open in new tab
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
onClick={stop(run(() => copyFile(file, clipboard)))}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconClipboardTypography size='1rem' />}
|
||||
onClick={stop(run(() => copyFile(file, clipboard, true)))}
|
||||
>
|
||||
Copy raw link
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconDownload size='1rem' />} onClick={stop(run(() => downloadFile(file)))}>
|
||||
Download
|
||||
</Menu.Item>
|
||||
|
||||
{!reduce && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
file.favorite ? (
|
||||
<IconStarFilled size='1rem' color='var(--mantine-color-yellow-5)' />
|
||||
) : (
|
||||
<IconStar size='1rem' />
|
||||
)
|
||||
}
|
||||
onClick={stop(run(() => favoriteFile(file)))}
|
||||
>
|
||||
{file.favorite ? 'Unfavorite' : 'Favorite'}
|
||||
</Menu.Item>
|
||||
|
||||
{file.folderId ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderMinus size='1rem' color='var(--mantine-color-red-5)' />}
|
||||
onClick={stop(run(() => removeFromFolder(file)))}
|
||||
>
|
||||
Remove from folder
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Sub openDelay={100} closeDelay={200}>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconFolderSymlink size='1rem' />}>
|
||||
Move to folder
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<Box p='xs' w={220} onClick={(event) => event.stopPropagation()}>
|
||||
<Combobox
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={handleAddToFolder}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
size='xs'
|
||||
placeholder='Search folders...'
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
folderCombobox.openDropdown();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => folderCombobox.openDropdown()}
|
||||
onFocus={() => folderCombobox.openDropdown()}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<ScrollArea.Autosize mah={200} type='scroll'>
|
||||
<FolderComboboxOptions
|
||||
folderOptions={filteredFolders}
|
||||
searchValue={folderSearch}
|
||||
additionalOptions={
|
||||
!folders?.some((f) => f.name === folderSearch.trim()) &&
|
||||
folderSearch.trim().length > 0 ? (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create "{folderSearch.trim()}"
|
||||
</Combobox.Option>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{!filteredFolders.length && !folderSearch.trim() && (
|
||||
<Combobox.Empty px='xs' py='sm'>
|
||||
<Text size='xs' c='dimmed'>
|
||||
No folders yet
|
||||
</Text>
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<Menu.Item mt={4} onClick={stop(() => openCreateFolderModal(file))}>
|
||||
+ Create new folder
|
||||
</Menu.Item>
|
||||
</Box>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size='1rem' />}
|
||||
onClick={stop(run(() => setEditOpen(true)))}
|
||||
>
|
||||
Edit details
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
color='red'
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
onClick={stop(run(() => deleteFile(warnDeletion, file, () => {})))}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>,
|
||||
const url = raw
|
||||
? getDomain(`/raw/${file.name}`)
|
||||
: file.url
|
||||
? getDomain(`${file.url}`)
|
||||
? getDomain(file.url)
|
||||
: getDomain(`/view/${file.name}`);
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { LinksList } from '@/components/LinksList';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { SimpleGrid, Title } from '@mantine/core';
|
||||
import { IconAdjustments, IconGraph, IconStopwatch, IconTags, IconUsersGroup } from '@tabler/icons-react';
|
||||
import { Version } from './parts/Version';
|
||||
import { Storage } from './parts/Storage';
|
||||
|
||||
export default function DashboardAdminHome() {
|
||||
const { user } = useLogin();
|
||||
const config = useConfig();
|
||||
|
||||
const adminLinks = [
|
||||
{
|
||||
label: 'Metrics',
|
||||
description: 'Instance-wide usage graphs and statistics',
|
||||
href: '/dashboard/metrics',
|
||||
icon: IconGraph,
|
||||
show:
|
||||
config.features.metrics.enabled &&
|
||||
(!config.features.metrics.adminOnly || isAdministrator(user?.role)),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
description: 'Maintenance tools and import/export',
|
||||
href: '/dashboard/admin/actions',
|
||||
icon: IconStopwatch,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
description: 'Manage users and quotas',
|
||||
href: '/dashboard/admin/users',
|
||||
icon: IconUsersGroup,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
description: 'Server configuration',
|
||||
href: '/dashboard/admin/settings',
|
||||
icon: IconAdjustments,
|
||||
show: user?.role === 'SUPERADMIN',
|
||||
},
|
||||
{
|
||||
label: 'Invites',
|
||||
description: 'Create and manage invite codes',
|
||||
href: '/dashboard/admin/invites',
|
||||
icon: IconTags,
|
||||
show: config.invites.enabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={1}>Administrator</Title>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing='md' my='md'>
|
||||
<Storage />
|
||||
<Version />
|
||||
</SimpleGrid>
|
||||
|
||||
<LinksList links={adminLinks} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Button, Group, Paper, Progress, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconDatabase, IconRefresh } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export function Storage() {
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<Response['/api/server/status']>('/api/server/status');
|
||||
|
||||
return (
|
||||
<Paper withBorder p='md' radius='md'>
|
||||
<Group justify='space-between' mb='sm'>
|
||||
<Group gap='xs'>
|
||||
<IconDatabase size='1.2rem' />
|
||||
<Title order={3}>Storage</Title>
|
||||
</Group>
|
||||
|
||||
<Tooltip label='Refresh storage stats'>
|
||||
<Button variant='subtle' size='compact-sm' onClick={() => mutate()} loading={isLoading}>
|
||||
<IconRefresh size='1rem' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap='sm'>
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={28} animate />
|
||||
<Skeleton height={12} animate />
|
||||
</Stack>
|
||||
) : error ? (
|
||||
<Text size='sm' c='red'>
|
||||
Failed to load storage
|
||||
</Text>
|
||||
) : status ? (
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='dimmed'>
|
||||
{status.datasource === 's3' ? 'S3: ' : ''}
|
||||
{status.storage.path}
|
||||
</Text>
|
||||
|
||||
{status.storage.total != null ? (
|
||||
<>
|
||||
<Progress.Root size='xl'>
|
||||
<Progress.Section
|
||||
value={Math.min(100, (status.storage.used / status.storage.total) * 100)}
|
||||
color={
|
||||
status.storage.used / status.storage.total > 0.9
|
||||
? 'red'
|
||||
: status.storage.used / status.storage.total > 0.75
|
||||
? 'orange'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
<Progress.Label>
|
||||
{Math.round((status.storage.used / status.storage.total) * 100)}%
|
||||
</Progress.Label>
|
||||
</Progress.Section>
|
||||
</Progress.Root>
|
||||
|
||||
<Text size='xs' c='dimmed' ta='right'>
|
||||
{bytes(status.storage.used)} / {bytes(status.storage.total)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{bytes(status.storage.used)} used
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { VersionInfo } from '@/components/VersionBadge';
|
||||
import useVersion from '@/lib/client/hooks/useVersion';
|
||||
import { Group, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconVersions } from '@tabler/icons-react';
|
||||
|
||||
export function Version() {
|
||||
const { version, isLoading } = useVersion();
|
||||
|
||||
return (
|
||||
<Paper withBorder p='md' radius='md'>
|
||||
<Group gap='xs' mb='sm'>
|
||||
<IconVersions size='1.2rem' />
|
||||
<Title order={3}>Version</Title>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap='sm'>
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={60} animate />
|
||||
</Stack>
|
||||
) : version ? (
|
||||
<VersionInfo version={version} />
|
||||
) : (
|
||||
<Text size='xs' c='dimmed'>
|
||||
Version information could not be loaded.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Stat from '@/components/Stat';
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
@@ -17,11 +18,12 @@ import { lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
const ActivityChart = lazy(() => import('./parts/ActivityChart'));
|
||||
const Recents = lazy(() => import('./parts/Recents'));
|
||||
|
||||
export default function DashboardHome() {
|
||||
const { user } = useLogin();
|
||||
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
const { homeShowActivity, homeShowRecents, homeShowTypes } = useSettingsStore((state) => state.settings);
|
||||
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
|
||||
|
||||
const config = useConfig();
|
||||
@@ -38,6 +40,32 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
</Skeleton>
|
||||
|
||||
{homeShowRecents && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={260} mt='md' animate />
|
||||
</Paper>
|
||||
}
|
||||
>
|
||||
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Recent files</Title>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/files'
|
||||
leftSection={<IconFiles size='1rem' />}
|
||||
>
|
||||
View all files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Recents />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{user?.quota && (user.quota.maxBytes || user.quota.maxFiles) ? (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{user.quota.filesQuota === 'BY_BYTES' ? (
|
||||
@@ -60,41 +88,9 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Recent files</Title>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/files'
|
||||
leftSection={<IconFiles size='1rem' />}
|
||||
>
|
||||
View all files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{recentLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} height={350} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : recent?.length !== 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{recent!.map((file, i) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size='sm' c='dimmed'>
|
||||
You have no recent files. The last three files you uploaded will appear here.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group mt='md' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Stats</Title>
|
||||
|
||||
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -113,90 +109,98 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
|
||||
{statsLoading ? (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} height={105} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} height={105} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
<Stat Icon={IconFiles} title='Files uploaded' value={stats!.filesUploaded} />
|
||||
<Stat Icon={IconStarFilled} title='Favorite files' value={stats!.favoriteFiles} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Storage used' value={bytes(stats!.storageUsed)} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
|
||||
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
|
||||
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
<Stat Icon={IconFiles} title='Files uploaded' value={stats!.filesUploaded} />
|
||||
<Stat Icon={IconStarFilled} title='Favorite files' value={stats!.favoriteFiles} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Storage used' value={bytes(stats!.storageUsed)} />
|
||||
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
|
||||
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
|
||||
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
|
||||
|
||||
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
|
||||
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
|
||||
</SimpleGrid>
|
||||
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
|
||||
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{Object.keys(stats!.sortTypeCount).length !== 0 && (
|
||||
<>
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{Object.entries(stats!.sortTypeCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count], i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{type}</Table.Td>
|
||||
<Table.Td>{count}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{homeShowActivity && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={260} mt='md' animate />
|
||||
</Paper>
|
||||
}
|
||||
>
|
||||
<ActivityChart />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{statsLoading ? (
|
||||
<Paper withBorder my='md'>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
) : (
|
||||
Object.keys(stats!.sortTypeCount).length !== 0 &&
|
||||
homeShowTypes && (
|
||||
<>
|
||||
<Title order={3} mt='lg' mb='xs'>
|
||||
File types
|
||||
</Title>
|
||||
<Paper withBorder my='md'>
|
||||
<ScrollArea.Autosize mah={400} type='auto'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>File Type</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{Object.entries(stats!.sortTypeCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count], i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{type}</Table.Td>
|
||||
<Table.Td>{count}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Box, Group, Paper, Select, Skeleton, Text, Title } from '@mantine/core';
|
||||
import { IconChartAreaLine, IconLogin2, IconUpload } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const CHART_HEIGHT = 260;
|
||||
|
||||
function parseChartDate(value: unknown): dayjs.Dayjs | null {
|
||||
if (value == null || value === '') return null;
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const d = dayjs(value);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const d = dayjs(value);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDayLabel(value: unknown) {
|
||||
const d = parseChartDate(value);
|
||||
if (!d) return '';
|
||||
|
||||
const today = dayjs().startOf('day');
|
||||
if (d.isSame(today, 'day')) return 'Today';
|
||||
if (d.isSame(today.subtract(1, 'day'), 'day')) return 'Yesterday';
|
||||
return d.format('MMM D');
|
||||
}
|
||||
|
||||
export default function ActivityChart() {
|
||||
const [days, setDays] = useState(14);
|
||||
const { data, isLoading } = useSWR<Response['/api/user/activity']>('/api/user/activity?days=' + days);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Skeleton height={24} width={180} mb='xs' animate />
|
||||
<Skeleton height={16} width={240} mb='lg' animate />
|
||||
<Skeleton height={CHART_HEIGHT} animate />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.series.length) return null;
|
||||
|
||||
const chartData = data.series
|
||||
.map((point) => {
|
||||
const d = dayjs(point.date);
|
||||
if (!d.isValid()) return null;
|
||||
|
||||
return {
|
||||
date: d.valueOf(),
|
||||
uploads: point.uploads,
|
||||
logins: point.logins,
|
||||
};
|
||||
})
|
||||
.filter((point) => point !== null);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
const hasActivity = data.totals.uploads > 0 || data.totals.logins > 0;
|
||||
|
||||
return (
|
||||
<Paper radius='md' withBorder p='md' mt='lg'>
|
||||
<Group justify='space-between' align='flex-start' mb='lg' wrap='nowrap'>
|
||||
<Box>
|
||||
<Title order={3} fw={600}>
|
||||
Activity
|
||||
</Title>
|
||||
<Group gap='xs' style={{ alignItems: 'center' }}>
|
||||
<Text size='sm' c='dimmed' mt={4}>
|
||||
Your uploads and logins over the last{' '}
|
||||
</Text>
|
||||
<Select
|
||||
value={String(days)}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
data={[
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '7', label: '7 days' },
|
||||
{ value: '14', label: '14 days' },
|
||||
{ value: '30', label: '30 days' },
|
||||
]}
|
||||
size='0.4rem'
|
||||
variant='filled'
|
||||
p={0}
|
||||
m={0}
|
||||
fw={500}
|
||||
styles={{
|
||||
input: {
|
||||
color: 'var(--mantine-primary-color-filled)',
|
||||
padding: 10,
|
||||
width: '10em',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
section: {
|
||||
margin: 0,
|
||||
},
|
||||
option: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
wrapper: {
|
||||
borderRadius: 1,
|
||||
},
|
||||
}}
|
||||
comboboxProps={{
|
||||
dropdownPadding: 0,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap='lg' visibleFrom='sm'>
|
||||
<Group gap='xs'>
|
||||
<IconUpload size='1rem' style={{ opacity: 0.85 }} color='var(--mantine-primary-color-filled)' />
|
||||
<Box>
|
||||
<Text size='xs' c='dimmed' lh={1.2}>
|
||||
Uploads
|
||||
</Text>
|
||||
<Text size='sm' fw={600} lh={1.3}>
|
||||
{data.totals.uploads}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap='xs'>
|
||||
<IconLogin2 size='1rem' style={{ opacity: 0.65 }} color='var(--mantine-color-gray-5)' />
|
||||
<Box>
|
||||
<Text size='xs' c='dimmed' lh={1.2}>
|
||||
Logins
|
||||
</Text>
|
||||
<Text size='sm' fw={600} lh={1.3}>
|
||||
{data.totals.logins}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasActivity ? (
|
||||
<Paper withBorder h={CHART_HEIGHT} radius='md' p='md' ta='center'>
|
||||
<Group align='center' justify='center' h='100%'>
|
||||
<IconChartAreaLine size='1.75rem' style={{ opacity: 0.35 }} />
|
||||
<Text size='sm' c='dimmed'>
|
||||
No uploads or logins in this period yet
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<LineChart
|
||||
h={CHART_HEIGHT}
|
||||
data={chartData}
|
||||
dataKey='date'
|
||||
curveType='natural'
|
||||
connectNulls
|
||||
withLegend={false}
|
||||
withDots={false}
|
||||
activeDotProps={{ r: 4, strokeWidth: 2 }}
|
||||
gridAxis='none'
|
||||
tickLine='none'
|
||||
strokeWidth={2}
|
||||
series={[
|
||||
{
|
||||
name: 'uploads',
|
||||
label: 'Uploads',
|
||||
color: 'var(--mantine-primary-color-filled)',
|
||||
},
|
||||
{
|
||||
name: 'logins',
|
||||
label: 'Logins',
|
||||
color: 'gray.5',
|
||||
},
|
||||
]}
|
||||
xAxisProps={{
|
||||
tickMargin: 12,
|
||||
minTickGap: 32,
|
||||
tickFormatter: (v) => formatDayLabel(v),
|
||||
}}
|
||||
yAxisProps={{
|
||||
width: 36,
|
||||
tickMargin: 8,
|
||||
}}
|
||||
tooltipProps={{
|
||||
content: ({ label, payload }) => (
|
||||
<ChartTooltip
|
||||
label={formatDayLabel(label) || '—'}
|
||||
payload={payload}
|
||||
series={[
|
||||
{ name: 'uploads', label: 'Uploads', color: 'var(--mantine-primary-color-filled)' },
|
||||
{ name: 'logins', label: 'Logins', color: 'gray.5' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function Recents() {
|
||||
const { data, isLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} height={350} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (data?.length)
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
{data!.map((file, i) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
|
||||
<DashboardFile file={file} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<Text size='sm' c='dimmed'>
|
||||
You have no recent files. The last three files you uploaded will appear here.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
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';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '.';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '.';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
@@ -38,7 +37,7 @@ export default function PendingFilesModal({
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
setModals: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
@@ -72,7 +71,7 @@ export default function PendingFilesModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Modal opened={modals.pending} onClose={() => setModals({ pending: false })} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFilesOff, IconStarsFilled, IconStarsOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import {
|
||||
IconClipboardListFilled,
|
||||
IconFilesOff,
|
||||
IconStarsFilled,
|
||||
IconStarsOff,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]) => void) {
|
||||
modals.openConfirmModal({
|
||||
@@ -130,3 +137,17 @@ export async function bulkFavorite(ids: string[], favorite: boolean) {
|
||||
onCancel: modals.closeAll,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkCopyLinks(urls: string[]) {
|
||||
const links = urls.map((url) => getDomain(url)).join('\n');
|
||||
|
||||
await navigator.clipboard.writeText(links);
|
||||
|
||||
notifications.show({
|
||||
title: 'Copied links to clipboard',
|
||||
message: `Copied ${urls.length} link${urls.length === 1 ? '' : 's'} to clipboard`,
|
||||
color: 'green',
|
||||
icon: <IconClipboardListFilled size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
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 {
|
||||
@@ -10,7 +9,8 @@ import {
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { parseAsBoolean, useQueryStates } from 'nuqs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from './tags/TagsModal';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
@@ -24,48 +24,21 @@ export type DashboardFilesModals = {
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
export function useModals() {
|
||||
return useQueryStates({
|
||||
table: parseAsBoolean.withDefault(false),
|
||||
idSearch: parseAsBoolean.withDefault(false),
|
||||
tags: parseAsBoolean.withDefault(false),
|
||||
pending: parseAsBoolean.withDefault(false),
|
||||
});
|
||||
}
|
||||
|
||||
export type DashboardFilesModalsUpdate = ReturnType<typeof useModals>[1];
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
|
||||
|
||||
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 });
|
||||
};
|
||||
const [modals, setModals] = useModals();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -92,12 +65,15 @@ export default function DashboardFiles() {
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconTags size='1rem' />} onClick={() => setModals('tags', !modals.tags)}>
|
||||
<Menu.Item
|
||||
leftSection={<IconTags size='1rem' />}
|
||||
onClick={() => setModals({ tags: !modals.tags })}
|
||||
>
|
||||
Manage Tags
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileDots size='1rem' />}
|
||||
onClick={() => setModals('pending', !modals.pending)}
|
||||
onClick={() => setModals({ pending: !modals.pending })}
|
||||
>
|
||||
View Pending Files
|
||||
</Menu.Item>
|
||||
@@ -106,13 +82,13 @@ export default function DashboardFiles() {
|
||||
<Menu.Label>Table Options</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconGridPatternFilled size='1rem' />}
|
||||
onClick={() => setModals('idSearch', !modals.idSearch)}
|
||||
onClick={() => setModals({ idSearch: !modals.idSearch })}
|
||||
>
|
||||
Search by ID
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTableOptions size='1rem' />}
|
||||
onClick={() => setModals('table', !modals.table)}
|
||||
onClick={() => setModals({ table: !modals.table })}
|
||||
>
|
||||
Table Options
|
||||
</Menu.Item>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function CreateTagModal({ open, onClose }: { open: boolean; onClo
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm'>
|
||||
<Button type='submit' variant='outline'>
|
||||
Create tag
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function EditTagModal({
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' disabled={!form.isDirty}>
|
||||
<Button type='submit' variant='outline' disabled={!form.isDirty}>
|
||||
Edit tag
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -2,13 +2,12 @@ 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/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';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
import EditTagModal from './EditTagModal';
|
||||
import TagPill from './TagPill';
|
||||
@@ -18,7 +17,7 @@ export default function TagsModals({
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
setModals: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
@@ -55,7 +54,7 @@ export default function TagsModals({
|
||||
|
||||
<Modal
|
||||
opened={modals.tags}
|
||||
onClose={() => setModals('tags', false)}
|
||||
onClose={() => setModals({ tags: false })}
|
||||
title={
|
||||
<Group>
|
||||
<Title>Tags</Title>
|
||||
|
||||
@@ -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,3 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -13,17 +14,19 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
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];
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45, 60];
|
||||
|
||||
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
@@ -37,8 +40,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 +77,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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useClipboard, useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
@@ -40,25 +40,25 @@ import {
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import { bulkCopyLinks, 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 };
|
||||
action: { field: string; query: string };
|
||||
};
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50, 70, 100];
|
||||
|
||||
function SearchFilter({
|
||||
setSearchField,
|
||||
@@ -187,7 +187,7 @@ export default function FileTable({
|
||||
id?: string;
|
||||
folderId?: string;
|
||||
modals?: Partial<DashboardFilesModals>;
|
||||
setModals?: UpdateFn<DashboardFilesModals>;
|
||||
setModals?: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -201,8 +201,8 @@ export default function FileTable({
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(20);
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(20));
|
||||
const [sort, setSort] = useState<
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
@@ -231,7 +231,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 +266,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,32 +379,28 @@ 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 && (
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals({ table: false })} />
|
||||
)}
|
||||
|
||||
<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
|
||||
Selections are saved across page changes. Currently selected <b>{selectedFiles.length}</b> file
|
||||
{selectedFiles.length > 1 ? 's' : ''}.
|
||||
</Text>
|
||||
|
||||
<Group>
|
||||
@@ -415,7 +416,7 @@ export default function FileTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
|
||||
Delete files
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -429,8 +430,15 @@ export default function FileTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
|
||||
{selectedFiles.length > 1 ? 's' : ''}
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} files
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
onClick={() => bulkCopyLinks(selectedFiles.map((x) => x.url!))}
|
||||
>
|
||||
Copy file links
|
||||
</Button>
|
||||
|
||||
{!id && (
|
||||
@@ -487,7 +495,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'
|
||||
@@ -508,7 +516,6 @@ export default function FileTable({
|
||||
{/*@ts-ignore*/}
|
||||
<DataTable
|
||||
mt='xs'
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
@@ -587,7 +594,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,3 @@
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiPagination } from '../files/useApiPagination';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const [page, setPage] = useQueryState('fpage', 1);
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
favorite: true,
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function FolderCard({
|
||||
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
|
||||
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
|
||||
<Card withBorder shadow='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
|
||||
<Group justify='space-between'>
|
||||
<Group gap='xs'>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function DashboardFolders() {
|
||||
data: currentFolder,
|
||||
error: currentFolderError,
|
||||
isLoading,
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}?noincl=true` : null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -158,7 +158,7 @@ export default function DashboardFolders() {
|
||||
{...form.getInputProps('isPublic', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' leftSection={<IconFolderPlus size='1rem' />}>
|
||||
<Button type='submit' variant='outline' leftSection={<IconFolderPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
|
||||
{filesOpen ? '▼' : '▶'} {currentFolder.name}'s files{' '}
|
||||
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
|
||||
</Text>
|
||||
<Collapse in={filesOpen}>
|
||||
<Collapse expanded={filesOpen}>
|
||||
{view === 'grid' ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<FilesGridView folderId={currentFolderId} />
|
||||
|
||||
@@ -3,6 +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 { openWarningModal } from '@/lib/client/warningModal';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -10,7 +11,7 @@ import { IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade';
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade' | 'cascade-files';
|
||||
|
||||
export default function DeleteFolderModal({
|
||||
folder,
|
||||
@@ -47,29 +48,9 @@ export default function DeleteFolderModal({
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const performDelete = async (body: any) => {
|
||||
setLoading(true);
|
||||
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
@@ -95,6 +76,46 @@ export default function DeleteFolderModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContent && (childrenAction === 'cascade' || childrenAction === 'cascade-files')) {
|
||||
openWarningModal({
|
||||
confirmLabel: `Delete '${folder.name}' and ${childrenAction === 'cascade-files' ? 'all subfolders and files' : 'all subfolders'}?`,
|
||||
message: (
|
||||
<Stack gap='sm'>
|
||||
<Text c='red' fw={500}>
|
||||
{childrenAction === 'cascade-files'
|
||||
? 'All subfolders and every file within them will be permanently deleted from storage. This action cannot be undone.'
|
||||
: 'All subfolders will be permanently deleted (files will be moved to the root). This action cannot be undone.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
onConfirm: () => performDelete(body),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performDelete(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
|
||||
<Stack gap='sm'>
|
||||
@@ -118,7 +139,15 @@ export default function DeleteFolderModal({
|
||||
value='cascade'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete everything (cascade delete)
|
||||
Delete subfolders (files moved to root)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
value='cascade-files'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete subfolders and their files (cascade delete)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
@@ -171,8 +200,15 @@ export default function DeleteFolderModal({
|
||||
|
||||
{childrenAction === 'cascade' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all contents within this folder (subfolders will be
|
||||
deleted, and files will be unlinked from their folders).
|
||||
Warning: This will permanently delete all subfolders within this folder. Files will be
|
||||
unlinked from their folders and moved to the root.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{childrenAction === 'cascade-files' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all subfolders within this folder, along with every file
|
||||
contained in them. The files will be removed from storage and cannot be recovered.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function FolderGridView({
|
||||
currentFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}) {
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}&noincl=true` : '?root=true&noincl=true';
|
||||
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function FolderTableView({
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}&noincl=true` : '?root=true&noincl=true';
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
@@ -168,7 +168,6 @@ export default function FolderTableView({
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function InviteCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card withBorder shadow='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group justify='space-between'>
|
||||
<Anchor href={`/invite/${invite.code}`} target='_blank' fw={400}>
|
||||
|
||||
@@ -96,13 +96,7 @@ export default function DashboardInvites() {
|
||||
{...form.getInputProps('maxUses')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
fullWidth
|
||||
radius='sm'
|
||||
leftSection={<IconPlus size='1rem' />}
|
||||
>
|
||||
<Button type='submit' variant='outline' fullWidth leftSection={<IconPlus size='1rem' />}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function InviteTableView() {
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, Center, PinInput, Text, Group, Button } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconX, IconShieldQuestion } from '@tabler/icons-react';
|
||||
|
||||
export default function TotpModal({
|
||||
@@ -12,34 +13,43 @@ export default function TotpModal({
|
||||
onVerify: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const mobile = useMediaQuery('(max-width: 600px)');
|
||||
|
||||
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={mobile ? 'md' : '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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph'));
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
const StatsCards = lazy(() => import('./parts/StatsCards'));
|
||||
@@ -133,16 +133,16 @@ export default function DashboardMetrics() {
|
||||
<StatsCardsSkeleton />
|
||||
<StatsTablesSkeleton />
|
||||
</div>
|
||||
) : data?.length ? (
|
||||
) : data?.points.length ? (
|
||||
<div>
|
||||
<StatsCards data={data} />
|
||||
<StatsTables data={data} />
|
||||
<StatsCards points={data.points} />
|
||||
<StatsTables latest={data.latest} />
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
|
||||
<FilesUrlsCountGraph metrics={data} />
|
||||
<ViewsGraph metrics={data} />
|
||||
<FilesUrlsCountGraph points={data.points} />
|
||||
<ViewsGraph points={data.points} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<StorageGraph metrics={data} />
|
||||
<StorageGraph points={data.points} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function FilesUrlsCountGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function FilesUrlsCountGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
files: point.files,
|
||||
urls: point.urls,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Title order={3}>Count</Title>
|
||||
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
files: metric.data.files,
|
||||
urls: metric.data.urls,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'files',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
@@ -21,8 +21,8 @@ function StatCard({
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
first: number;
|
||||
last: number;
|
||||
first: number | bigint;
|
||||
last: number | bigint;
|
||||
Icon: TablerIcon;
|
||||
formatter?: (value: number) => string;
|
||||
}) {
|
||||
@@ -35,9 +35,9 @@ function StatCard({
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Group justify='space-between'>
|
||||
<Text size='xl' fw='bolder'>
|
||||
<Text size='xl' fw={900}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -45,8 +45,8 @@ function StatCard({
|
||||
</Group>
|
||||
|
||||
<Group justify='flex-start' gap='xs'>
|
||||
<Text size='xl' fw='bolder'>
|
||||
{formatter ? formatter(first) : first}
|
||||
<Text size='lg' fw={600}>
|
||||
{formatter ? formatter(Number(first)) : first}
|
||||
</Text>
|
||||
|
||||
<Paper
|
||||
@@ -54,7 +54,6 @@ function StatCard({
|
||||
py={2}
|
||||
pl={5}
|
||||
pr={8}
|
||||
radius='sm'
|
||||
display='flex'
|
||||
bg={rgba(`var(--mantine-color-${color}-6)`, 0.25)}
|
||||
>
|
||||
@@ -87,14 +86,11 @@ export function StatsCardsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsCards({ data }: { data: Metric[] }) {
|
||||
if (!data.length) return null;
|
||||
const sortedMetrics = data.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
export default function StatsCards({ points }: { points: MetricsPoint[] }) {
|
||||
if (!points.length) return null;
|
||||
|
||||
const recent = sortedMetrics[0];
|
||||
const last = sortedMetrics[sortedMetrics.length - 1];
|
||||
const recent = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
@@ -105,28 +101,18 @@ export default function StatsCards({ data }: { data: Metric[] }) {
|
||||
}}
|
||||
mb='sm'
|
||||
>
|
||||
<StatCard title='Files' first={recent.data.files} last={last.data.files} Icon={IconFiles} />
|
||||
<StatCard title='URLs' first={recent.data.urls} last={last.data.urls} Icon={IconLink} />
|
||||
<StatCard title='Files' first={recent.files} last={last.files} Icon={IconFiles} />
|
||||
<StatCard title='URLs' first={recent.urls} last={last.urls} Icon={IconLink} />
|
||||
<StatCard
|
||||
title='Storage Used'
|
||||
first={recent.data.storage}
|
||||
last={last.data.storage}
|
||||
first={recent.storage}
|
||||
last={last.storage}
|
||||
formatter={bytes}
|
||||
Icon={IconDatabase}
|
||||
/>
|
||||
<StatCard title='Users' first={recent.data.users} last={last.data.users} Icon={IconUsers} />
|
||||
<StatCard
|
||||
title='File Views'
|
||||
first={recent.data.fileViews}
|
||||
last={last.data.fileViews}
|
||||
Icon={IconEyeFilled}
|
||||
/>
|
||||
<StatCard
|
||||
title='URL Views'
|
||||
first={recent.data.urlViews}
|
||||
last={last.data.urlViews}
|
||||
Icon={IconEyeFilled}
|
||||
/>
|
||||
<StatCard title='Users' first={recent.users} last={last.users} Icon={IconUsers} />
|
||||
<StatCard title='File Views' first={recent.fileViews} last={last.fileViews} Icon={IconEyeFilled} />
|
||||
<StatCard title='URL Views' first={recent.urlViews} last={last.urlViews} Icon={IconEyeFilled} />
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function StatsTablesSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='sm' withBorder>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -42,7 +42,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder mah={500}>
|
||||
<Paper withBorder mah={500} radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -65,7 +65,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<Paper withBorder radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
@@ -86,7 +86,7 @@ export function StatsTablesSkeleton() {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Skeleton height={500} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
@@ -94,18 +94,18 @@ export function StatsTablesSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
if (!data.length) return null;
|
||||
export default function StatsTables({ latest }: { latest: Metric | null }) {
|
||||
if (!latest) return null;
|
||||
|
||||
const recent = data[0]; // it is sorted by desc so 0 is the first one.
|
||||
const recent = latest;
|
||||
|
||||
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -131,8 +131,8 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder mah={500}>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder mah={500}>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -156,8 +156,8 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -179,7 +179,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<TypesPieChart metric={recent} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { LineChart, ChartTooltip } from '@mantine/charts';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function StorageGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function StorageGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
storage: point.storage,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm' mt='md'>
|
||||
<Paper radius='md' withBorder p='sm' mt='md'>
|
||||
<Title order={3} mb='sm'>
|
||||
Storage Used
|
||||
</Title>
|
||||
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
storage: metric.data.storage,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'storage',
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { ChartTooltip, LineChart } from '@mantine/charts';
|
||||
import { Paper, Title } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { defaultChartProps } from '../statsHelpers';
|
||||
|
||||
export default function ViewsGraph({ metrics }: { metrics: Metric[] }) {
|
||||
const sortedMetrics = metrics.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
export default function ViewsGraph({ points }: { points: MetricsPoint[] }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
points
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((point) => ({
|
||||
date: new Date(point.createdAt).getTime(),
|
||||
files: point.fileViews,
|
||||
urls: point.urlViews,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper radius='sm' withBorder p='sm'>
|
||||
<Paper radius='md' withBorder p='sm'>
|
||||
<Title order={3}>Views</Title>
|
||||
<LineChart
|
||||
data={sortedMetrics.map((metric) => ({
|
||||
date: new Date(metric.createdAt).getTime(),
|
||||
files: metric.data.fileViews,
|
||||
urls: metric.data.urlViews,
|
||||
}))}
|
||||
data={data}
|
||||
series={[
|
||||
{
|
||||
name: 'files',
|
||||
|
||||
@@ -13,7 +13,10 @@ export const defaultChartProps: Partial<LineChartProps> & { dataKey: string } =
|
||||
dataKey: 'date',
|
||||
};
|
||||
|
||||
export function percentChange(a: number, b: number): [string, string] {
|
||||
export function percentChange(a: number | bigint, b: number | bigint): [string, string] {
|
||||
if (typeof a === 'bigint') a = Number(a);
|
||||
if (typeof b === 'bigint') b = Number(b);
|
||||
|
||||
const change = Math.round(((b - a) / a) * 100);
|
||||
const color = change > 0 ? 'green' : change < 0 ? 'red' : 'gray';
|
||||
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+1
-1
@@ -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,4 +1,5 @@
|
||||
import { Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import ClearTempButton from './actions/ClearTempButton';
|
||||
import ClearZerosButton from './actions/ClearZerosButton';
|
||||
import GenThumbsButton from './actions/GenThumbsButton';
|
||||
@@ -10,6 +11,7 @@ const ACTIONS = [
|
||||
name: 'Import/Export Data',
|
||||
desc: 'Allows you to import or export server data and configurations.',
|
||||
Component: ImportExport,
|
||||
superAdminOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'Clear Temporary Files',
|
||||
@@ -34,6 +36,9 @@ const ACTIONS = [
|
||||
];
|
||||
|
||||
export default function DashboardServerActions() {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const actions = ACTIONS.filter((action) => !action.superAdminOnly || user?.role === 'SUPERADMIN');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
@@ -43,7 +48,7 @@ export default function DashboardServerActions() {
|
||||
Useful tools and scripts for server management.
|
||||
</Text>
|
||||
<Stack gap='xs' my='sm'>
|
||||
{ACTIONS.map(({ name, desc, Component }) => (
|
||||
{actions.map(({ name, desc, Component }) => (
|
||||
<Paper withBorder p='sm' key={name}>
|
||||
<Group gap='md'>
|
||||
<Component />
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import { LinksList } from '@/components/LinksList';
|
||||
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,
|
||||
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,116 +53,289 @@ 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) => ({
|
||||
label: setting.name,
|
||||
description: setting.desc,
|
||||
href: `/dashboard/admin/settings/${setting.key}`,
|
||||
icon: setting.Icon ? setting.Icon : IconAdjustmentsHorizontalFilled,
|
||||
}));
|
||||
|
||||
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}"]`);
|
||||
const parent = input?.parentElement?.parentElement;
|
||||
if (!input || !parent) return;
|
||||
const toSettingSection = useCallback((settingKey: string) => {
|
||||
const normalizedSetting = settingKey.toLowerCase();
|
||||
const matched = SETTINGS_PART_KEYS.find((key) => normalizedSetting.startsWith(key.toLowerCase()));
|
||||
|
||||
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 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>
|
||||
|
||||
<Stack mt='md' gap='md'>
|
||||
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<Box my='sm'>
|
||||
<LinksList links={SETTINGS_EXTERNAL_LINKS} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,47 @@
|
||||
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 { checkCommaArray, 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,
|
||||
filesDisabledTypes: data.settings.filesDisabledTypes.join(', '),
|
||||
filesDisabledTypesDefault: data.settings.filesDisabledTypesDefault,
|
||||
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,
|
||||
filesExtensionlessUrls: data.settings.filesExtensionlessUrls,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
disabled: data.tampered.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -73,167 +58,152 @@ export default function Files({
|
||||
values.filesMaxExpiration = values.filesMaxExpiration.trim();
|
||||
}
|
||||
|
||||
if (!values.filesDisabledExtensions) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
} else if (
|
||||
values.filesDisabledExtensions &&
|
||||
typeof values.filesDisabledExtensions === 'string' &&
|
||||
values.filesDisabledExtensions.trim() === ''
|
||||
) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
if (values.filesDisabledTypesDefault?.trim() === '' || !values.filesDisabledTypesDefault) {
|
||||
values.filesDisabledTypesDefault = null;
|
||||
} else {
|
||||
if (!Array.isArray(values.filesDisabledExtensions))
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = values.filesDisabledExtensions
|
||||
.split(',')
|
||||
.map((ext) => ext.trim())
|
||||
.filter((ext) => ext !== '');
|
||||
values.filesDisabledTypesDefault = values.filesDisabledTypesDefault.trim();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = checkCommaArray(values.filesDisabledExtensions);
|
||||
// @ts-ignore
|
||||
values.filesDisabledTypes = checkCommaArray(values.filesDisabledTypes);
|
||||
|
||||
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>
|
||||
<TextInput
|
||||
label='Disabled Types'
|
||||
description='Mimetypes to disable, separated by commas. It is recommended to have the Assume Mimetypes setting enabled if you are disabling mimetypes, as this will also block files with the corresponding extensions.'
|
||||
placeholder='text/html, application/javascript'
|
||||
{...form.getInputProps('filesDisabledTypes')}
|
||||
/>
|
||||
|
||||
<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='Default MIME for Disabled Types'
|
||||
description='The default MIME type to use for disabled types. Leave blank to completely block disabled types.'
|
||||
placeholder='application/octet-stream'
|
||||
{...form.getInputProps('filesDisabledTypesDefault')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label='Length'
|
||||
description='The length of the file name (for randomly generated names).'
|
||||
min={1}
|
||||
max={64}
|
||||
{...form.getInputProps('filesLength')}
|
||||
/>
|
||||
<Switch
|
||||
label='Remove GPS Metadata'
|
||||
description='Remove GPS metadata from files.'
|
||||
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Assume Mimetypes'
|
||||
description='Assume the mimetype of a file for its extension.'
|
||||
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
|
||||
/>
|
||||
<Switch
|
||||
label='Extensionless URLs'
|
||||
description='Allow file links without the extension (e.g. /u/uuid instead of /u/uuid.png). Upload responses still include the extension.'
|
||||
{...form.getInputProps('filesExtensionlessUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Remove GPS Metadata'
|
||||
description='Remove GPS metadata from files.'
|
||||
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
|
||||
/>
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for file uploads. Requires a server restart.'
|
||||
placeholder='/u'
|
||||
{...form.getInputProps('filesRoute')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Default Format'
|
||||
description='The default format to use for file names.'
|
||||
placeholder='random'
|
||||
data={['random', 'date', 'uuid', 'name', 'gfycat']}
|
||||
{...form.getInputProps('filesDefaultFormat')}
|
||||
/>
|
||||
<NumberInput
|
||||
label='Length'
|
||||
description='The length of the file name (for randomly generated names).'
|
||||
min={1}
|
||||
max={64}
|
||||
{...form.getInputProps('filesLength')}
|
||||
/>
|
||||
|
||||
<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')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
<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>
|
||||
|
||||
<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 "OAuth Registration" 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 "OAuth Registration" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,22 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export function checkCommaArray(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
|
||||
if (value && typeof value === 'string' && value.trim() === '') return [];
|
||||
|
||||
if (!Array.isArray(value) && typeof value === 'string')
|
||||
return value
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x !== '');
|
||||
|
||||
if (Array.isArray(value)) return value.map((x) => String(x).trim()).filter((x) => x !== '');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<typeof useForm<any>>) {
|
||||
return async (values: unknown) => {
|
||||
const { data, error } = await fetchApi<Response['/api/server/settings']>(
|
||||
@@ -42,7 +58,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');
|
||||
}
|
||||
@@ -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' />}
|
||||
/>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user