Compare commits

...

78 Commits

Author SHA1 Message Date
diced 7bd477d7aa feat(v4.6.3): version 2026-06-25 20:05:04 -07:00
diced 26a16137b2 fix: build errors 2026-06-25 20:04:51 -07:00
diced d07c1fa99b feat: add copy links to multiple selections 2026-06-25 19:43:03 -07:00
Noah ae80d228b5 feat: extensionless file urls (#1109)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-25 10:41:01 -07:00
Noah b5c39bed47 fix: folders page loading with large file counts (#1085)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-24 22:07:07 -07:00
diced ca9bd41244 chore: update flake 2026-06-24 22:02:23 -07:00
diced 93f0210605 feat: add disk status and admin dashboard 2026-06-22 17:54:51 -07:00
diced 4329dc7cdf fix: cascade files & folders(#1106, #1042) 2026-06-22 15:42:43 -07:00
diced ae6a6536f9 fix: separate option to disable text view (#1099) 2026-06-22 15:42:43 -07:00
dicedtomato 0fc7e7a06f Merge commit from fork 2026-06-19 22:52:08 -07:00
zorex 18bc86c261 feat: add right-click context menu on file cards (#1105)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-14 13:16:13 -07:00
diced 2e210da549 fix: #1102 2026-06-14 13:10:56 -07:00
diced 3639ec0dc2 fix: #1104 2026-06-14 12:59:30 -07:00
diced 754c54542e feat(v4.6.2): version 2026-06-02 19:35:43 -07:00
dicedtomato 19b7e6f938 Merge commit from fork 2026-06-02 19:33:54 -07:00
dicedtomato cd22a8915e Merge commit from fork 2026-06-02 19:30:34 -07:00
zorex 97a75c0f84 feat: add user activity chart on home page (#1092)
Co-authored-by: dicedtomato <git@diced.sh>

also adds settings to hide the recents, activity graph, and file type table
2026-06-02 19:19:22 -07:00
diced d5e2bc3ec2 fix: build errors 2026-06-01 22:06:38 -07:00
diced 6f90339f17 fix: inconsistent border-radius 2026-06-01 21:41:54 -07:00
diced 833f8a30cc fix: optimize stats page 2026-06-01 18:46:18 -07:00
diced e6382b3881 fix: block thumbnails on files w/ passwords 2026-06-01 17:25:31 -07:00
diced e3789446c2 feat: reference folders by their name 2026-05-24 18:58:06 -07:00
diced 6c94abc73b feat: mimetype overhauls 2026-05-20 10:11:10 -07:00
diced 8fb21988a7 feat(v4.6.1): version 2026-05-20 09:01:10 -07:00
diced 72fc8116d4 fix: build errors 2026-05-20 09:00:19 -07:00
dicedtomato 177febf305 Merge commit from fork 2026-05-20 08:54:59 -07:00
diced a8c65c19b4 refactor: nuqs 2026-05-15 23:03:11 -07:00
diced 3fc3dcd1ed fix: folder files not showing anything 2026-05-15 14:48:00 -07:00
zorex 0c52b48c05 feat(view): media-only OpenGraph previews when embeds disabled (#1090)
Add embedMediaOnly view setting: emit og:image/og:video (and related tags)
without custom title, description, or site name for Discord-style unfurls.
Canonical og:url uses the view page URL. API and dashboard settings updated.

Co-authored-by: dicedtomato <git@diced.sh>
2026-05-15 12:57:58 -07:00
diced 5c386a792e fix: add pnpm-workspace.yml to dockerfile 2026-05-14 23:59:00 -07:00
diced c4f8aa52a4 chore: update node + update packages 2026-05-14 23:55:45 -07:00
diced 5c0097fed5 feat: option to turn on automute 2026-05-14 23:09:01 -07:00
diced 6ebd8f68f9 fix: #1083 2026-05-14 22:54:13 -07:00
diced f6188cf15b fix: #1089 2026-05-13 16:03:29 -07:00
diced c9cbc2322f fix: #1087 2026-05-12 19:33:49 -07:00
diced ae11a29057 feat(v4.6.0): version 2026-05-11 16:24:14 -07:00
diced 4776d9e85f fix: totp 2fa QOL #1073 2026-05-11 16:18:37 -07:00
diced 41b63e6f25 fix: #1081 2026-05-11 16:07:41 -07:00
diced 24b332c23e fix: build errors 2026-05-09 17:55:05 -07:00
diced 3fd9154e57 fix: #1072 2026-05-09 17:54:24 -07:00
diced 3f71769ec6 fix: #1069 2026-05-09 17:18:14 -07:00
diced a99b0f4f1d refactor: use access tokens for file/url passwords
no longer using cookies, since they are buggy and weird with caching
using a access token that "expires" in 10 minutes
2026-05-04 18:21:53 -07:00
diced 15f5279ddb fix: perf improvements 2026-04-27 17:51:00 -07:00
diced 87a2dfbda6 fix: thin text files 2026-04-27 16:47:57 -07:00
diced c7d2b3010f fix: add tags/folder editing to new viewer 2026-04-27 16:47:44 -07:00
diced 5119806147 fix: build errors 2026-04-25 19:10:14 -07:00
diced 33104ce1be fix: rework passwd protected file logic 2026-04-25 19:07:39 -07:00
diced eeb1c51fb2 fix: build errors 2026-04-24 22:11:17 -07:00
diced 756dee6bba fix: impl serverside pagination folders (#1052) 2026-04-24 22:06:46 -07:00
Tomasz Kołodziej a0907e8791 fix: return 404 status on not-found SPA fallback (#1061) (#1063)
The non-API branch of setNotFoundHandler served the Next.js index shell
without setting the status code, so clients saw HTTP 200 with a Not-Found
UI. This broke monitoring, ingress proxy_intercept_errors rules,
crawlers, and curl -f. Set status 404 explicitly before serving the
index.

Closes #1061

Co-authored-by: dicedtomato <git@diced.sh>
2026-04-23 23:29:10 -07:00
diced 5a58abeb51 fix: #1062 2026-04-23 23:26:35 -07:00
diced 72d8c693c7 chore: update packages 2026-04-19 21:58:06 -07:00
diced 7caf314ce1 fix: session serialization errors 2026-04-19 21:49:19 -07:00
diced 677927b4a6 fix: blob urls not persisting 2026-04-18 22:43:32 -07:00
diced ac0b718f77 fix: date format #1056 2026-04-18 22:42:16 -07:00
diced db3a1b88ad fix: passkey fields validation (#1047) 2026-04-18 22:35:09 -07:00
diced a97cf32682 feat: make 'fullscreen' viewer default 2026-04-18 16:24:41 -07:00
diced 7e2b4ed1bb fix: build errors 2026-04-18 16:24:14 -07:00
diced a7fdf5afed fix: fix issues and polish new fullscreen viewer 2026-04-18 16:08:42 -07:00
diced db8adcc768 fix: #384 again 2026-04-18 16:07:59 -07:00
diced 135cf1982a fix: build errors 2026-04-15 23:23:56 -07:00
diced 9925300e9d feat: add details drawer for fullscreen viewer 2026-04-15 23:19:56 -07:00
diced 3bf125b4b4 fix: leftover import 2026-04-15 00:16:47 -07:00
diced dc9abe4383 feat: new file viewer 2026-04-15 00:15:49 -07:00
diced 1ccbc878f8 fix: add arrow key control (#1049) 2026-04-14 17:39:10 -07:00
diced aa43f66570 fix: add missing metrics interval 2026-04-13 21:50:52 -07:00
diced 7e3bba5e55 feat: overhaul oauth + PKCE for OIDC
refactored a lot of oauth stuff, so there may be bugs
2026-04-13 21:33:11 -07:00
diced 82e1fe4824 fix: naming 2026-04-12 22:51:41 -07:00
diced 818d3f5518 refactor: clean up main file
split into different files for maintainability
2026-04-11 22:11:13 -07:00
diced 23c131f45a feat: add unix sockets binding support 2026-04-11 21:28:22 -07:00
diced 3c5fd8effe feat: file nav buttons (#1046)
<- and -> on file modal
2026-04-11 17:49:12 -07:00
diced 377e3dc73d fix: throw errors on uploads 2026-04-10 22:44:59 -07:00
diced f75457da1c fix: various md rendering errors 2026-04-10 22:12:36 -07:00
diced d6b0ba3b16 fix: better error handling for uploading 2026-04-09 14:51:43 -07:00
diced 1a1bc46667 fix: delete old cached views 2026-04-09 14:51:27 -07:00
diced eb1c39933a fix: #1040 2026-04-08 18:26:57 -07:00
diced b070dbf432 fix: build errors 2026-04-07 21:49:43 -07:00
diced 8af5ad05d6 fix: add register link on login page
other minor fixes as well incl.
2026-04-07 21:45:03 -07:00
191 changed files with 7210 additions and 6330 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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',
Generated
+65 -561
View File
@@ -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": {
+38 -38
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.5.3",
"version": "4.6.3",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
@@ -22,27 +22,27 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1025.0",
"@aws-sdk/lib-storage": "3.1025.0",
"@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": "^9.0.1",
"@mantine/code-highlight": "^9.0.1",
"@mantine/core": "^9.0.1",
"@mantine/dates": "^9.0.1",
"@mantine/dropzone": "^9.0.1",
"@mantine/form": "^9.0.1",
"@mantine/hooks": "^9.0.1",
"@mantine/modals": "^9.0.1",
"@mantine/notifications": "^9.0.1",
"@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.5.2",
"@tabler/icons-react": "^3.41.1",
"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,34 +63,34 @@
"cross-env": "^10.1.0",
"dayjs": "^1.11.20",
"detect-browser": "^5.3.0",
"devalue": "^5.7.0",
"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.45",
"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.14.0",
"react-virtuoso": "^4.18.4",
"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",
"vite": "^8.0.5",
"vite": "^8.0.12",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
@@ -105,27 +105,27 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.0",
"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-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": "^6.0.2",
"typescript-eslint": "^8.58.0"
"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"
}
+1349 -2611
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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,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;
+6
View File
@@ -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)
@@ -295,6 +298,9 @@ model File {
folderId String?
thumbnail Thumbnail?
@@index([name])
@@index([folderId, createdAt])
}
model Thumbnail {
+5 -1
View File
@@ -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>
+23 -3
View File
@@ -33,7 +33,7 @@ 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';
@@ -43,7 +43,11 @@ export default function 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());
@@ -124,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} />;
@@ -135,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);
@@ -212,6 +222,7 @@ export default function Login() {
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
config.features.userRegistration,
) && (
<>
<Divider label='or' />
@@ -243,6 +254,15 @@ export default function Login() {
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
{config.features.userRegistration && (
<Text ta='center' mt='md'>
Don&apos;t have an account?{' '}
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
Register
</Anchor>
</Text>
)}
</>
)}
</Stack>
+10 -16
View File
@@ -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';
+68 -28
View File
@@ -1,6 +1,7 @@
import { useApiPagination } from '@/components/pages/files/useApiPagination';
import { type Response } from '@/lib/api/response';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
@@ -19,25 +20,34 @@ import {
Title,
} from '@mantine/core';
import { IconFolder, IconUpload } from '@tabler/icons-react';
import { lazy, Suspense, useMemo, useState } 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' />
@@ -64,10 +74,30 @@ 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[] = [];
@@ -85,25 +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 [perpage, setPerpage] = useState(15);
const [page, setPage] = useQueryState('page', 1);
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]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, folder.files?.length ?? 0);
const totalRecords = folder.files?.length ?? 0;
const cachedPages = Math.ceil(totalRecords / perpage);
const visible = useMemo(() => {
if (!folder.files) return [];
const start = (page - 1) * perpage;
return folder.files.slice(start, start + perpage);
}, [folder.files, page, perpage]);
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) => (
@@ -152,7 +187,7 @@ export function Component() {
</>
)}
{(visible.length ?? 0) > 0 && (
{(files.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
@@ -165,16 +200,16 @@ export function Component() {
}}
spacing='md'
>
{visible.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>
@@ -188,12 +223,16 @@ export function Component() {
value={perpage.toString()}
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
onChange={(value) => {
setPerpage(Number(value));
setPage(1);
setSearchParams((prev) => {
prev.set('perpage', value ?? '15');
prev.set('page', '1');
return prev;
});
}}
w={80}
size='xs'
variant='filled'
disabled={isLoading}
/>
<Pagination
@@ -203,6 +242,7 @@ export function Component() {
size='sm'
withControls
withEdges
disabled={isLoading}
/>
</Group>
</Group>
+4 -1
View File
@@ -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,
};
}
+14 -20
View File
@@ -1,5 +1,7 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
@@ -8,7 +10,6 @@ import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
@@ -24,9 +25,7 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
import { useTitle } from '@/lib/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,7 +42,7 @@ export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
const { file, password, code, user, host, metrics, filesRoute, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -51,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) => {
@@ -64,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');
}
@@ -105,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' />
@@ -143,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>
</>
) : (
<>
@@ -201,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' />
@@ -212,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' />
@@ -221,7 +215,7 @@ export default function ViewFileId() {
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
{user?.view!.content && (
<Typography>
+5 -3
View File
@@ -6,10 +6,11 @@ export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
token?: string | null;
}>();
if (!data) return null;
const { url, password } = data;
const { url, password, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -18,7 +19,7 @@ export default function ViewUrlId() {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -31,7 +32,8 @@ export default function ViewUrlId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
+1
View File
@@ -77,6 +77,7 @@ 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/actions', lazy: () => import('./pages/dashboard/admin/actions') },
+13 -21
View File
@@ -1,13 +1,11 @@
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { verifyAccessToken } from '@/lib/accessToken';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
import { FastifyRequest } from 'fastify';
import { createRoutes } from './routes';
export async function render(
{
@@ -17,13 +15,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -52,31 +48,27 @@ export async function render(
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const token = req.query.token;
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
token: valid ? token : null,
};
delete (data.url as any).password;
const routes = createRoutes(themes, defaultTheme);
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
if (!valid) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
+68 -67
View File
@@ -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)}`,
};
}
+8
View File
@@ -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);
}
+11 -5
View File
@@ -121,17 +121,23 @@ 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.startsWith('/dashboard/admin/settings'),
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
label: name,
icon,
active: (path: string) => path === url,
href: url,
links: SETTINGS_EXTERNAL_LINKS.map(({ label, href, icon: Icon }) => ({
label,
icon: <Icon size='1rem' />,
active: (path: string) => path === href,
href,
})),
},
{
+37
View File
@@ -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>
);
}
-1
View File
@@ -74,7 +74,6 @@ export default function ThemeProvider({
forceColorScheme={theme.colorScheme as unknown as any}
theme={createTheme({
...themeComponents(theme),
defaultRadius: 'md',
})}
>
{children}
+91 -87
View File
@@ -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'
+107 -6
View File
@@ -2,14 +2,16 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/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 &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={search}
onChange={(event) => {
folderCombobox.openDropdown();
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
{folders?.length === 0 && (
<Combobox.Empty>
You have no folders. Start typing to create a new folder for this file.
</Combobox.Empty>
)}
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
</Box>
</>
)}
</Stack>
)}
</Drawer>
<Box
onClick={() => setOpen(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 220ms cubic-bezier(0.33, 1, 0.68, 1)',
willChange: 'opacity',
}}
>
<Paper m={0} p={0} withBorder bdrs={0} style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Stack gap='sm' px='lg' py='sm' onClick={(e) => e.stopPropagation()}>
<Group justify='space-between' align='center' gap='sm' wrap='nowrap' visibleFrom='sm'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<Group gap='sm' wrap='nowrap' style={{ flexShrink: 0 }}>
{headerActionGroup}
<ActionButton Icon={IconX} tooltip='Close' onClick={() => setOpen(false)} />
</Group>
</Group>
<Stack gap='sm' hiddenFrom='sm'>
<Group justify='space-between' align='flex-start' gap='sm' wrap='nowrap'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<ActionButton
Icon={IconX}
tooltip='Close'
onClick={() => setOpen(false)}
style={{ flexShrink: 0 }}
/>
</Group>
<Group gap={0} wrap='nowrap'>
{headerActionGroup}
</Group>
</Stack>
</Stack>
</Paper>
<Box
ref={setScrollParent}
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
paddingTop: '1rem',
paddingBottom: '1rem',
marginLeft: '1rem',
marginRight: '1rem',
overflow: 'auto',
position: 'relative',
overscrollBehavior: 'contain',
}}
>
{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>
</>
);
}
+22 -6
View File
@@ -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>
</>
);
}
-339
View File
@@ -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]);
}
+329
View File
@@ -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 &quot;{folderSearch.trim()}&quot;
</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>
</>
);
}
+1 -1
View File
@@ -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);
+66
View File
@@ -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>
);
}
+120 -116
View File
@@ -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>
+23 -2
View File
@@ -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,
});
}
+21 -45
View File
@@ -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 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 && (
@@ -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,
+1 -1
View File
@@ -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'>
+2 -2
View File
@@ -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>
@@ -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 ?? []}
+1 -1
View File
@@ -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}>
+1 -7
View File
@@ -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 ?? []}
+35 -25
View File
@@ -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>
);
}
+7 -7
View File
@@ -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',
+4 -1
View File
@@ -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';
+6 -1
View File
@@ -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 />
+12 -33
View File
@@ -1,3 +1,4 @@
import { LinksList } from '@/components/LinksList';
import { Response } from '@/lib/api/response';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
@@ -9,8 +10,6 @@ import {
Collapse,
Group,
LoadingOverlay,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
@@ -176,9 +175,10 @@ const SETTINGS_COMPONENTS = {
export const SETTINGS_EXTERNAL_LINKS = Object.values(SETTINGS_COMPONENTS)
.filter((setting) => setting.component !== null)
.map((setting) => ({
name: setting.name,
url: `/dashboard/admin/settings/${setting.key}`,
icon: setting.Icon ? <setting.Icon size='1rem' /> : <IconAdjustmentsHorizontalFilled size='1rem' />,
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)
@@ -189,7 +189,7 @@ export default function DashboardServerSettings() {
const location = useLocation();
const navigate = useNavigate();
const { data, isLoading } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const { data } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const [opened, { toggle }] = useDisclosure(false);
const toSettingSection = useCallback((settingKey: string) => {
@@ -299,10 +299,11 @@ export default function DashboardServerSettings() {
{(data?.tampered?.length ?? 0) > 0 && (
<Collapse expanded={opened} transitionDuration={180}>
<Alert
my='md'
color='red'
title='Environment Variable Settings'
mb='md'
icon={<IconExclamationMark size='1rem' />}
variant='outline'
>
<Text size='sm' mb='xs'>
These settings are controlled by environment variables:
@@ -327,35 +328,13 @@ export default function DashboardServerSettings() {
</Box>
}
>
<SettingsComponent swr={{ data, isLoading }} />
<SettingsComponent />
</Suspense>
</Box>
) : (
<Stack mt='md' gap='md'>
{Object.entries(SETTINGS_COMPONENTS)
.filter(([key]) => key !== 'settings')
.map(([k, { key, Icon, name, desc }]) => (
<Anchor
key={k}
component={Link}
to={`/dashboard/admin/settings/${key}`}
style={{ textDecoration: 'none' }}
>
<Paper withBorder p='sm'>
<Group gap='md'>
<ActionIcon variant='filled' radius='md' size='xl'>
{Icon ? <Icon size='1.75rem' /> : <IconAdjustmentsHorizontalFilled size='1.75rem' />}
</ActionIcon>
<div>
<Title order={4}>{name}</Title>
<Text c='dimmed'>{desc}</Text>
</div>
</Group>
</Paper>
</Anchor>
))}
</Stack>
<Box my='sm'>
<LinksList links={SETTINGS_EXTERNAL_LINKS} />
</Box>
)}
</>
);
@@ -1,27 +1,34 @@
import { Response } from '@/lib/api/response';
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,49 +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 (
<>
<LoadingOverlay visible={isLoading} bdrs='md' />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<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')}
/>
</Stack>
<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>
</>
<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 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,55 +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 (
<>
<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' })}
/>
<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' })}
/>
<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' })}
/>
<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='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='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')}
/>
</Stack>
<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>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<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,56 +134,8 @@ 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 (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={formMain.onSubmit(onSubmitMain)}>
<TextInput
label='Webhook URL'
@@ -1,19 +1,24 @@
import { Response } from '@/lib/api/response';
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);
@@ -56,7 +61,7 @@ export default function Domains({
return (
<>
<LoadingOverlay visible={isLoading || submitting} />
<LoadingOverlay visible={submitting} />
<form onSubmit={addDomain}>
<TextInput
@@ -1,4 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Anchor,
Button,
@@ -13,191 +13,175 @@ 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';
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',
featuresThumbnailsInstantaneous: false,
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',
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous ?? false,
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 (
<>
<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' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { 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='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<Divider label='Thumbnails' />
<SimpleGrid 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' })}
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
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>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { 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')}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<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')}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<Divider label='Version Checking' />
<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='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' })}
/>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { 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>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<Divider label='Thumbnails' />
<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' })}
/>
<Switch
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>
<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>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,52 +1,47 @@
import { Response } from '@/lib/api/response';
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,
}),
});
@@ -63,165 +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 (
<>
<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' })}
/>
<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' })}
/>
<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')}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<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')}
/>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<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' })}
/>
<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='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<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='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<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='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<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>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<NumberInput
label='Max Files Per Upload'
description='The maximum number of files allowed per upload. Requires a server restart.'
min={1}
{...form.getInputProps('filesMaxFilesPerUpload')}
/>
</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 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,40 +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 (
<>
<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')}
/>
<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')}
/>
<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>
<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>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<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 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,41 +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 (
<>
<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' })}
/>
<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' })}
/>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</Stack>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<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 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 (
<>
<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' })}
/>
<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' })}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<Divider />
<Divider />
<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')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<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 {
Anchor,
Button,
@@ -13,45 +13,52 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
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,
}),
});
@@ -90,44 +97,8 @@ 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 (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
For OAuth to work, the &quot;OAuth Registration&quot; setting must be enabled in the{' '}
<Anchor component={Link} to='/dashboard/admin/settings/features'>
@@ -1,30 +1,37 @@
import { Response } from '@/lib/api/response';
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,
}),
@@ -48,23 +55,8 @@ 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 (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
Refresh the page after enabling PWA to see any changes.
</Text>
@@ -1,35 +1,42 @@
import { Response } from '@/lib/api/response';
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,
}),
@@ -55,22 +62,8 @@ 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 (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
All options require a restart to take effect.
</Text>
@@ -1,51 +1,43 @@
import { Response } from '@/lib/api/response';
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 (
<>
<LoadingOverlay visible={isLoading} />
<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>
@@ -86,6 +78,13 @@ export default function Tasks({
placeholder='1d'
{...form.getInputProps('tasksCleanThumbnailsInterval')}
/>
<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' />}>
@@ -1,66 +1,60 @@
import { Response } from '@/lib/api/response';
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 (
<>
<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')}
/>
<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')}
/>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</Stack>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<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 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.sh',
},
];
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,110 +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 (
<>
<LoadingOverlay visible={isLoading} />
<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)}>
<Stack gap='lg'>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
<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')}
/>
<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')}
/>
<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')}
/>
<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(defaultExternalLinks, null, 2)}
{...form.getInputProps('websiteExternalLinks')}
/>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<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')}
/>
<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')}
/>
<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')}
/>
<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')}
/>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<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')}
/>
<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')}
/>
<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>
</>
<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']>(
@@ -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' />}
/>
@@ -34,24 +34,67 @@ export default function SettingsDashboard() {
<Paper withBorder p='sm' h='100%'>
<Title order={2}>Dashboard Settings</Title>
<Text size='sm' c='dimmed' mt={3}>
These settings are saved in your browser.
These settings are saved automatically in your <b>browser.</b>
</Text>
<Stack gap='sm' my='xs'>
<Group grow>
<Stack>
<Switch
label='Disable Media Preview'
description='Disable previews of files in the dashboard. This is useful to save data as Zipline, by default, will load previews of files.'
description='Disable previews of files in the dashboard. This may help to save data and speed up the dashboard if you have a lot of media files, but it will also disable the file viewer and show a generic file icon instead of a preview for supported files.'
checked={settings.disableMediaPreview}
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
/>
<Switch
label='Mute video and audio previews'
description='When enabled, video and audio in the file viewer autoplay muted. Turning this off tries to play sound immediately. Browsers may block unmuted autoplay until you interact with the page.'
checked={settings.mediaAutoMuted}
onChange={(event) => update('mediaAutoMuted', event.currentTarget.checked)}
/>
<Switch
label='Warn on deletion'
description='Show a warning when deleting stuff. When this is disabled, files, urls, etc will be deleted with no prior warning! Folders, users, and bulk-transactions are exempt from this rule and will always warn you before deleting anything.'
checked={settings.warnDeletion}
onChange={(event) => update('warnDeletion', event.currentTarget.checked)}
/>
</Group>
<Switch
label='File navigation buttons'
description='Show previous/next on the right and left of the file viewer to easily navigate between files.'
checked={settings.fileNavButtons}
onChange={(event) => update('fileNavButtons', event.currentTarget.checked)}
/>
<Switch
label='Show recents'
description='Show recent uploads and logins on the home page.'
checked={settings.homeShowRecents}
onChange={(event) => update('homeShowRecents', event.currentTarget.checked)}
/>
<Switch
label='Show activity'
description='Show your recent activity as a graph on the home page.'
checked={settings.homeShowActivity}
onChange={(event) => update('homeShowActivity', event.currentTarget.checked)}
/>
<Switch
label='Show file types'
description='Show the file types table on the home page.'
checked={settings.homeShowTypes}
onChange={(event) => update('homeShowTypes', event.currentTarget.checked)}
/>
</Stack>
<Select
label='File viewer'
description='Choose which file viewer opens when you click a file.'
data={[
{ value: 'fullscreen', label: 'Fullscreen (beta)' },
{ value: 'default', label: 'Default (modal)' },
]}
value={settings.fileViewer}
onChange={(value) => update('fileViewer', (value as 'default' | 'fullscreen') ?? 'fullscreen')}
/>
<DomainSelect
label='Default Domain'
@@ -1,3 +1,4 @@
import type { User } from '@/lib/db/models/user';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/client/store/user';
@@ -27,7 +28,6 @@ import {
IconDeviceFloppy,
IconFileX,
} from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
@@ -40,26 +40,45 @@ const alignIcons: Record<string, React.ReactNode> = {
export default function SettingsFileView() {
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
if (!user) {
return (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>
<Text c='dimmed' mt='xs'>
Loading
</Text>
</Paper>
);
}
return <Form user={user} setUser={setUser} />;
}
function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
const form = useForm({
initialValues: {
enabled: user?.view.enabled ?? false,
content: user?.view.content ?? '',
embed: user?.view.embed ?? false,
embedTitle: user?.view.embedTitle ?? '',
embedDescription: user?.view.embedDescription ?? '',
embedSiteName: user?.view.embedSiteName ?? '',
embedColor: user?.view.embedColor ?? '',
align: user?.view.align ?? 'left',
showMimetype: user?.view.showMimetype ?? false,
showTags: user?.view.showTags ?? false,
showFolder: user?.view.showFolder ?? false,
enabled: user.view.enabled || false,
disableTextFiles: user.view.disableTextFiles || false,
content: user.view.content || '',
embed: user.view.embed || false,
embedMediaOnly: user.view.embedMediaOnly || false,
embedTitle: user.view.embedTitle || '',
embedDescription: user.view.embedDescription || '',
embedSiteName: user.view.embedSiteName || '',
embedColor: user.view.embedColor || '',
align: user.view.align || 'left',
showMimetype: user.view.showMimetype || false,
showTags: user.view.showTags || false,
showFolder: user.view.showFolder || false,
},
});
const onSubmit = async (values: typeof form.values) => {
const valuesTrimmed = {
const view = {
enabled: values.enabled,
disableTextFiles: values.disableTextFiles,
embed: values.embed,
embedMediaOnly: values.embed ? false : values.embedMediaOnly,
content: values.content.trim() || null,
embedTitle: values.embedTitle.trim() || null,
embedDescription: values.embedDescription.trim() || null,
@@ -72,7 +91,7 @@ export default function SettingsFileView() {
};
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
view: valuesTrimmed,
view,
});
if (!data && error) {
@@ -95,24 +114,6 @@ export default function SettingsFileView() {
});
};
useEffect(() => {
if (user) {
form.setValues({
enabled: user.view.enabled || false,
content: user.view.content || '',
embed: user.view.embed || false,
embedTitle: user.view.embedTitle || '',
embedDescription: user.view.embedDescription || '',
embedSiteName: user.view.embedSiteName || '',
embedColor: user.view.embedColor || '',
align: user.view.align || 'left',
showMimetype: user.view.showMimetype || false,
showTags: user.view.showTags || false,
showFolder: user.view.showFolder || false,
});
}
}, [user]);
return (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>
@@ -125,6 +126,12 @@ export default function SettingsFileView() {
<Stack gap='sm' mt='xs'>
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='sm' mb='xs'>
<Switch
label='Disable text files'
description='Disable viewing text files through view-routes. This has no effect on other file types and will work even if view-routes are disabled.'
{...form.getInputProps('disableTextFiles', { type: 'checkbox' })}
/>
<Switch
label='Enable View Routes'
description='Enable viewing files through customizable view-routes'
@@ -189,6 +196,20 @@ export default function SettingsFileView() {
disabled={!form.values.enabled}
my='xs'
{...form.getInputProps('embed', { type: 'checkbox' })}
onChange={(event) => {
form.getInputProps('embed', { type: 'checkbox' }).onChange(event);
if (event.currentTarget.checked) {
form.setFieldValue('embedMediaOnly', false);
}
}}
/>
<Switch
label='Media-only link preview'
description='When embeds are off, still add OpenGraph image/video tags so Discord and similar apps unfurl the media only (no custom title, description, or site name). The URL you paste stays in the message as plain text.'
disabled={!form.values.enabled || form.values.embed}
my='xs'
{...form.getInputProps('embedMediaOnly', { type: 'checkbox' })}
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='sm'>
@@ -1,3 +1,4 @@
import type { User } from '@/lib/db/models/user';
import { ApiError } from '@/lib/api/errors';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
@@ -25,29 +26,36 @@ import {
IconUser,
IconUserCancel,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { mutate } from 'swr';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
export default function SettingsUser() {
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
const { data: tokenPayload } = useSWR<Response['/api/user/token']>('/api/user/token');
if (!user) {
return (
<Paper withBorder p='sm'>
<Title order={2}>User</Title>
<Text c='dimmed' size='sm' mt='sm'>
Loading
</Text>
</Paper>
);
}
return <Form user={user} setUser={setUser} token={tokenPayload?.token ?? ''} />;
}
function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void; token: string }) {
const [tokenShown, setTokenShown] = useState(false);
const [token, setToken] = useState('');
useEffect(() => {
(async () => {
const { data } = await fetchApi<Response['/api/user/token']>('/api/user/token');
if (data) {
setToken(data.token || '');
}
})();
}, []);
const form = useForm({
initialValues: {
username: user?.username ?? '',
username: user.username,
password: '',
},
validate: {
@@ -61,7 +69,7 @@ export default function SettingsUser() {
password?: string;
} = {};
if (values.username !== user?.username) send['username'] = values.username.trim();
if (values.username !== user.username) send['username'] = values.username.trim();
if (values.password) send['password'] = values.password.trim();
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', send);
@@ -84,6 +92,7 @@ export default function SettingsUser() {
if (!data?.user) return;
mutate('/api/user');
mutate('/api/user/token');
setUser(data.user);
notifications.show({
message: 'User updated',
@@ -96,7 +105,7 @@ export default function SettingsUser() {
<Paper withBorder p='sm'>
<Title order={2}>User</Title>
<Text c='dimmed' size='sm' mb='sm'>
{user?.id}
{user.id}
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
@@ -134,7 +143,7 @@ export default function SettingsUser() {
leftSection={<IconAsteriskSimple size='1rem' />}
/>
<Button type='submit' mt='md' loading={!user} leftSection={<IconDeviceFloppy size='1rem' />}>
<Button type='submit' mt='md' leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
@@ -50,17 +50,18 @@ export default function DropzoneFile({
</Center>
</Paper>
</HoverCard.Target>
<HoverCard.Dropdown>
<Group maw={400}>
<ScrollArea>
<Box mah={250} maw={400}>
<HoverCard.Dropdown p='md' maw={480}>
<Stack gap='sm'>
<ScrollArea h={240} offsetScrollbars type='auto'>
<Box w='100%' miw={280} style={{ maxWidth: 'min(92vw, 26rem)' }}>
<DashboardFileType file={file} show />
</Box>
</ScrollArea>
<Stack justify='xs'>
<Stack gap='xs'>
<Text size='sm' c='dimmed'>
<b>{file.name}</b> {file.type || file.type === '' ? `(${file.type})` : ''}
<b>{file.name}</b>
{file.type ? ` (${file.type})` : ''}
</Text>
<Text size='sm' c='dimmed'>
{bytes(file.size)}
@@ -76,7 +77,7 @@ export default function DropzoneFile({
Remove
</Button>
</Stack>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
+23 -20
View File
@@ -66,23 +66,12 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
}, []);
const upload = async () => {
const toPartialFiles: File[] = files.filter(
(file) => config.chunks.enabled && file.size >= bytes(config.chunks.max),
);
if (toPartialFiles.length > 0) {
uploadPartialFiles(toPartialFiles, {
setFiles,
setLoading,
setProgress,
clipboard,
clearEphemeral,
options,
ephemeral,
config,
folder,
});
} else {
const size = aggSize();
const maxBytes = config.chunks.enabled && bytes(config.chunks.max);
const partialUploads: File[] = maxBytes ? files.filter((file) => file.size >= maxBytes) : [];
const normalUploads: File[] = maxBytes ? files.filter((file) => file.size < maxBytes) : files;
if (normalUploads.length > 0) {
const size = normalUploads.reduce((acc, file) => acc + file.size, 0);
if (size > bytes(config.files.maxFileSize)) {
notifications.show({
title: 'Upload may fail',
@@ -98,7 +87,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
});
}
await uploadFiles(files, {
await uploadFiles(normalUploads, {
setFiles,
setLoading,
setProgress,
@@ -109,6 +98,20 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
folder,
});
}
if (partialUploads.length > 0) {
await uploadPartialFiles(partialUploads, {
setFiles,
setLoading,
setProgress,
clipboard,
clearEphemeral,
options,
ephemeral,
config,
folder,
});
}
};
useEffect(() => {
@@ -205,7 +208,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
</Collapse>
<Collapse expanded={progress.speed > 0 && progress.remaining > 0}>
<Paper withBorder p='xs' radius='sm'>
<Paper withBorder p='xs'>
<Text ta='center' size='sm'>
{bytes(progress.speed)}/s, {humanizeDuration(progress.remaining)} remaining
</Text>
@@ -213,7 +216,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
</Collapse>
<Collapse expanded={progress.percent === 100}>
<Paper withBorder p='xs' radius='sm'>
<Paper withBorder p='xs'>
<Text ta='center' size='sm' c='yellow' fw={500}>
Finalizing upload(s)...
</Text>
@@ -125,6 +125,7 @@ export default function UploadText() {
disabled={loading}
className={styles.textarea}
my='sm'
resize='vertical'
/>
<Group style={{ position: 'absolute', bottom: 10, right: 10 }} gap='xs'>

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