Compare commits

..

50 Commits

Author SHA1 Message Date
diced 1e26037f45 feat(v4.6.4): version 2026-07-06 12:31:58 -07:00
diced 7e61bffcd3 fix: lint errors 2026-07-06 12:31:42 -07:00
diced 157a94b099 fix: sanitize folder name when export 2026-07-06 12:25:38 -07:00
diced 6093356c34 fix: folder ownership issues 2026-07-06 12:22:44 -07:00
diced e0806588f4 fix: add oauth nonce to prevent csrf 2026-07-06 12:22:33 -07:00
diced 976f057584 fix: add content security policy 2026-07-06 12:22:20 -07:00
dicedtomato aa4897349f Merge commit from fork 2026-07-06 11:30:42 -07:00
diced b13e2cc9cb fix: add color to logger + fix worker issues 2026-07-04 13:38:06 -07:00
diced 373f54fc24 fix: ineffective dynamic import 2026-07-04 13:37:56 -07:00
diced 6a9dcf02a7 chore: update dependencies 2026-07-04 13:37:42 -07:00
diced d4151c6801 fix: remove unused deps 2026-07-04 13:13:19 -07:00
diced 1c8adf414f fix: remove fluent-ffmpeg 2026-07-03 23:30:04 -07:00
diced 388c5827e9 fix: lessen ratelimits on login (#1116) 2026-07-03 16:17:48 -07:00
dicedtomato a120c0984c Merge commit from fork 2026-07-03 14:21:32 -07:00
dicedtomato fa30c6757b Merge commit from fork 2026-07-03 14:12:18 -07:00
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
137 changed files with 8902 additions and 5463 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": {
+50 -57
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.6.0",
"version": "4.6.4",
"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,109 +22,102 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1032.0",
"@aws-sdk/lib-storage": "3.1032.0",
"@aws-sdk/client-s3": "3.1079.0",
"@aws-sdk/lib-storage": "3.1079.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": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/rate-limit": "^11.1.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.1.1",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
"@mantine/charts": "^9.0.2",
"@mantine/code-highlight": "^9.0.2",
"@mantine/core": "^9.0.2",
"@mantine/dates": "^9.0.2",
"@mantine/dropzone": "^9.0.2",
"@mantine/form": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"@mantine/modals": "^9.0.2",
"@mantine/notifications": "^9.0.2",
"@mantine/charts": "^9.4.1",
"@mantine/core": "^9.4.1",
"@mantine/dates": "^9.4.1",
"@mantine/dropzone": "^9.4.1",
"@mantine/form": "^9.4.1",
"@mantine/hooks": "^9.4.1",
"@mantine/modals": "^9.4.1",
"@mantine/notifications": "^9.4.1",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@smithy/node-http-handler": "^4.5.3",
"@tabler/icons-react": "^3.41.1",
"archiver": "^7.0.1",
"@simplewebauthn/server": "^13.3.2",
"@smithy/node-http-handler": "^4.9.3",
"@tabler/icons-react": "^3.44.0",
"archiver": "7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.15.1",
"asciinema-player": "^3.17.0",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.3",
"cookie": "^1.1.1",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"detect-browser": "^5.3.0",
"devalue": "^5.7.1",
"devalue": "^5.8.1",
"fast-glob": "^3.3.3",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"fastify": "^5.9.0",
"fastify-plugin": "^6.0.0",
"fastify-type-provider-zod": "^7.0.0",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^3.9.0",
"katex": "^0.16.45",
"mantine-datatable": "^8.3.13",
"isomorphic-dompurify": "^3.18.0",
"katex": "^0.17.0",
"mantine-datatable": "^9.3.1",
"marked-react": "^4.0.0",
"ms": "^2.1.3",
"multer": "2.1.1",
"otplib": "^13.4.0",
"nuqs": "^2.9.0",
"otplib": "^13.4.1",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"react-virtuoso": "^4.18.5",
"sharp": "^0.34.5",
"swr": "^2.4.1",
"vite": "^8.0.9",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"react-virtuoso": "^4.18.10",
"sharp": "^0.35.3",
"swr": "^2.4.2",
"vite": "^8.1.3",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/he": "^1.2.3",
"@types/katex": "^0.16.8",
"@types/ms": "^2.1.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.10",
"postcss": "^8.5.16",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.3",
"sass": "^1.98.0",
"tsc-alias": "^1.8.16",
"prettier": "^3.9.4",
"sass": "^1.101.0",
"tsc-alias": "^1.8.17",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.2"
"typescript-eslint": "^8.62.1"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
}
+6125 -3816
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>
+11 -11
View File
@@ -1,14 +1,14 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="manifest.json">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="manifest.json" />
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
@@ -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';
+4 -4
View File
@@ -1,6 +1,5 @@
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';
@@ -24,6 +23,7 @@ import { IconFolder, IconUpload } from '@tabler/icons-react';
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'));
@@ -47,7 +47,7 @@ export async function loader({ params, request }: { params: Params<string>; requ
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' />
@@ -78,8 +78,8 @@ export function Component() {
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const [page, setPage] = useQueryState('page', 1);
const [perpage] = useQueryState('perpage', 15);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
{
+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') },
+57 -53
View File
@@ -10,7 +10,7 @@ import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
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';
@@ -24,17 +24,20 @@ import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'r
import { createRoutes } from './routes';
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(
{
@@ -168,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,
@@ -178,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,
@@ -189,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,
@@ -200,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,
@@ -211,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'>
@@ -237,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} />
@@ -542,7 +542,7 @@ export default function FileViewer({
overscrollBehavior: 'contain',
}}
>
{file ? (
{open && file ? (
<Box
onClick={(e) => e.stopPropagation()}
style={{
@@ -550,10 +550,11 @@ export default function FileViewer({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
height: 'fit-content',
alignSelf: 'stretch',
flex: 1,
minWidth: 0,
minHeight: 0,
width: '100%',
overflow: 'visible',
paddingLeft: '4rem',
paddingRight: '4rem',
@@ -568,7 +569,7 @@ export default function FileViewer({
scrollParent={scrollParent}
/>
{open && sequenced && fileNavButtons && file && (
{sequenced && fileNavButtons && file && (
<>
<ActionButton
Icon={IconChevronLeft}
+8 -9
View File
@@ -2,6 +2,7 @@ import type { File } from '@/lib/db/models/file';
import { Card } from '@mantine/core';
import { useState } from 'react';
import DashboardFileType from '../DashboardFileType';
import FileContextMenu from '../FileContextMenu';
import DashboardFileModal from './DashboardFileModal';
import styles from './index.module.css';
@@ -19,19 +20,17 @@ export default function DashboardFile({
}) {
const [open, setOpen] = useState(false);
const handleView = () => (onOpen ? onOpen(file.id) : setOpen(true));
return (
<>
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
<Card
shadow='md'
radius='md'
p={0}
onClick={() => (onOpen ? onOpen(file.id) : setOpen(true))}
className={styles.file}
>
<DashboardFileType key={file.id} file={file} />
</Card>
<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>
</>
);
}
+49 -23
View File
@@ -36,6 +36,24 @@ export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon
);
}
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,
@@ -54,6 +72,8 @@ export default function DashboardFileType({
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;
@@ -97,9 +117,6 @@ export default function DashboardFileType({
}
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
const mediaMax = fullscreen
? { maxWidth: 'min(96vw, calc(100vw - 3rem))', maxHeight: 'calc(100vh - 7.5rem)' }
: undefined;
if (type === 'video') {
if (!fileUrl) return <Loader />;
@@ -123,21 +140,24 @@ export default function DashboardFileType({
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
}
return (
const video = (
<video
width='100%'
width={fullscreen ? undefined : '100%'}
autoPlay
muted
muted={mediaAutoMuted}
controls
src={fileUrl}
style={{
cursor: 'pointer',
objectFit: 'contain',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '85vw', maxHeight: '85vh' }),
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
: { maxWidth: '85vw', maxHeight: '85vh', width: '100%' }),
}}
/>
);
return fullscreen ? <FullscreenSizedMedia>{video}</FullscreenSizedMedia> : video;
}
if (type === 'image') {
@@ -147,20 +167,26 @@ export default function DashboardFileType({
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 (
<Center>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
objectFit: 'contain',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '70vw', maxHeight: '70vw' }),
}}
onClick={() => allowZoom && setZoomOpen(true)}
/>
<>
{fullscreen ? <FullscreenSizedMedia>{image}</FullscreenSizedMedia> : <Center>{image}</Center>}
{allowZoom && zoomOpen && (
<FileZoomModal setOpen={setZoomOpen}>
<MantineImage
@@ -176,14 +202,14 @@ export default function DashboardFileType({
/>
</FileZoomModal>
)}
</Center>
</>
);
}
if (type === 'audio') {
if (!fileUrl) return <Loader />;
return show ? (
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
<audio autoPlay muted={mediaAutoMuted} controls style={{ width: '100%' }} src={fileUrl} />
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
+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,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,4 @@
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import DashboardFile from '@/components/file/DashboardFile';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import {
Button,
@@ -14,20 +14,19 @@ import {
Title,
} from '@mantine/core';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useEffect, useMemo, 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 DashboardFile from '@/components/file/DashboardFile';
import { useApiPagination } from '../useApiPagination';
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,
@@ -4,7 +4,6 @@ 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';
@@ -41,15 +40,14 @@ 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 { useShallow } from 'zustand/shallow';
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
import { DashboardFilesModals } from '..';
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';
@@ -60,7 +58,7 @@ type ReducerQuery = {
action: { field: string; query: string };
};
const PER_PAGE_OPTIONS = [10, 20, 50];
const PER_PAGE_OPTIONS = [10, 20, 50, 70, 100];
function SearchFilter({
setSearchField,
@@ -189,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);
@@ -203,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'
@@ -394,14 +392,15 @@ export default function FileTable({
/>
{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>
@@ -417,7 +416,7 @@ export default function FileTable({
)
}
>
Delete {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
Delete files
</Button>
<Button
@@ -431,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 && (
@@ -510,7 +516,6 @@ export default function FileTable({
{/*@ts-ignore*/}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
@@ -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 ?? []}
+4 -1
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,6 +13,8 @@ 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}>
<form onSubmit={onVerify}>
@@ -23,7 +26,7 @@ export default function TotpModal({
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
size={mobile ? 'md' : 'xl'}
autoFocus
/>
</Center>
+8 -9
View File
@@ -3,11 +3,10 @@ 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 { StatsCardsSkeleton, StatsTablesSkeleton } from './parts/Skeletons';
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 +132,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',
@@ -0,0 +1,109 @@
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
export function StatsCardsSkeleton() {
return (
<SimpleGrid
cols={{
base: 1,
md: 2,
lg: 3,
}}
mb='sm'
>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} height={100} animate />
))}
</SimpleGrid>
);
}
function SkeletonText() {
return (
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
);
}
export function StatsTablesSkeleton() {
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='md' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Files</Table.Th>
<Table.Th>Storage Used</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder mah={500} radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>URLs</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder radius='md'>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper withBorder p='sm'>
<Skeleton height={500} />
</Paper>
</SimpleGrid>
</>
);
}
@@ -1,6 +1,6 @@
import { bytes } from '@/lib/bytes';
import { Metric } from '@/lib/db/models/metric';
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
import { MetricsPoint } from '@/lib/metrics';
import { Group, Paper, rgba, SimpleGrid, Text } from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
@@ -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)}
>
@@ -70,31 +69,11 @@ function StatCard({
);
}
export function StatsCardsSkeleton() {
return (
<SimpleGrid
cols={{
base: 1,
md: 2,
lg: 3,
}}
mb='sm'
>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} height={100} animate />
))}
</SimpleGrid>
);
}
export default function StatsCards({ points }: { points: MetricsPoint[] }) {
if (!points.length) return null;
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(),
);
const recent = sortedMetrics[0];
const last = sortedMetrics[sortedMetrics.length - 1];
const recent = points[0];
const last = points[points.length - 1];
return (
<SimpleGrid
@@ -105,28 +84,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>
);
}
@@ -1,111 +1,20 @@
import { bytes } from '@/lib/bytes';
import { Metric } from '@/lib/db/models/metric';
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
import { Paper, ScrollArea, SimpleGrid, Table } from '@mantine/core';
import TypesPieChart from './TypesPieChart';
function SkeletonText() {
return (
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
);
}
export default function StatsTables({ latest }: { latest: Metric | null }) {
if (!latest) return null;
export function StatsTablesSkeleton() {
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Files</Table.Th>
<Table.Th>Storage Used</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper radius='sm' withBorder mah={500}>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>URLs</Table.Th>
<Table.Th>Views</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<SkeletonText />
<SkeletonText />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
<Paper radius='sm' withBorder p='sm'>
<Skeleton height={500} />
</Paper>
</SimpleGrid>
</>
);
}
export default function StatsTables({ data }: { data: Metric[] }) {
if (!data.length) 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 +40,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 +65,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 +88,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 />
+8 -30
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)
@@ -332,31 +332,9 @@ export default function DashboardServerSettings() {
</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>
)}
</>
);
@@ -3,7 +3,7 @@ import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput }
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { checkCommaArray, settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Files() {
@@ -25,6 +25,8 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
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,
@@ -36,6 +38,7 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
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,
@@ -55,25 +58,17 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
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);
};
@@ -86,12 +81,32 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
{...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')}
/>
<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')}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<Switch
label='Extensionless URLs'
description='Allow file links without the extension (e.g. /u/uuid instead of /u/uuid.png). Upload responses still include the extension.'
{...form.getInputProps('filesExtensionlessUrls', { type: 'checkbox' })}
/>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
@@ -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']>(
@@ -45,6 +45,12 @@ export default function SettingsDashboard() {
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.'
@@ -57,6 +63,26 @@ export default function SettingsDashboard() {
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
@@ -58,8 +58,10 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
const form = useForm({
initialValues: {
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 || '',
@@ -72,9 +74,11 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
});
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,
@@ -87,7 +91,7 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
};
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
view: valuesTrimmed,
view,
});
if (!data && error) {
@@ -122,6 +126,12 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
<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'
@@ -186,6 +196,20 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
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'>
+2 -2
View File
@@ -208,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>
@@ -216,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>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function UserCard({
return (
<>
<Card withBorder shadow='sm' radius='sm'>
<Card withBorder shadow='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group justify='space-between'>
{url.enabled ? (
+1 -1
View File
@@ -198,7 +198,7 @@ export default function DashboardURLs() {
{...form.getInputProps('password')}
/>
<Button type='submit' variant='outline' radius='sm' leftSection={<IconLink size='1rem' />}>
<Button type='submit' variant='outline' leftSection={<IconLink size='1rem' />}>
Create
</Button>
</Stack>
@@ -155,7 +155,6 @@ export default function UrlTableView() {
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
+3 -9
View File
@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { readToDataURL } from '@/lib/base64';
import { bytes } from '@/lib/bytes';
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { canInteract } from '@/lib/role';
import { useUserStore } from '@/lib/client/store/user';
@@ -31,7 +31,7 @@ export default function EditUserModal({
opened,
onClose,
}: {
user?: User | null;
user?: LimitedUser | null;
opened: boolean;
onClose: () => void;
}) {
@@ -268,13 +268,7 @@ export default function EditUserModal({
/>
<Divider />
<Button
type='submit'
variant='outline'
color='blue'
radius='sm'
leftSection={<IconUserEdit size='1rem' />}
>
<Button type='submit' variant='outline' color='blue' leftSection={<IconUserEdit size='1rem' />}>
Update user
</Button>
</Stack>
+3 -3
View File
@@ -1,4 +1,4 @@
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { ActionIcon, Avatar, Card, Group, Menu, Stack, Text } from '@mantine/core';
import { useUserStore } from '@/lib/client/store/user';
import { IconDots, IconFiles, IconTrashFilled, IconUserEdit } from '@tabler/icons-react';
@@ -9,7 +9,7 @@ import RelativeDate from '@/components/RelativeDate';
import { canInteract, isAdministrator, roleName } from '@/lib/role';
import { Link } from 'react-router-dom';
export default function UserCard({ user }: { user: User }) {
export default function UserCard({ user }: { user: LimitedUser }) {
const currentUser = useUserStore((state) => state.user);
const [opened, setOpen] = useState(false);
@@ -18,7 +18,7 @@ export default function UserCard({ user }: { user: User }) {
<>
<EditUserModal user={user} opened={opened} onClose={() => setOpen(false)} />
<Card withBorder shadow='sm' radius='sm'>
<Card withBorder shadow='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group justify='space-between'>
<Group>
+5 -9
View File
@@ -1,22 +1,18 @@
import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
import GridTableSwitcher from '@/components/GridTableSwitcher';
import useObjectState from '@/lib/client/hooks/useObjectState';
import { useViewStore } from '@/lib/client/store/view';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { Link, useLoaderData } from 'react-router-dom';
import { DashboardFilesModals } from '../files';
import FilesTableView from '../files/views/FilesTableView';
import { useModals } from '../files';
import FilesGridView from '../files/views/FilesGridView';
import FilesTableView from '../files/views/FilesTableView';
export default function ViewUserFiles() {
const data = useLoaderData<typeof loader>();
const view = useViewStore((state) => state.files);
const [modals, setModals] = useObjectState<Partial<DashboardFilesModals>>({
table: false,
idSearch: false,
});
const [modals, setModals] = useModals();
if (!data) return;
@@ -34,13 +30,13 @@ export default function ViewUserFiles() {
</Tooltip>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setModals('table', !modals.table)}>
<ActionIcon variant='outline' onClick={() => setModals({ table: !modals.table })}>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon variant='outline' onClick={() => setModals('idSearch', !modals.idSearch)}>
<ActionIcon variant='outline' onClick={() => setModals({ idSearch: !modals.idSearch })}>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
+3 -3
View File
@@ -1,12 +1,12 @@
import { Response } from '@/lib/api/response';
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconUserCancel, IconUserMinus } from '@tabler/icons-react';
import { mutate } from 'swr';
export async function deleteUser(user: User) {
export async function deleteUser(user: LimitedUser) {
modals.openConfirmModal({
centered: true,
title: `Delete ${user.username}?`,
@@ -33,7 +33,7 @@ export async function deleteUser(user: User) {
});
}
async function handleDeleteUser(user: User, deleteFiles: boolean = false) {
async function handleDeleteUser(user: LimitedUser, deleteFiles: boolean = false) {
const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'DELETE', {
delete: deleteFiles,
});
+3 -4
View File
@@ -1,7 +1,6 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { readToDataURL } from '@/lib/base64';
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { canInteract } from '@/lib/role';
import { useUserStore } from '@/lib/client/store/user';
@@ -68,7 +67,7 @@ export default function DashboardUsers() {
}
}
const { data, error } = await fetchApi<Extract<Response['/api/users'], User>>('/api/users', 'POST', {
const { data, error } = await fetchApi<LimitedUser>('/api/users', 'POST', {
username: values.username,
password: values.password,
role: values.role ?? 'USER',
@@ -144,7 +143,7 @@ export default function DashboardUsers() {
{...form.getInputProps('role')}
/>
<Button type='submit' variant='outline' radius='sm' leftSection={<IconUserPlus size='1rem' />}>
<Button type='submit' variant='outline' leftSection={<IconUserPlus size='1rem' />}>
Create
</Button>
</Stack>
@@ -1,13 +1,11 @@
import { Response } from '@/lib/api/response';
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
import { IconFilesOff } from '@tabler/icons-react';
import useSWR from 'swr';
import UserCard from '../UserCard';
export default function UserGridView() {
const { data: users, isLoading } =
useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const { data: users, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
return (
<>
@@ -1,7 +1,6 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { useUserStore } from '@/lib/client/store/user';
import { User } from '@/lib/db/models/user';
import { LimitedUser } from '@/lib/db/models/user';
import { canInteract, roleName } from '@/lib/role';
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react';
@@ -15,20 +14,20 @@ import EditUserModal from '../EditUserModal';
export default function UserTableView() {
const currentUser = useUserStore((state) => state.user);
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const { data, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<LimitedUser | null>(null);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const sorted = useMemo<User[]>(() => {
const sorted = useMemo<LimitedUser[]>(() => {
if (!data) return [];
const { columnAccessor, direction } = sortStatus;
const key = columnAccessor as keyof User;
const key = columnAccessor as keyof LimitedUser;
return [...data].sort((a, b) => {
const av = a[key]!;
@@ -45,7 +44,6 @@ export default function UserTableView() {
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
+6
View File
@@ -0,0 +1,6 @@
import { FastifyReply } from 'fastify';
export function setContentSecurity(res: FastifyReply) {
res.header('X-Content-Type-Options', 'nosniff');
res.header('Content-Security-Policy', 'sandbox');
}
+2
View File
@@ -63,6 +63,7 @@ export const API_ERRORS = {
1062: 'No files in multipart/form-data request',
1063: 'Already linked to this OAuth provider',
1064: 'Invalid OAuth state parameter',
1065: 'Invalid MIME type',
// 2xxx, session errors
2000: 'Invalid login session',
@@ -91,6 +92,7 @@ export const API_ERRORS = {
3016: 'OAuth registration is disabled',
3017: 'OAuth login is not allowed for this account',
3018: 'Invalid access token provided.',
3019: 'You cannot modify this user',
// 4xxx, not founds
4000: 'File not found',
+4
View File
@@ -14,6 +14,7 @@ import { ApiServerImportV3 } from '@/server/routes/api/server/import/v3';
import { ApiServerImportV4 } from '@/server/routes/api/server/import/v4';
import { ApiServerPublicResponse } from '@/server/routes/api/server/public';
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
import { ApiServerStatusResponse } from '@/server/routes/api/server/status';
import { ApiServerSettingsResponse, ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
import { ApiServerThemesResponse } from '@/server/routes/api/server/themes';
import { ApiServerThumbnailsResponse } from '@/server/routes/api/server/thumbnails';
@@ -34,6 +35,7 @@ import { ApiUserMfaPasskeyResponse } from '@/server/routes/api/user/mfa/passkey'
import { ApiUserMfaTotpResponse } from '@/server/routes/api/user/mfa/totp';
import { ApiUserRecentResponse } from '@/server/routes/api/user/recent';
import { ApiUserSessionsResponse } from '@/server/routes/api/user/sessions';
import { ApiUserActivityResponse } from '@/server/routes/api/user/activity';
import { ApiUserStatsResponse } from '@/server/routes/api/user/stats';
import { ApiUserTagsResponse } from '@/server/routes/api/user/tags';
import { ApiUserTagsIdResponse } from '@/server/routes/api/user/tags/[id]';
@@ -70,6 +72,7 @@ export type Response = {
'/api/user/sessions': ApiUserSessionsResponse;
'/api/user': ApiUserResponse;
'/api/user/stats': ApiUserStatsResponse;
'/api/user/activity': ApiUserActivityResponse;
'/api/user/recent': ApiUserRecentResponse;
'/api/user/token': ApiUserTokenResponse;
'/api/user/export': ApiUserExportResponse;
@@ -78,6 +81,7 @@ export type Response = {
'/api/server/clear_temp': ApiServerClearTempResponse;
'/api/server/clear_zeros': ApiServerClearZerosResponse;
'/api/server/requery_size': ApiServerRequerySizeResponse;
'/api/server/status': ApiServerStatusResponse;
'/api/server/settings': ApiServerSettingsResponse;
'/api/server/settings/web': ApiServerSettingsWebResponse;
'/api/server/public': ApiServerPublicResponse;
-37
View File
@@ -1,37 +0,0 @@
import { useSearchParams } from 'react-router-dom';
function parseValue<T>(value: string | null, defaultValue: T): T {
if (value === null) return defaultValue;
if (typeof defaultValue === 'number') {
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : (parsed as T);
}
if (typeof defaultValue === 'boolean') {
return (value === 'true') as T;
}
return value as T;
}
export function useQueryState<T>(key: string, defaultValue: T): [T, (value: T | null) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const rawValue = searchParams.get(key);
const value: T = parseValue(rawValue, defaultValue);
const setValue = (newValue: T | null) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newValue === null) {
next.delete(key);
} else {
next.set(key, String(newValue));
}
return next;
});
};
return [value, setValue];
}
+1 -1
View File
@@ -19,5 +19,5 @@ export default function useVersion() {
revalidateOnReconnect: false,
});
return { version: data?.data, isLoading };
return { version: data?.data, details: data?.details, cached: data?.cached, isLoading };
}
+1 -9
View File
@@ -2,15 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Field =
| 'name'
| 'originalName'
| 'tags'
| 'type'
| 'size'
| 'createdAt'
| 'favorite'
| 'views'
| 'anonymous';
'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views' | 'anonymous';
const FIELDS: {
property: Field;
+8
View File
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware';
export type SettingsStore = {
settings: {
disableMediaPreview: boolean;
mediaAutoMuted: boolean;
warnDeletion: boolean;
fileNavButtons: boolean;
fileViewer: 'default' | 'fullscreen';
@@ -11,6 +12,9 @@ export type SettingsStore = {
themeDark: string;
themeLight: string;
domain: '' | string;
homeShowRecents: boolean;
homeShowActivity: boolean;
homeShowTypes: boolean;
};
update: <K extends keyof SettingsStore['settings']>(key: K, value: SettingsStore['settings'][K]) => void;
@@ -18,6 +22,7 @@ export type SettingsStore = {
const defaultSettings: SettingsStore['settings'] = {
disableMediaPreview: false,
mediaAutoMuted: true,
warnDeletion: true,
fileNavButtons: true,
fileViewer: 'fullscreen',
@@ -25,6 +30,9 @@ const defaultSettings: SettingsStore['settings'] = {
themeDark: 'builtin:dark_blue',
themeLight: 'builtin:light_blue',
domain: '',
homeShowRecents: true,
homeShowActivity: true,
homeShowTypes: true,
};
export const useSettingsStore = create<SettingsStore>()(
+1 -1
View File
@@ -20,7 +20,7 @@ export function openWarningModal(options: WarningModalOptions) {
onCancel: () => modals.closeAll(),
onConfirm: options.onConfirm,
zIndex: 10320948239487,
size: 'auto',
size: 'md',
});
}
+3
View File
@@ -22,6 +22,8 @@ export const DATABASE_TO_PROP = {
filesRoute: 'files.route',
filesLength: 'files.length',
filesDefaultFormat: 'files.defaultFormat',
filesDisabledTypes: 'files.disabledTypes',
filesDisabledTypesDefault: 'files.disabledTypesDefault',
filesDisabledExtensions: 'files.disabledExtensions',
filesMaxFileSize: 'files.maxFileSize',
filesDefaultExpiration: 'files.defaultExpiration',
@@ -33,6 +35,7 @@ export const DATABASE_TO_PROP = {
filesRandomWordsSeparator: 'files.randomWordsSeparator',
filesDefaultCompressionFormat: 'files.defaultCompressionFormat',
filesMaxFilesPerUpload: 'files.maxFilesPerUpload',
filesExtensionlessUrls: 'files.extensionlessUrls',
urlsRoute: 'urls.route',
urlsLength: 'urls.length',
+3
View File
@@ -56,6 +56,8 @@ export const ENVS = [
env('files.route', 'FILES_ROUTE', 'string', true),
env('files.length', 'FILES_LENGTH', 'number', true),
env('files.defaultFormat', 'FILES_DEFAULT_FORMAT', 'string', true),
env('files.disabledTypes', 'FILES_DISABLED_TYPES', 'string[]', true),
env('files.disabledTypesDefault', 'FILES_DISABLED_TYPES_DEFAULT', 'string', true),
env('files.disabledExtensions', 'FILES_DISABLED_EXTENSIONS', 'string[]', true),
env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true),
env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true),
@@ -66,6 +68,7 @@ export const ENVS = [
env('files.randomWordsSeparator', 'FILES_RANDOM_WORDS_SEPARATOR', 'string', true),
env('files.defaultCompressionFormat', 'FILES_DEFAULT_COMPRESSION_FORMAT', 'string', true),
env('files.maxFilesPerUpload', 'FILES_MAX_FILES_PER_UPLOAD', 'number', true),
env('files.extensionlessUrls', 'FILES_EXTENSIONLESS_URLS', 'boolean', true),
env('urls.route', 'URLS_ROUTE', 'string', true),
env('urls.length', 'URLS_LENGTH', 'number', true),
+3 -1
View File
@@ -49,6 +49,7 @@ export const rawConfig: any = {
randomWordsSeparator: undefined,
defaultCompressionFormat: undefined,
maxFilesPerUpload: undefined,
extensionlessUrls: undefined,
},
urls: {
route: undefined,
@@ -169,10 +170,11 @@ export async function read() {
}
global.__tamperedConfig__.push(col);
logger.info('overriding database value from env', { col, value: val });
}
}
logger.debug('overridden db settings from env vars', { overridden: global.__tamperedConfig__ });
const raw = structuredClone(rawConfig);
for (const [key, value] of Object.entries(database)) {
+2
View File
@@ -1,4 +1,5 @@
import enabled from '../oauth/enabled';
import { version } from '../../../package.json';
import { Config } from './validate';
export type SafeConfig = Omit<
@@ -21,6 +22,7 @@ export function safeConfig(config: Config): SafeConfig {
bypassLocalLogin: oauth.bypassLocalLogin,
loginOnly: oauth.loginOnly,
};
(rest as SafeConfig).version = version;
return rest as SafeConfig;
}
+31 -17
View File
@@ -14,10 +14,21 @@ declare global {
ZIPLINE_BUILD?: string;
ZIPLINE_DB_LOG?: string;
ZIPLINE_OVERRIDE_DISABLED_WORKER_LOG?: string;
ZIPLINE_OVERRIDE_LOG_DATE_FORMAT?: string;
ZIPLINE_MONITOR_MEMORY?: string;
ZIPLINE_NO_COLOR?: string;
ZIPLINE_OUTPUT_OPENAPI?: string;
ZIPLINE_GIT_SHA?: string;
FFMPEG_PATH?: string;
DEBUG?: string;
}
}
}
export const MIME_REGEX = /^[a-zA-Z0-9!#$&^_\-\+.]+\/[a-zA-Z0-9!#$&^_\-\+.]+$/gi;
export const MAX_SAFE_TIMEOUT_MS = 2147483647;
export function validateInterval(value: string): boolean {
@@ -131,6 +142,8 @@ export const schema = z.object({
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/u'),
length: z.number().default(6),
defaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat', 'random-words']).default('random'),
disabledTypes: z.array(z.string().regex(MIME_REGEX, 'Invalid MIME type format')).default([]),
disabledTypesDefault: z.string().nullable().default(null),
disabledExtensions: z.array(z.string()).default([]),
maxFileSize: z.string().default('100mb'),
defaultExpiration: z.string().nullable().default(null),
@@ -145,6 +158,7 @@ export const schema = z.object({
.default('jpg')
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
maxFilesPerUpload: z.number().max(2147483647).min(1).default(1000),
extensionlessUrls: z.boolean().default(false),
}),
urls: z.object({
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
@@ -300,11 +314,11 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
allowedIds: z.undefined().or(z.array(z.string()).default([])),
deniedIds: z.undefined().or(z.array(z.string()).default([])),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
allowedIds: z.undefined().optional().or(z.array(z.string()).default([])),
deniedIds: z.undefined().optional().or(z.array(z.string()).default([])),
}),
),
github: z
@@ -315,9 +329,9 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
google: z
@@ -328,9 +342,9 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
oidc: z
@@ -344,12 +358,12 @@ export const schema = z.object({
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
authorizeUrl: z.undefined(),
userinfoUrl: z.undefined(),
tokenUrl: z.undefined(),
redirectUri: z.undefined(),
clientId: z.undefined().optional(),
clientSecret: z.undefined().optional(),
authorizeUrl: z.undefined().optional(),
userinfoUrl: z.undefined().optional(),
tokenUrl: z.undefined().optional(),
redirectUri: z.undefined().optional(),
}),
),
}),
+28 -9
View File
@@ -3,6 +3,7 @@ import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } fro
import { join, resolve, sep } from 'path';
import { Readable } from 'stream';
import { Datasource, ListOptions, PutOptions } from './Datasource';
import { log } from '../logger';
async function existsAndCanRW(path: string): Promise<boolean> {
try {
@@ -13,8 +14,13 @@ async function existsAndCanRW(path: string): Promise<boolean> {
}
}
function isCrossDeviceMove(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EXDEV';
}
export class LocalDatasource extends Datasource {
name = 'local';
logger = log('datasource').c('local');
constructor(public dir: string) {
super();
@@ -40,11 +46,9 @@ export class LocalDatasource extends Datasource {
public async put(file: string, data: Buffer | string, { noDelete }: PutOptions): Promise<void> {
const path = this.resolvePath(file);
if (!path) {
throw new Error('Invalid path provided');
}
if (!path) throw new Error('Invalid path provided');
// handles if given a path to a file, it will just move it instead of doing unecessary writes
// handles path-based writes without duplicating bytes when the source can be consumed
if (typeof data === 'string' && data.startsWith('/')) {
const exists = await existsAndCanRW(data);
if (!exists)
@@ -52,6 +56,15 @@ export class LocalDatasource extends Datasource {
"Something went very wrong! the temporary directory wasn't readable or the file doesn't exist.",
);
if (!noDelete) {
try {
await rename(data, path);
return;
} catch (e) {
if (!isCrossDeviceMove(e)) throw e;
}
}
await copyFile(data, path);
if (!noDelete) await rm(data);
@@ -69,14 +82,17 @@ export class LocalDatasource extends Datasource {
return;
}
const path = join(this.dir, file);
const path = this.resolvePath(file);
if (!path) throw new Error('Invalid path provided');
if (!existsSync(path)) return Promise.resolve();
return rm(path);
}
public async size(file: string): Promise<number> {
const path = join(this.dir, file);
const path = this.resolvePath(file);
if (!path) throw new Error('Invalid path provided');
if (!existsSync(path)) return 0;
const { size } = await stat(path);
@@ -98,15 +114,18 @@ export class LocalDatasource extends Datasource {
}
public async range(file: string, start: number, end: number): Promise<Readable> {
const path = join(this.dir, file);
const path = this.resolvePath(file);
if (!path) throw new Error('Invalid path provided');
const readStream = createReadStream(path, { start, end });
return readStream;
}
public async rename(from: string, to: string): Promise<void> {
const fromPath = join(this.dir, from);
const toPath = join(this.dir, to);
const fromPath = this.resolvePath(from);
const toPath = this.resolvePath(to);
if (!fromPath || !toPath) throw new Error('Invalid path provided');
if (!existsSync(fromPath))
throw new Error(`Something went very wrong! File ${from} does not exist in local datasource.`);
+21 -12
View File
@@ -7,6 +7,7 @@ import {
GetObjectCommand,
HeadObjectCommand,
ListObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
UploadPartCopyCommand,
@@ -256,23 +257,31 @@ export class S3Datasource extends Datasource {
}
public async totalSize(): Promise<number> {
const command = new ListObjectsCommand({
Bucket: this.options.bucket,
Prefix: this.options.subdirectory ?? undefined,
Delimiter: this.options.subdirectory ? undefined : '/',
});
let total = 0;
let continuationToken: string | undefined;
try {
const res = await this.client.send(command);
do {
const res = await this.client.send(
new ListObjectsV2Command({
Bucket: this.options.bucket,
Prefix: this.options.subdirectory ?? undefined,
ContinuationToken: continuationToken,
}),
);
if (!isOk(res.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while listing objects');
this.logger.error('error metadata', res.$metadata as Record<string, unknown>);
if (!isOk(res.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while listing objects');
this.logger.error('error metadata', res.$metadata as Record<string, unknown>);
return 0;
}
return 0;
}
return res.Contents?.reduce((acc, obj) => acc + Number(obj.Size), 0) ?? 0;
total += res.Contents?.reduce((acc, obj) => acc + Number(obj.Size ?? 0), 0) ?? 0;
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (continuationToken);
return total;
} catch (e) {
this.logger.error('there was an error while listing objects');
this.logger.error('error metadata', e as Record<string, unknown>);
+16
View File
@@ -1,5 +1,7 @@
import { config } from '@/lib/config';
import { sanitizeFilename } from '@/lib/fs';
import { formatRootUrl } from '@/lib/url';
import type { Prisma } from '@/prisma/client';
import { z } from 'zod';
import { tagSchema, tagSelectNoFiles } from './tag';
@@ -27,6 +29,20 @@ export const fileSelect = {
},
};
export async function findFileByName<TResult>(
id: string,
query: (
where: Prisma.FileWhereInput,
orderBy?: Prisma.FileOrderByWithRelationInput,
) => Promise<TResult | null>,
) {
const name = sanitizeFilename(id);
if (!name) return null;
const file = await query({ name });
if (file || !config.files.extensionlessUrls || name.includes('.')) return file;
return query({ name: { startsWith: `${name}.` } }, { createdAt: 'desc' });
}
export function cleanFile(file: File) {
file.password = !!file.password;
+15 -6
View File
@@ -38,30 +38,39 @@ export async function buildPublicParentChain(parentId: string | null): Promise<F
};
}
export function cleanFolder<T extends Partial<Folder>>(folder: T, stringifyDates = false): T {
type CleanableFolder = {
createdAt?: string | Date;
updatedAt?: string | Date;
files?: unknown;
children?: unknown;
parent?: unknown;
[key: string]: unknown;
};
export function cleanFolder<T extends CleanableFolder>(folder: T, stringifyDates = false): T {
if (folder.files && Array.isArray(folder.files)) cleanFiles(folder.files as any, stringifyDates);
if (stringifyDates) {
if (folder.createdAt && folder.createdAt instanceof Date)
folder.createdAt = folder.createdAt.toISOString();
(folder as CleanableFolder).createdAt = folder.createdAt.toISOString();
if (folder.updatedAt && folder.updatedAt instanceof Date)
folder.updatedAt = folder.updatedAt.toISOString();
(folder as CleanableFolder).updatedAt = folder.updatedAt.toISOString();
}
if (folder.children && Array.isArray(folder.children)) {
for (const child of folder.children) {
cleanFolder(child, stringifyDates);
if (child && typeof child === 'object') cleanFolder(child as CleanableFolder, stringifyDates);
}
}
if (folder.parent && typeof folder.parent === 'object') {
cleanFolder(folder.parent, stringifyDates);
cleanFolder(folder.parent as CleanableFolder, stringifyDates);
}
return folder;
}
export function cleanFolders<T extends Partial<Folder>>(folders: T[], stringifyDates = false): T[] {
export function cleanFolders<T extends CleanableFolder>(folders: T[], stringifyDates = false): T[] {
for (let i = 0; i !== folders.length; ++i) {
cleanFolder(folders[i], stringifyDates);
}
+23
View File
@@ -14,15 +14,27 @@ export const userSelect = {
sessions: true,
};
export const limitedUserSelect = {
id: true,
username: true,
createdAt: true,
updatedAt: true,
role: true,
view: true,
quota: true,
};
export const userViewSchema = z
.object({
enabled: z.boolean().nullish(),
disableTextFiles: z.boolean().nullish(),
align: z.enum(['left', 'center', 'right']).nullish(),
showMimetype: z.boolean().nullish(),
showTags: z.boolean().nullish(),
showFolder: z.boolean().nullish(),
content: z.string().nullish(),
embed: z.boolean().nullish(),
embedMediaOnly: z.boolean().nullish(),
embedTitle: z.string().nullish(),
embedDescription: z.string().nullish(),
embedColor: z.string().nullish(),
@@ -105,3 +117,14 @@ export const userSchema = z.object({
});
export type User = z.infer<typeof userSchema>;
export const limitedUserSchema = userSchema.omit({
oauthProviders: true,
totpSecret: true,
passkeys: true,
sessions: true,
password: true,
token: true,
});
export type LimitedUser = z.infer<typeof limitedUserSchema>;
+41
View File
@@ -0,0 +1,41 @@
import { statfs } from 'fs/promises';
import { config } from './config';
import { datasource } from './datasource';
import z from 'zod';
export const diskStatusSchema = z.object({
used: z.number(),
total: z.number().nullable(),
available: z.number().nullable(),
path: z.string(),
});
export type DiskStatus = z.infer<typeof diskStatusSchema>;
async function localDiskStatus() {
const path = config.datasource.local!.directory;
const stats = await statfs(path);
const total = stats.blocks * stats.bsize;
const available = stats.bavail * stats.bsize;
const used = total - stats.bfree * stats.bsize;
return { used, total, available, path };
}
async function s3DiskStatus() {
const s3 = config.datasource.s3!;
const totalSize = await datasource.totalSize();
const path = `${s3.bucket}${s3.subdirectory ? `/${s3.subdirectory.replace(/\/$/, '')}` : ''}`;
return {
used: totalSize,
total: null,
available: null,
path,
};
}
export async function diskStatus() {
return config.datasource.type === 'local' ? localDiskStatus() : s3DiskStatus();
}
+137
View File
@@ -0,0 +1,137 @@
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { accessSync, constants, statSync } from 'fs';
import { delimiter, join } from 'path';
let cachedFfmpeg: string | null;
function isExec(path: string): boolean {
try {
if (!statSync(path).isFile()) return false;
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export function findFfmpeg(): string | null {
if (cachedFfmpeg !== undefined) return cachedFfmpeg;
if (process.env.FFMPEG_PATH && isExec(process.env.FFMPEG_PATH)) {
cachedFfmpeg = process.env.FFMPEG_PATH;
return cachedFfmpeg;
}
const extensions =
process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (!dir) continue;
for (const ext of extensions) {
const candidate = join(dir, `ffmpeg${ext.toLowerCase()}`);
if (isExec(candidate)) {
cachedFfmpeg = candidate;
return cachedFfmpeg;
}
}
}
cachedFfmpeg = null;
return cachedFfmpeg;
}
export class FfmpegCommand extends EventEmitter {
private input: string;
private videoFilterList: string[] = [];
private frameCount?: number;
private outputPath?: string;
constructor(input: string) {
super();
this.input = input;
}
public on(event: 'start', listener: (command: string) => void): this;
public on(event: 'error', listener: (err: Error, stdout: string, stderr: string) => void): this;
public on(event: 'end', listener: (stdout: string, stderr: string) => void): this;
public on(event: string, listener: (...args: any[]) => void): this {
return super.on(event, listener);
}
public videoFilters(...filters: string[]): this {
this.videoFilterList.push(...filters);
return this;
}
public frames(count: number): this {
this.frameCount = count;
return this;
}
public output(path: string): this {
this.outputPath = path;
return this;
}
private buildArgs(): string[] {
const args = ['-hide_banner', '-i', this.input];
if (this.videoFilterList.length) args.push('-filter:v', this.videoFilterList.join(','));
if (this.frameCount !== undefined) args.push('-frames:v', String(this.frameCount));
args.push('-y', this.outputPath!);
return args;
}
public run(): void {
if (!this.outputPath) {
this.emit('error', new Error('no output file specified'), '', '');
return;
}
const ffmpegPath = findFfmpeg();
if (!ffmpegPath) {
this.emit(
'error',
new Error('cannot find ffmpeg executable, set FFMPEG_PATH or add ffmpeg to your PATH'),
'',
'',
);
return;
}
const args = this.buildArgs();
this.emit('start', [ffmpegPath, ...args].join(' '));
const proc = spawn(ffmpegPath, args, { windowsHide: true });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk) => (stdout += chunk));
proc.stderr.on('data', (chunk) => (stderr += chunk));
proc.on('error', (err) => {
this.emit('error', err, stdout, stderr);
});
proc.on('close', (code, signal) => {
if (code === 0) {
this.emit('end', stdout, stderr);
} else if (signal) {
this.emit('error', new Error(`ffmpeg was killed with signal ${signal}`), stdout, stderr);
} else {
const lastLine = stderr.trim().split('\n').pop() ?? '';
this.emit('error', new Error(`ffmpeg exited with code ${code}: ${lastLine}`), stdout, stderr);
}
});
}
}
export default function ffmpeg(input: string): FfmpegCommand {
return new FfmpegCommand(input);
}
+40 -11
View File
@@ -1,18 +1,36 @@
import dayjs from 'dayjs';
import { green, red, yellow, gray, white, bold, blue } from 'colorette';
import { isatty } from 'tty';
import { styleText } from 'util';
import { isMainThread } from 'worker_threads';
const canStyle = !process.env.ZIPLINE_NO_COLOR && isatty(1);
const style = (format: Parameters<typeof styleText>[0], text: string) =>
canStyle ? styleText(format, text, { validateStream: false }) : text;
const colors: Record<string, (text: string) => string> = {
green: (text: string) => style('green', text),
red: (text: string) => style('red', text),
yellow: (text: string) => style('yellow', text),
gray: (text: string) => style('gray', text),
white: (text: string) => style('white', text),
bold: (text: string) => style('bold', text),
blue: (text: string) => style('blue', text),
};
export type LoggerLevel = 'info' | 'warn' | 'error' | 'debug' | 'trace';
export function log(name: string) {
return new Logger(name);
}
const SEPARATOR = '.';
export default class Logger {
public constructor(public name: string) {}
public c(name: string) {
return new Logger(`${this.name}::${name}`);
return new Logger(`${this.name}${SEPARATOR}${name}`);
}
private isZiplineDebug(): boolean {
@@ -28,25 +46,34 @@ export default class Logger {
}
private format(message: string, level: LoggerLevel) {
const timestamp = dayjs().format('YYYY-MM-DDTHH:mm:ss');
const timestamp = dayjs().format(process.env.ZIPLINE_OVERRIDE_LOG_DATE_FORMAT ?? 'YYYY-MM-DDTHH:mm:ss');
return `${gray('[')}${timestamp} ${this.formatLevel(level)} ${this.name}${gray(']')} ${message}`;
return `${colors.gray('[')}${timestamp} ${this.formatLevel(level)} ${this.formatName()}${colors.gray(']')} ${message}`;
}
private formatName() {
if (!canStyle) return this.name;
return this.name
.split(SEPARATOR)
.map((part) => part)
.join(colors.gray(SEPARATOR));
}
private formatLevel(level: LoggerLevel) {
switch (level) {
case 'info':
return green('INFO ');
return colors.green('INFO ');
case 'warn':
return yellow('WARN ');
return colors.yellow('WARN ');
case 'error':
return red('ERROR');
return colors.red('ERROR');
case 'debug':
return yellow(bold('DEBUG'));
return colors.yellow(colors.bold('DEBUG'));
case 'trace':
return gray(bold('TRACE'));
return colors.gray(colors.bold('TRACE'));
default:
return white(bold('?????'));
return colors.white(colors.bold('?????'));
}
}
@@ -54,7 +81,9 @@ export default class Logger {
return (
' ' +
Object.entries(extra)
.map(([key, value]) => `${blue(key)}${gray('=')}${JSON.stringify(value, this.replacer)}`)
.map(
([key, value]) => `${colors.blue(key)}${colors.gray('=')}${JSON.stringify(value, this.replacer)}`,
)
.join(' ')
);
}
+72
View File
@@ -0,0 +1,72 @@
import z from 'zod';
import { prisma } from './db';
import { Metric } from './db/models/metric';
export const metricsPointSchema = z.object({
id: z.string(),
createdAt: z.date(),
users: z.number(),
files: z.number(),
fileViews: z.number(),
urls: z.number(),
urlViews: z.number(),
storage: z.bigint(),
});
export type MetricsPoint = z.infer<typeof metricsPointSchema>;
export function getMetricsPoints(from?: Date, to?: Date): Promise<MetricsPoint[]> {
if (from && to) {
return prisma.$queryRaw<MetricsPoint[]>`
SELECT
id,
"createdAt",
(data->>'users')::int AS users,
(data->>'files')::int AS files,
(data->>'fileViews')::int AS "fileViews",
(data->>'urls')::int AS urls,
(data->>'urlViews')::int AS "urlViews",
(data->>'storage')::bigint AS storage
FROM "Metric"
WHERE "createdAt" >= ${from} AND "createdAt" <= ${to}
ORDER BY "createdAt" DESC
`;
}
return prisma.$queryRaw<MetricsPoint[]>`
SELECT
id,
"createdAt",
(data->>'users')::int AS users,
(data->>'files')::int AS files,
(data->>'fileViews')::int AS "fileViews",
(data->>'urls')::int AS urls,
(data->>'urlViews')::int AS "urlViews",
(data->>'storage')::bigint AS storage
FROM "Metric"
ORDER BY "createdAt" DESC
`;
}
export function getLatestMetricsPoint(from?: Date, to?: Date): Promise<Metric | null> {
return prisma.metric.findFirst({
where: from && to ? { createdAt: { gte: from, lte: to } } : undefined,
orderBy: { createdAt: 'desc' },
});
}
export function downsample(points: MetricsPoint[], max: number = 500): MetricsPoint[] {
if (points.length <= max) return points;
const indices = new Set<number>();
indices.add(0);
indices.add(points.length - 1);
const middle = max - 2;
const step = (points.length - 1) / (middle + 1);
for (let i = 1; i <= middle; i++) {
indices.add(Math.round(i * step));
}
return [...indices].sort((a, b) => a - b).map((i) => points[i]!);
}

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