Compare commits

..

10 Commits

Author SHA1 Message Date
diced 0b1db04159 fix: add errors to spec 2026-03-03 16:29:24 -08:00
diced 4735b102c3 fix: settings errors 2026-03-03 15:22:23 -08:00
diced 5d48735dfb fix: lint 2026-03-02 22:45:31 -08:00
diced ea9599a67a fix: more 2026-03-02 22:43:47 -08:00
diced 9bd22bd574 fix: responses + add descriptions 2026-03-02 22:43:40 -08:00
diced 6fef46246e refactor: generalized error codes 2026-03-02 19:57:23 -08:00
diced 3f159b3509 fix: finish up api refactor 2026-03-02 14:29:41 -08:00
diced eb3a58e790 feat: descriptions for api routes 2026-03-02 00:25:37 -08:00
diced 454b40501a refactor: models to zod 2026-03-01 22:41:36 -08:00
diced 4c6679b568 feat: add response schemas (WIP, hella unstable!!) 2026-03-01 14:57:16 -08:00
279 changed files with 8169 additions and 9965 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build:
strategy:
matrix:
node: [24.x, 26.x]
node: [22.x, 24.x]
arch: [amd64, arm64]
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
+10 -10
View File
@@ -1,25 +1,23 @@
FROM node:24-alpine3.22 AS base
FROM node:22-alpine3.21 AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable \
&& apk add --no-cache ffmpeg=6.1.2-r2 tzdata=2026b-r0
RUN corepack enable
RUN apk add --no-cache ffmpeg tzdata
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
@@ -46,8 +44,10 @@ 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 \
&& rm -rf /tmp/* /root/*
RUN pnpm prisma generate
# clean
RUN rm -rf /tmp/* /root/*
ENV NODE_ENV=production
ENV ZIPLINE_ROOT=/zipline
+1 -4
View File
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
This is the recommended way to run Zipline:
```yaml
```yml
services:
postgresql:
image: postgres:16
@@ -91,9 +91,6 @@ volumes:
pgdata:
```
> [!WARNING]
> Zipline requires a cpu with AVX support. We don't provide binaries or images that have support for non-AVX cpus
### Volumes
- `./uploads` - The folder where all the user uploads are stored (the default is `./uploads`)
+1 -2
View File
@@ -85,7 +85,6 @@ 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',
@@ -102,7 +101,7 @@ export default defineConfig(
},
settings: {
react: { version: '19' },
react: { version: 'detect' },
},
},
);
Generated
+39 -645
View File
@@ -6,8 +6,7 @@
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
"devenv"
],
"git-hooks": [
"devenv",
@@ -19,11 +18,11 @@
]
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"lastModified": 1748883665,
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
"type": "github"
},
"original": {
@@ -33,142 +32,22 @@
"type": "github"
}
},
"cachix_2": {
"inputs": {
"devenv": [
"devenv",
"crate2nix"
],
"flake-compat": [
"devenv",
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"cachix_3": {
"inputs": {
"devenv": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"git-hooks": "git-hooks_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix_2",
"crate2nix_stable": "crate2nix_stable",
"devshell": "devshell_2",
"flake-compat": "flake-compat_2",
"flake-parts": "flake-parts_2",
"nix-test-runner": "nix-test-runner_2",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"pre-commit-hooks": "pre-commit-hooks_2"
},
"locked": {
"lastModified": 1773440526,
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"crate2nix_stable": {
"inputs": {
"cachix": "cachix_3",
"crate2nix_stable": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": "nixpkgs_3",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1769627083,
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "0.15.0",
"repo": "crate2nix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"crate2nix": "crate2nix",
"flake-compat": "flake-compat_3",
"flake-parts": "flake-parts_3",
"git-hooks": "git-hooks_3",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
]
},
"locked": {
"lastModified": 1774134162,
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
"lastModified": 1753888869,
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
"owner": "cachix",
"repo": "devenv",
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
"type": "github"
},
"original": {
@@ -189,87 +68,14 @@
"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,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
@@ -282,17 +88,16 @@
"inputs": {
"nixpkgs-lib": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"type": "github"
},
"original": {
@@ -302,58 +107,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": 1753121425,
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"type": "github"
},
"original": {
@@ -366,82 +128,20 @@
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"lastModified": 1750779888,
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
"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": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"type": "github"
},
"original": {
@@ -451,102 +151,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",
@@ -574,10 +178,7 @@
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"flake-parts": "flake-parts",
"git-hooks-nix": [
"devenv",
"git-hooks"
@@ -594,101 +195,43 @@
]
},
"locked": {
"lastModified": 1774103430,
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
"lastModified": 1752773918,
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
"owner": "cachix",
"repo": "nix",
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.32",
"ref": "devenv-2.30",
"repo": "nix",
"type": "github"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nix-test-runner_2": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1773634079,
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
"owner": "nix-community",
"repo": "nixd",
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"lastModified": 1752827260,
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"type": "github"
},
"original": {
@@ -697,161 +240,12 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1769433173,
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1773964973,
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable",
"flake-compat"
],
"gitignore": "gitignore_3",
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"pre-commit-hooks_2": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_4",
"nixpkgs": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts_4",
"nixpkgs": "nixpkgs_4"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1773630837,
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1772660329,
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs"
}
}
},
+8 -2
View File
@@ -6,8 +6,8 @@
flake = false;
};
# node 24.14, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/812b3986fd1568f7a858f97fcf425ad996ba7d25";
# node 24.4.1, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
flake-parts.url = "github:hercules-ci/flake-parts";
devenv.url = "github:cachix/devenv";
@@ -58,6 +58,7 @@
ffmpeg
# for testing docker
colima
docker
docker-compose
];
@@ -74,6 +75,11 @@
downall.exec = ''
process-compose down
'';
# ensure that volumes are mounted with write access for docker containers
start_colima.exec = ''
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
'';
};
enterShell = ''
+63 -64
View File
@@ -2,15 +2,15 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.6.2",
"version": "4.4.2",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "tsx scripts/validate.ts",
"openapi": "tsx scripts/openapi.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
@@ -22,110 +22,109 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1046.0",
"@aws-sdk/lib-storage": "3.1046.0",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@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/cors": "^11.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
"@mantine/charts": "^9.2.0",
"@mantine/code-highlight": "^9.2.0",
"@mantine/core": "^9.2.0",
"@mantine/dates": "^9.2.0",
"@mantine/dropzone": "^9.2.0",
"@mantine/form": "^9.2.0",
"@mantine/hooks": "^9.2.0",
"@mantine/modals": "^9.2.0",
"@mantine/notifications": "^9.2.0",
"@fastify/static": "^8.3.0",
"@fastify/swagger": "^9.6.1",
"@mantine/charts": "^8.3.9",
"@mantine/code-highlight": "^8.3.9",
"@mantine/core": "^8.3.9",
"@mantine/dates": "^8.3.9",
"@mantine/dropzone": "^8.3.9",
"@mantine/form": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantine/modals": "^8.3.9",
"@mantine/notifications": "^8.3.9",
"@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.7.2",
"@tabler/icons-react": "^3.44.0",
"archiver": "7.0.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@smithy/node-http-handler": "^4.1.1",
"@tabler/icons-react": "^3.35.0",
"archiver": "^7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.15.1",
"asciinema-player": "^3.12.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.3",
"commander": "^14.0.2",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.20",
"dayjs": "^1.11.19",
"detect-browser": "^5.3.0",
"devalue": "^5.8.0",
"dotenv": "^17.2.3",
"fast-glob": "^3.3.3",
"fastify": "^5.8.5",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^3.12.0",
"katex": "^0.16.46",
"mantine-datatable": "^9.2.0",
"marked-react": "^4.0.0",
"isomorphic-dompurify": "^2.33.0",
"katex": "^0.16.27",
"mantine-datatable": "^8.3.9",
"ms": "^2.1.3",
"multer": "2.1.1",
"nuqs": "^2.8.9",
"otplib": "^13.4.0",
"multer": "2.0.2",
"otplib": "^12.0.1",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.7",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"swr": "^2.4.1",
"vite": "^8.0.12",
"zod": "^4.3.6",
"zustand": "^5.0.13"
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"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/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.2",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.14",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.3",
"sass": "^1.98.0",
"tsc-alias": "^1.8.17",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
+4001 -2479
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,11 +1,3 @@
allowBuilds:
'@parcel/watcher': true
'@prisma/client': true
'@prisma/engines': true
argon2: true
esbuild: true
prisma: true
sharp: true
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxFilesPerUpload" INTEGER NOT NULL DEFAULT 1000;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."File" ADD COLUMN "anonymous" BOOLEAN NOT NULL DEFAULT false;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDisabledTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "filesDisabledTypesDefault" TEXT;
+3 -8
View File
@@ -36,8 +36,6 @@ 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?
@@ -48,7 +46,6 @@ model Zipline {
filesRandomWordsNumAdjectives Int @default(2)
filesRandomWordsSeparator String @default("-")
filesDefaultCompressionFormat String? @default("jpg")
filesMaxFilesPerUpload Int @default(1000)
urlsRoute String @default("/go")
urlsLength Int @default(6)
@@ -60,10 +57,9 @@ model Zipline {
featuresOauthRegistration Boolean @default(false)
featuresDeleteOnMaxViews Boolean @default(true)
featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresThumbnailsInstantaneous Boolean @default(false)
featuresThumbnailsEnabled Boolean @default(true)
featuresThumbnailsNumberThreads Int @default(4)
featuresThumbnailsFormat String @default("jpg")
featuresMetricsEnabled Boolean @default(true)
featuresMetricsAdminOnly Boolean @default(false)
@@ -286,7 +282,6 @@ model File {
maxViews Int?
favorite Boolean @default(false)
password String?
anonymous Boolean @default(false)
tags Tag[]
+1 -5
View File
@@ -6,7 +6,6 @@ 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 }>) => (
<>
@@ -62,10 +61,7 @@ export default function Root({
modals={contextModals}
>
<Notifications position='top-center' zIndex={10000000} />
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
<Outlet />
</ModalsProvider>
</ThemeProvider>
</SWRConfig>
@@ -1,13 +1,6 @@
import { useRouteError } from 'react-router-dom';
import GenericError from './GenericError';
import ReloadPage from './ReloadPage';
export default function DashboardErrorBoundary(props: Record<string, any>) {
const error = useRouteError();
if (error instanceof Error && error.message.startsWith('Failed to fetch dynamically imported module:')) {
return <ReloadPage />;
}
return (
<GenericError
title='Dashboard Client Error'
-37
View File
@@ -1,37 +0,0 @@
import { Button, Collapse, Container, Text, Title } from '@mantine/core';
import { IconReload } from '@tabler/icons-react';
import GenericError from './GenericError';
import { useState } from 'react';
export default function ReloadPage() {
const [view, setView] = useState(false);
return (
<Container my='lg'>
<Title order={3}>Update available</Title>
<Text size='lg'>A new version of the app is available. Please reload the page to update.</Text>
<Button
leftSection={<IconReload size='1rem' />}
mr='sm'
mt='md'
onClick={() => window.location.reload()}
>
Reload Page
</Button>
<Button variant='subtle' mt='md' onClick={() => setView((v) => !v)}>
Why am I seeing this?
</Button>
<Collapse expanded={view}>
<GenericError
title='Failed to fetch dynamically imported module'
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
details={{}}
/>
</Collapse>
</Container>
);
}
+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>
+1 -1
View File
@@ -1,4 +1,4 @@
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
+28 -64
View File
@@ -6,9 +6,9 @@ import TotpModal from '@/components/pages/login/TotpModal';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/client/hooks/useLogin';
import useObjectState from '@/lib/client/hooks/useObjectState';
import { useTitle } from '@/lib/client/hooks/useTitle';
import useLogin from '@/lib/hooks/useLogin';
import useObjectState from '@/lib/hooks/useObjectState';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Anchor,
Box,
@@ -33,21 +33,16 @@ import {
IconCircleKeyFilled,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { eitherTrue } from '@/lib/primitive';
export default function Login() {
useTitle('Login');
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin({
swrConfig: {
shouldRetryOnError: false,
},
});
const { user, mutate } = useLogin();
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
@@ -128,12 +123,6 @@ export default function Login() {
}
};
const handleTotpChange = async (val: string) => {
setTotp('pin', val);
if (val.length === 6) await handleLoginSubmit(form.values, val);
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
@@ -145,7 +134,7 @@ export default function Login() {
<TotpModal
state={totp}
onPinChange={(val) => handleTotpChange(val)}
onPinChange={(val) => setTotp('pin', val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
@@ -216,55 +205,30 @@ export default function Login() {
/>
)}
{eitherTrue(
config.mfa.passkeys && browserSupportsWebAuthn(),
config.oauthEnabled.discord,
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
config.features.userRegistration,
) && (
<>
<Divider label='or' />
<Divider label='or' />
{config.mfa.passkeys && browserSupportsWebAuthn() && (
<PasskeyAuthButton onAuthSuccess={mutate} />
)}
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
provider='Discord'
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.github && (
<ExternalAuthButton
provider='GitHub'
leftSection={<IconBrandGithubFilled size='1.1rem' />}
/>
)}
{config.oauthEnabled.google && (
<ExternalAuthButton
provider='Google'
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.oidc && (
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
{config.features.userRegistration && (
<Text ta='center' mt='md'>
Don&apos;t have an account?{' '}
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
Register
</Anchor>
</Text>
)}
</>
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
provider='Discord'
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.github && (
<ExternalAuthButton provider='GitHub' leftSection={<IconBrandGithubFilled size='1.1rem' />} />
)}
{config.oauthEnabled.google && (
<ExternalAuthButton
provider='Google'
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
/>
)}
{config.oauthEnabled.oidc && (
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
</Stack>
</Paper>
</Center>
+17 -11
View File
@@ -1,7 +1,6 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import useUser from '@/lib/client/hooks/useUser';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -19,8 +18,8 @@ import {
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { getWebClient } from '@/lib/api/detect';
@@ -32,6 +31,8 @@ export function Component() {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const {
data: config,
error: configError,
@@ -58,8 +59,6 @@ export function Component() {
},
);
const { user, loading: userLoading } = useUser();
const form = useForm({
initialValues: {
username: '',
@@ -75,6 +74,17 @@ export function Component() {
}),
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
navigate('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!config) return;
@@ -128,11 +138,7 @@ export function Component() {
}
};
if (userLoading || configLoading) return <LoadingOverlay visible />;
if (user) {
return <Navigate to='/dashboard' replace />;
}
if (loading || configLoading) return <LoadingOverlay visible />;
if (!config || configError) {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
import { type Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Anchor,
Button,
+1 -1
View File
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Container, LoadingOverlay } from '@mantine/core';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Terms of Service');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardInvites from '@/components/pages/invites';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Invites');
@@ -1,5 +1,5 @@
import DashboardServerSettings from '@/components/pages/serverSettings';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Settings');
@@ -1,5 +1,5 @@
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import { Params, redirect, useLoaderData } from 'react-router-dom';
export async function loader({ params }: { params: Params<string> }) {
@@ -1,5 +1,5 @@
import DashboardUsers from '@/components/pages/users';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Users');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardFiles from '@/components/pages/files';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Files');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardFolders from '@/components/pages/folders';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Folders');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardHome from '@/components/pages/dashboard';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle();
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardMetrics from '@/components/pages/metrics';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import { isAdministrator } from '@/lib/role';
import { redirect } from 'react-router-dom';
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardSettings from '@/components/pages/settings';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Settings');
+1 -1
View File
@@ -1,5 +1,5 @@
import UploadFile from '@/components/pages/upload/File';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload File');
+1 -1
View File
@@ -1,5 +1,5 @@
import UploadText from '@/components/pages/upload/Text';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload Text');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardURLs from '@/components/pages/urls';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('URLs');
+12 -102
View File
@@ -1,7 +1,4 @@
import { useApiPagination } from '@/components/pages/files/useApiPagination';
import { type Response } from '@/lib/api/response';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
@@ -11,8 +8,6 @@ import {
Card,
Container,
Group,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -20,34 +15,25 @@ import {
Title,
} from '@mantine/core';
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';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
export async function loader({ params, request }: { params: Params<string>; request: Request }) {
const url = new URL(request.url);
const page = url.searchParams.get('page') ?? '1';
const perpage = url.searchParams.get('perpage') ?? '15';
const res = await fetch(
`/api/server/folder/${params.id}?page=${encodeURIComponent(page)}&perpage=${encodeURIComponent(perpage)}`,
);
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
initial: (await res.json()) as Response['/api/server/folder/[id]'],
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
return (
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
<Card withBorder shadow='sm' style={{ cursor: 'pointer' }}>
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
<Card.Section withBorder inheritPadding py='xs'>
<Group gap='xs'>
<IconFolder size='1.2rem' />
@@ -71,34 +57,10 @@ function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
);
}
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export function Component() {
const { initial } = useLoaderData<typeof loader>();
const { folder } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
{
route: `/api/server/folder/${initial.folder.id}`,
page,
perpage,
sort: 'createdAt',
order: 'desc',
},
{ fallbackData: initial, keepPreviousData: true, revalidateOnFocus: false },
);
const folder = data?.folder ?? initial.folder;
const files = data?.page ?? [];
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 0;
useTitle(folder.name ?? 'Folder');
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [];
@@ -115,30 +77,10 @@ export function Component() {
const breadcrumbs = buildBreadcrumbs();
const children = (folder.children ?? []) as Partial<Folder>[];
const from = totalRecords === 0 ? 0 : (page - 1) * perpage + 1;
const to = Math.min(page * perpage, totalRecords);
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (files.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => files.map((file) => file.id), [files]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<Container my='lg'>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => setCurrent(open ? (currentFile?.id ?? null) : null)}
file={currentFile}
reduce
sequenced
/>
{breadcrumbs.length > 1 && (
<Breadcrumbs mb='md'>
{breadcrumbs.map((item, index) => (
@@ -158,7 +100,7 @@ export function Component() {
<Title order={1}>{folder.name}</Title>
{folder.allowUploads && (
<Link to={`/folder/${folder.id}/upload`} reloadDocument>
<Link to={`/folder/${folder.id}/upload`}>
<ActionIcon variant='outline'>
<IconUpload size='1rem' />
</ActionIcon>
@@ -187,7 +129,7 @@ export function Component() {
</>
)}
{(files.length ?? 0) > 0 && (
{(folder.files?.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
@@ -200,52 +142,20 @@ export function Component() {
}}
spacing='md'
>
{files.map((file: any) => (
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce onOpen={(fileId) => setCurrent(fileId)} />
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
</>
)}
{children.length === 0 && totalRecords === 0 && (
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
<Text c='dimmed' mt='md'>
This folder is empty.
</Text>
)}
<Group justify='space-between' align='center' mt='md'>
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
<Group gap='sm'>
<Select
value={perpage.toString()}
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
onChange={(value) => {
setSearchParams((prev) => {
prev.set('perpage', value ?? '15');
prev.set('page', '1');
return prev;
});
}}
w={80}
size='xs'
variant='filled'
disabled={isLoading}
/>
<Pagination
value={page}
onChange={setPage}
total={cachedPages}
size='sm'
withControls
withEdges
disabled={isLoading}
/>
</Group>
</Group>
</Container>
</>
);
+7 -8
View File
@@ -2,20 +2,19 @@ import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { type Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useTitle } from '@/lib/hooks/useTitle';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { data, Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) throw data('Folder not found', { status: 404 });
const d = (await res.json()) as Response['/api/server/folder/[id]'];
if (!d.folder) throw data('Folder not found', { status: 404 });
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
if (!res.ok) {
throw data('Folder not found', { status: 404 });
}
return {
folder: d.folder,
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
@@ -41,7 +40,7 @@ export function Component() {
{folder.public ? (
<>
This folder is{' '}
<Anchor component={Link} to={`/folder/${folder.id}`} reloadDocument>
<Anchor component={Link} to={`/folder/${folder.id}`}>
public
</Anchor>
. Anyone with the link can view its contents and upload files.
+29 -22
View File
@@ -1,7 +1,5 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
@@ -10,6 +8,7 @@ import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
@@ -25,7 +24,9 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
import { useTitle } from '@/lib/hooks/useTitle';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
@@ -33,7 +34,7 @@ type SsrData = {
code: boolean;
user?: Partial<User>;
host: string;
token?: string | null;
pw?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
@@ -42,7 +43,14 @@ export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, token } = data;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
// Fix dates that were stringified during SSR
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -50,7 +58,7 @@ export default function ViewFileId() {
useTitle(file.originalName ?? file.name ?? 'View File');
return password && !token ? (
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -63,8 +71,7 @@ export default function ViewFileId() {
});
if (res.ok) {
const json = (await res.json()) as { token: string };
window.location.replace(`/view/${file.name}?token=${encodeURIComponent(json.token)}`);
window.location.reload();
} else {
setPasswordError('Invalid password');
}
@@ -105,7 +112,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -114,7 +121,7 @@ export default function ViewFileId() {
</Group>
</Paper>
<Collapse expanded={detailsOpen}>
<Collapse in={detailsOpen}>
<Paper m='md' p='md' withBorder>
{user?.view!.content && (
<Typography>
@@ -143,9 +150,15 @@ export default function ViewFileId() {
</Paper>
</Collapse>
<Center m='sm'>
<DashboardFileType file={file as unknown as File} token={token} show code={code} fullscreen />
</Center>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
</>
) : (
<>
@@ -167,13 +180,7 @@ export default function ViewFileId() {
file.Folder &&
(file.Folder.public ? (
<Tooltip label='View folder'>
<Anchor
component={Link}
ml='sm'
to={`/folder/${file.Folder.id}`}
target='_blank'
reloadDocument
>
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
{file.Folder.name}
</Anchor>
</Tooltip>
@@ -195,7 +202,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${token ? `?token=${encodeURIComponent(token)}` : ''}`}
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
@@ -206,7 +213,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -215,7 +222,7 @@ export default function ViewFileId() {
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
{user?.view!.content && (
<Typography>
+3 -5
View File
@@ -6,11 +6,10 @@ export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
token?: string | null;
}>();
if (!data) return null;
const { url, password, token } = data;
const { url, password } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -19,7 +18,7 @@ export default function ViewUrlId() {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password && !token ? (
return password ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -32,8 +31,7 @@ export default function ViewUrlId() {
});
if (res.ok) {
const json = (await res.json()) as { token: string };
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
window.location.reload();
} else {
setPasswordError('Invalid password');
}
+28 -22
View File
@@ -8,11 +8,6 @@ import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Root from './Root';
const fourOhFourCatchall = {
path: '*',
Component: FourOhFour,
};
export async function dashboardLoader() {
try {
const res = await fetch('/api/server/settings/web');
@@ -33,21 +28,21 @@ export const router = createBrowserRouter([
{
Component: Root,
path: '/',
HydrateFallback: () => null,
children: [
{
ErrorBoundary: RootErrorBoundary,
children: [
fourOhFourCatchall,
{ path: '*', Component: FourOhFour },
{
path: '/auth',
children: [
{ path: 'auth/login', Component: Login },
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
{ path: 'login', Component: Login },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'auth/setup',
path: 'setup',
lazy: () => import('./pages/auth/setup'),
},
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
],
},
{
@@ -64,26 +59,37 @@ export const router = createBrowserRouter([
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
// admin routes
{
path: 'upload',
children: [
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
],
},
{
path: 'admin',
loader: async () => {
const res = await fetch('/api/user');
if (!res.ok) return redirect('/auth/login');
if (!res.ok) {
return redirect('/auth/login');
}
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
},
children: [
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
{
path: 'admin/users/:id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
path: 'users',
children: [
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
{
path: ':id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
},
],
},
],
},
+21 -13
View File
@@ -1,11 +1,13 @@
import { verifyAccessToken } from '@/lib/accessToken';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { FastifyRequest } from 'fastify';
import { createRoutes } from './routes';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
export async function render(
{
@@ -15,11 +17,13 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
req: FastifyRequest;
},
url: string,
) {
const id = req.params?.id ?? null;
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -48,27 +52,31 @@ export async function render(
return { html: 'Gone', meta: '', status: 410 };
}
const token = req.query.token;
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
token: valid ? token : null,
};
delete (data.url as any).password;
const routes = createRoutes(themes, defaultTheme);
if (hasPassword) {
if (!valid) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
+75 -78
View File
@@ -5,19 +5,19 @@ import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { verifyAccessToken } from '@/lib/accessToken';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import { stripHtml } from '@/lib/stripHtml';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
@@ -43,11 +43,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
req: FastifyRequest;
},
url: string,
) {
const id = req.params?.id ?? null;
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -93,15 +93,17 @@ export async function render(
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const token = req.query.token;
const valid = token && file.password ? verifyAccessToken(token, 'file', file.id) : false;
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const hasPassword = !!file.password;
delete (file as any).password;
if (hasPassword) {
console.log('File is password protected');
if (!valid) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
@@ -138,7 +140,7 @@ export async function render(
const data = {
file,
password: hasPassword,
token: valid ? token : null,
pw: pw || null,
code,
user,
host,
@@ -164,115 +166,110 @@ export async function render(
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
const safeFilename = stripHtml(file.name);
const safeOriginalName = stripHtml(file.originalName || '');
const safeType = stripHtml(file.type || '');
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(
const meta = `
${
user?.view?.embedTitle && user.view.embed
? `<meta property="og:title" content="${
parseString(user.view.embedTitle, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? '',
)}" />`
: '',
showRichOg && user?.view?.embedDescription
? `<meta property="og:description" content="${stripHtml(
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedDescription && user.view.embed
? `<meta property="og:description" content="${
parseString(user.view.embedDescription, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? '',
)}" />`
: '',
showRichOg && user?.view?.embedSiteName
? `<meta property="og:site_name" content="${stripHtml(
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedSiteName && user.view.embed
? `<meta property="og:site_name" content="${
parseString(user.view.embedSiteName, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? '',
)}" />`
: '',
showRichOg && user?.view?.embedColor
? `<meta property="theme-color" content="${stripHtml(
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedColor && user.view.embed
? `<meta property="theme-color" content="${
parseString(user.view.embedColor, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? '',
)}" />`
: '',
]
.filter(Boolean)
.join('\n ');
}) ?? ''
}" />`
: ''
}
const imageOg =
showMediaOg && file.type?.startsWith('image')
${
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="${pageUrl}" />
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
${showRichOg ? `<meta property="twitter:title" content="${safeFilename}" />` : ''}
<meta property="twitter:image" content="${host}/raw/${file.name}" />
<meta property="twitter:title" content="${file.name}" />
`
: '';
: ''
}
const videoOg =
showMediaOg && file.type?.startsWith('video')
${
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:url" content="${host}/raw/${file.name}" />
<meta property="og:video:width" content="1920" />
<meta property="og:video:height" content="1080" />
`
: '';
: ''
}
const audioOg =
showMediaOg && file.type?.startsWith('audio')
${
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}" />
${showRichOg ? `<meta name="twitter:title" content="${safeFilename}" />` : ''}
<meta name="twitter:player" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream:content_type" content="${file.type}" />
<meta name="twitter:title" content="${file.name}" />
<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="${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}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="og:audio" content="${host}/raw/${file.name}" />
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
<meta property="og:audio:type" content="${file.type}" />
`
: '';
: ''
}
const otherOg =
showRichOg && !file.type?.startsWith('video') && !file.type?.startsWith('image')
${
!file.type?.startsWith('video') && !file.type?.startsWith('image')
? `
<meta property="og:url" content="${pageUrl}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
`
: '';
: ''
}
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')
: '';
<title>${file.originalName ?? file.name}</title>
`;
return {
html,
meta: `${headMeta ? `${headMeta}\n` : ''}${createZiplineSsr(data)}`,
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
};
}
-8
View File
@@ -5,11 +5,3 @@
font-weight: 700;
font-size: var(--mantine-font-size-xl);
}
.mantine-Table-th {
font-weight: 800;
}
.mantine-datatable {
border-radius: var(--mantine-radius-default);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { ViewStore, ViewType, useViewStore } from '@/lib/client/store/view';
import { ViewStore, ViewType, useViewStore } from '@/lib/store/view';
import { Center, SegmentedControl } from '@mantine/core';
import { IconLayoutGrid, IconLayoutList } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
+57 -86
View File
@@ -1,11 +1,11 @@
import type { Response } from '@/lib/api/response';
import useAvatar from '@/lib/client/hooks/useAvatar';
import useLogin from '@/lib/client/hooks/useLogin';
import { useLogout } from '@/lib/client/hooks/useLogout';
import { useUserStore } from '@/lib/client/store/user';
import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
AppShell,
Avatar,
@@ -47,11 +47,11 @@ import {
IconUsersGroup,
} from '@tabler/icons-react';
import { useState } from 'react';
import { Link, NavigateFunction, Outlet, useLoaderData, useLocation, useNavigate } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { SETTINGS_EXTERNAL_LINKS } from './pages/serverSettings';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import { useLogout } from '@/lib/hooks/useLogout';
type NavLinks = {
label: string;
@@ -124,15 +124,9 @@ const navLinks: NavLinks[] = [
{
label: 'Settings',
icon: <IconAdjustments size='1rem' />,
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
active: (path: string) => path === '/dashboard/admin/settings',
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
label: name,
icon,
active: (path: string) => path === url,
href: url,
})),
},
{
label: 'Actions',
@@ -157,66 +151,6 @@ const navLinks: NavLinks[] = [
},
];
const renderLinks = (
links: NavLinks[],
pathname: string,
user: Response['/api/user']['user'],
config: SafeConfig,
navigate: NavigateFunction,
) => {
const visible = (link: NavLinks) => !link.if || link.if(user as Response['/api/user']['user'], config);
const active = (link: NavLinks): boolean => {
if (!visible(link)) return false;
if (link.active(pathname)) return true;
return (link.links || []).some((child) => active(child));
};
return links.map((link) => {
if (visible(link)) {
const sublinks = link.links;
const isActive = link.active(pathname);
if (!sublinks) {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={isActive}
component={Link}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={isActive && !sublinks.some((child) => active(child))}
defaultOpened={isActive || sublinks.some((child) => active(child))}
onClick={(event) => {
if (!link.href) return;
event.preventDefault();
navigate(link.href);
}}
>
{renderLinks(sublinks, pathname, user as Response['/api/user']['user'], config, navigate)}
</NavLink>
);
}
}
return null;
});
};
export default function Layout() {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -225,7 +159,6 @@ export default function Layout() {
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const navigate = useNavigate();
const logout = useLogout();
const loaderData = useLoaderData<typeof dashboardLoader>();
@@ -234,12 +167,6 @@ export default function Layout() {
const { user, mutate } = useLogin();
const { avatar } = useAvatar();
const [prev, setPrev] = useState(location.pathname);
if (prev !== location.pathname) {
setPrev(location.pathname);
setOpened(false);
}
const copyToken = () => {
modals.openConfirmModal({
title: 'Copy token?',
@@ -314,7 +241,6 @@ export default function Layout() {
color={theme.colors.gray[6]}
mr='xl'
hiddenFrom='sm'
bdrs='md'
/>
{config.website.titleLogo && (
@@ -395,9 +321,54 @@ export default function Layout() {
</Title>
<Divider hiddenFrom='sm' />
<ScrollArea mah='calc(100vh - 200px)'>
{renderLinks(navLinks, location.pathname, user as Response['/api/user']['user'], config, navigate)}
</ScrollArea>
{navLinks
.filter((link) => !link.if || link.if(user as Response['/api/user']['user'], config))
.map((link) => {
if (!link.links) {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={location.pathname === link.href}
component={Link}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
return (
<NavLink
key={link.label}
label={link.label}
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(location.pathname)}
>
{link.links
.filter(
(sublink) => !sublink.if || sublink.if(user as Response['/api/user']['user'], config),
)
.map((sublink) => (
<NavLink
key={sublink.label}
label={sublink.label}
leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={location.pathname === sublink.href}
component={Link}
to={sublink.href || ''}
prefetch='intent'
/>
))}
</NavLink>
);
}
})}
<div style={{ marginTop: 'auto' }}>
<VersionBadge />
@@ -424,7 +395,7 @@ export default function Layout() {
<AppShell.Main>
<ConfigProvider data={loaderData}>
<Paper withBorder m='md' p='xs' radius='md'>
<Paper m='lg' withBorder p='xs'>
<Outlet />
</Paper>
</ConfigProvider>
-118
View File
@@ -1,118 +0,0 @@
import { getDomain } from '@/lib/client/webDomain';
import { Button, Group, Image, Modal, Select, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconClipboardCheck, IconClipboardX, IconCopy, IconDownload } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
type Type = 'image/png' | 'image/jpeg' | 'image/webp';
const UNSUPPORTED_COPY = ['image/jpeg', 'image/webp'];
export default function QRCodeModal({
opened,
onClose,
url,
}: {
opened: boolean;
onClose: () => void;
url: string;
}) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [type, setType] = useState<Type>('image/png');
useEffect(() => {
if (!opened) return;
import('qrcode')
.then((QRCode) => QRCode.toDataURL(getDomain(url), { width: 500, type }))
.then(setDataUrl)
.catch(() => setDataUrl(null));
}, [opened, url, type]);
const copyImageToClipboard = async () => {
if (!dataUrl) return;
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
showNotification({
message: 'QR code image copied to clipboard',
color: 'green',
icon: <IconClipboardCheck size='1rem' />,
});
} catch (error) {
showNotification({
title: 'Failed to copy QR code image',
message: error instanceof Error ? error.message : String(error),
color: 'red',
icon: <IconClipboardX size='1rem' />,
});
}
};
const downloadImage = () => {
if (!dataUrl) return;
const link = document.createElement('a');
link.href = dataUrl;
link.style.display = 'none';
link.download = `qr-code.${type.split('/')[1]}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Modal title='QR Code' opened={opened} onClose={onClose} size='sm' centered>
{dataUrl ? (
<Image src={dataUrl} alt='QR Code' />
) : (
<Text c='red' ta='center'>
Failed to generate QR code.
</Text>
)}
<Select
mt='md'
label='Format'
value={type}
onChange={(value) => setType(value as Type)}
data={[
{ value: 'image/png', label: 'png' },
{ value: 'image/jpeg', label: 'jpeg' },
{ value: 'image/webp', label: 'webp' },
]}
size='xs'
/>
{dataUrl && (
<Group gap='xs' mt='md' grow>
<Tooltip
label={
UNSUPPORTED_COPY.includes(type)
? 'Copying this format is not supported in some browsers. You can copy the image normally via right-click or holding it.'
: ''
}
hidden={!UNSUPPORTED_COPY.includes(type)}
>
<Button
onClick={copyImageToClipboard}
leftSection={<IconCopy size='1rem' />}
disabled={UNSUPPORTED_COPY.includes(type)}
>
Copy Image
</Button>
</Tooltip>
<Button onClick={downloadImage} leftSection={<IconDownload size='1rem' />}>
Download
</Button>
</Group>
)}
</Modal>
);
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { Config } from '@/lib/config/validate';
import { useSettingsStore } from '@/lib/client/store/settings';
import { useUserStore } from '@/lib/client/store/user';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
import { ZiplineTheme, findTheme, themeComponents } from '@/lib/theme';
import dark_blue from '@/lib/theme/builtins/dark_blue';
import { MantineProvider, createTheme } from '@mantine/core';
@@ -74,6 +74,7 @@ export default function ThemeProvider({
forceColorScheme={theme.colorScheme as unknown as any}
theme={createTheme({
...themeComponents(theme),
defaultRadius: 'md',
})}
>
{children}
+5 -14
View File
@@ -1,4 +1,4 @@
import useVersion from '@/lib/client/hooks/useVersion';
import useVersion from '@/lib/hooks/useVersion';
import {
Anchor,
Badge,
@@ -14,11 +14,7 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
function DataDisplay({
items,
}: {
items: { label: string; value: string; href?: string; color?: string }[];
}) {
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
return (
<Paper withBorder p='sm'>
<Stack gap='xs'>
@@ -33,7 +29,7 @@ function DataDisplay({
{item.value}
</Anchor>
) : (
<Text c={item.color ?? undefined}>{item.value}</Text>
<Text>{item.value}</Text>
)}
</Flex>
))}
@@ -109,14 +105,10 @@ export default function VersionBadge() {
},
{
label: 'Commit',
value: version.version.sha!.slice(0, 7)!,
value: version.version.sha!,
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
},
{
label: 'Upstream?',
value: version.isUpstream ? 'Yes' : 'No',
color: version.isUpstream ? 'orange' : 'green',
},
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
]}
/>
@@ -139,7 +131,6 @@ export default function VersionBadge() {
{
label: 'Available to update',
value: version.latest.commit.pull ? 'Yes' : 'No',
color: version.latest.commit.pull ? 'green' : 'red',
},
]}
/>
@@ -1,22 +0,0 @@
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File } from '@/lib/db/models/file';
import FileModal from './FileModal';
import FileViewer from './FileViewer';
export default function DashboardFileModal(props: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const fileModal = useSettingsStore((state) => state.settings.fileViewer);
if (fileModal === 'default') {
return <FileModal {...props} />;
}
return <FileViewer {...props} />;
}
@@ -1,6 +1,6 @@
import { File } from '@/lib/db/models/file';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/client/hooks/useObjectState';
import useObjectState from '@/lib/hooks/useObjectState';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
@@ -120,7 +120,7 @@ export default function EditFileDetailsModal({
};
return (
<Modal zIndex={400} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
+13 -117
View File
@@ -2,16 +2,14 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
@@ -33,8 +31,6 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
@@ -52,11 +48,9 @@ import {
IconTextRecognition,
IconTrashFilled,
IconUpload,
IconUserQuestion,
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
@@ -78,16 +72,15 @@ function ActionButton({
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
} & ActionIconProps) {
}) {
return (
<Tooltip label={tooltip}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick} {...props}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick}>
<Icon size='1rem' />
</ActionIcon>
</Tooltip>
@@ -100,18 +93,15 @@ export default function FileModal({
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const [editFileOpen, setEditFileOpen] = useState(false);
@@ -190,34 +180,6 @@ export default function FileModal({
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) {
return [state.goPrev, state.goNext, false, false];
}
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open || !sequenced) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && hasPrev) {
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
goNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
@@ -237,7 +199,7 @@ export default function FileModal({
>
{file ? (
<>
{open && <DashboardFileType file={file} show />}
<DashboardFileType file={file} show />
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing='md' my='xs'>
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
@@ -267,7 +229,6 @@ export default function FileModal({
{file.originalName && (
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
)}
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
</SimpleGrid>
{!reduce && (
@@ -276,7 +237,12 @@ export default function FileModal({
<Title order={4} mt='lg' mb='xs'>
Tags
</Title>
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
<Combobox
zIndex={90000}
withinPortal={false}
store={tagsCombobox}
onOptionSubmit={handleValueSelect}
>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
@@ -352,7 +318,7 @@ export default function FileModal({
</Button>
) : (
<Combobox
zIndex={90000}
withinPortal={false}
store={folderCombobox}
onOptionSubmit={(value) => handleAdd(value)}
>
@@ -383,12 +349,6 @@ export default function FileModal({
</Combobox.Target>
<Combobox.Dropdown>
{folders?.length === 0 && (
<Combobox.Empty>
You have no folders. Start typing to create a new folder for this file.
</Combobox.Empty>
)}
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
@@ -468,70 +428,6 @@ export default function FileModal({
<></>
)}
</Modal>
{open && sequenced && fileNavButtons && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
</>
)}
</>
);
}
@@ -1,641 +0,0 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
Combobox,
Drawer,
Group,
Input,
InputBase,
Paper,
Pill,
PillsInput,
Stack,
Text,
Title,
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
IconExternalLink,
IconEyeFilled,
IconFileInfo,
IconFolderMinus,
IconInfoCircle,
IconPencil,
IconRefresh,
IconStar,
IconStarFilled,
IconTags,
IconTagsOff,
IconTextRecognition,
IconTrashFilled,
IconUpload,
IconUserQuestion,
IconX,
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
addToFolder,
copyFile,
createFolderAndAdd,
deleteFile,
downloadFile,
favoriteFile,
mutateFiles,
removeFromFolder,
viewFile,
} from '../actions';
import EditFileDetailsModal from './EditFileDetailsModal';
import FileStat from './FileStat';
function ActionButton({
Icon,
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
} & ActionIconProps) {
return (
<Tooltip label={tooltip} zIndex='200'>
<ActionIcon
size='xl'
variant='subtle'
bd='1px solid var(--mantine-color-dark-4)'
color={color ?? 'gray'}
onClick={onClick}
{...props}
>
<Icon size='1.15rem' />
</ActionIcon>
</Tooltip>
);
}
export default function FileViewer({
open,
setOpen,
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const { data: folders } = useFolders(user);
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const folderCombobox = useCombobox();
const [search, setSearch] = useState('');
const handleAdd = async (value: string) => {
if (value === '$create') {
await createFolderAndAdd(file!, search.trim());
} else {
await addToFolder(file!, value);
}
};
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
user ? `/api/users/${user}/tags` : '/api/user/tags',
);
const tagsCombobox = useCombobox();
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
const handleValueRemove = (val: string) => {
setValue((current) => current.filter((v) => v !== val));
};
const handleTagsUpdate = async () => {
if (value.length === file?.tags?.length && value.every((v) => file?.tags?.map((x) => x.id).includes(v))) {
return;
}
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/files/${file!.id}`,
'PATCH',
{
tags: value,
},
);
if (error) {
showNotification({
title: 'Failed to save tags',
message: error.error,
color: 'red',
icon: <IconTagsOff size='1rem' />,
});
} else {
showNotification({
title: 'Saved tags',
message: `Saved ${data!.tags!.length} tags for file ${data!.name}`,
color: 'green',
icon: <IconTags size='1rem' />,
});
}
mutateFiles();
mutate('/api/user/tags');
};
const triggerSave = async () => {
tagsCombobox.closeDropdown();
handleTagsUpdate();
};
const values = value.map((id) => <TagPill key={id} tag={tags?.find((t) => t.id === id) || null} />);
const [editFileOpen, setEditFileOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) return [state.goPrev, state.goNext, false, false];
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
return;
}
if (!sequenced) return;
if (event.key === 'ArrowLeft' && hasPrev) {
event.preventDefault();
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
event.preventDefault();
goNext();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext, setOpen]);
const headerActionGroup = file ? (
<ActionIcon.Group>
{!reduce && (
<>
<ActionButton
Icon={IconPencil}
onClick={() => setEditFileOpen(true)}
tooltip='Edit file details'
color='orange'
/>
<ActionButton
Icon={IconTrashFilled}
onClick={() => deleteFile(warnDeletion, file, setOpen)}
tooltip='Delete file'
color='red'
/>
<ActionButton
Icon={file.favorite ? IconStarFilled : IconStar}
onClick={() => favoriteFile(file)}
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
color={file.favorite ? 'gray' : 'yellow'}
/>
</>
)}
<ActionButton
Icon={IconInfoCircle}
onClick={() => setInfoOpen((v) => !v)}
tooltip={infoOpen ? 'Hide details' : 'Show details'}
color={infoOpen ? 'cyan' : 'gray'}
/>
<ActionButton
Icon={IconExternalLink}
onClick={() => viewFile(file)}
tooltip='Open in new tab'
color='blue'
/>
<ActionButton
Icon={IconClipboardTypography}
onClick={() => copyFile(file, clipboard, true)}
tooltip='Copy raw file link'
/>
<ActionButton Icon={IconCopy} onClick={() => copyFile(file, clipboard)} tooltip='Copy file link' />
<ActionButton Icon={IconDownload} onClick={() => downloadFile(file)} tooltip='Download' />
</ActionIcon.Group>
) : null;
return (
<>
{file && (
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file} />
)}
<Drawer
opened={infoOpen}
onClose={() => setInfoOpen(false)}
position='right'
title={<Title order={2}>Details</Title>}
radius='md'
offset={20}
overlayProps={{ blur: 6 }}
>
{file && (
<Stack gap='md'>
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
<FileStat Icon={IconDeviceSdCard} title='Size' value={bytes(file.size)} />
<FileStat
Icon={IconUpload}
title='Created at'
value={new Date(file.createdAt).toLocaleString()}
/>
<FileStat
Icon={IconRefresh}
title='Updated at'
value={new Date(file.updatedAt).toLocaleString()}
/>
{file.deletesAt && !reduce && (
<FileStat
Icon={IconBombFilled}
title='Deletes at'
value={new Date(file.deletesAt).toLocaleString()}
/>
)}
<FileStat
Icon={IconEyeFilled}
title='Views'
value={file.maxViews ? `${file.views} / ${file.maxViews}` : file.views}
/>
{file.originalName && (
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
)}
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
{!reduce && (
<>
<Box>
<Title order={4} mb='xs'>
Tags
</Title>
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
pointer
onClick={() => tagsCombobox.openDropdown()}
>
<Pill.Group>
{values.length > 0 ? (
values
) : (
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
)}
<Combobox.EventsTarget>
<PillsInput.Field
type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => {
if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
event.preventDefault();
handleValueRemove(value[value.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
{tags?.length ? (
tags.map((tag) => (
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
<Group gap='sm'>
<Checkbox
checked={value.includes(tag.id)}
onChange={() => {}}
aria-hidden
tabIndex={-1}
style={{ pointerEvents: 'none' }}
/>
<TagPill tag={tag} />
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Box>
<Box>
<Title order={4} mb='xs'>
Folder
</Title>
{file.folderId ? (
<Button
color='red'
leftSection={<IconFolderMinus size='1rem' />}
onClick={() => removeFromFolder(file)}
fullWidth
>
Remove from folder &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={search}
onChange={(event) => {
folderCombobox.openDropdown();
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
{folders?.length === 0 && (
<Combobox.Empty>
You have no folders. Start typing to create a new folder for this file.
</Combobox.Empty>
)}
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
</Box>
</>
)}
</Stack>
)}
</Drawer>
<Box
onClick={() => setOpen(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 220ms cubic-bezier(0.33, 1, 0.68, 1)',
willChange: 'opacity',
}}
>
<Paper m={0} p={0} withBorder bdrs={0} style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Stack gap='sm' px='lg' py='sm' onClick={(e) => e.stopPropagation()}>
<Group justify='space-between' align='center' gap='sm' wrap='nowrap' visibleFrom='sm'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<Group gap='sm' wrap='nowrap' style={{ flexShrink: 0 }}>
{headerActionGroup}
<ActionButton Icon={IconX} tooltip='Close' onClick={() => setOpen(false)} />
</Group>
</Group>
<Stack gap='sm' hiddenFrom='sm'>
<Group justify='space-between' align='flex-start' gap='sm' wrap='nowrap'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<ActionButton
Icon={IconX}
tooltip='Close'
onClick={() => setOpen(false)}
style={{ flexShrink: 0 }}
/>
</Group>
<Group gap={0} wrap='nowrap'>
{headerActionGroup}
</Group>
</Stack>
</Stack>
</Paper>
<Box
ref={setScrollParent}
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
paddingTop: '1rem',
paddingBottom: '1rem',
marginLeft: '1rem',
marginRight: '1rem',
overflow: 'auto',
position: 'relative',
overscrollBehavior: 'contain',
}}
>
{open && file ? (
<Box
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
alignSelf: 'stretch',
flex: 1,
minWidth: 0,
minHeight: 0,
width: '100%',
overflow: 'visible',
paddingLeft: '4rem',
paddingRight: '4rem',
}}
>
<DashboardFileType
key={file.id}
file={file}
show
fullscreen
allowZoom={false}
scrollParent={scrollParent}
/>
{sequenced && fileNavButtons && file && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
</>
)}
</Box>
) : null}
</Box>
</Box>
</>
);
}
+4 -21
View File
@@ -2,34 +2,17 @@ import type { File } from '@/lib/db/models/file';
import { Card } from '@mantine/core';
import { useState } from 'react';
import DashboardFileType from '../DashboardFileType';
import DashboardFileModal from './DashboardFileModal';
import FileModal from './FileModal';
import styles from './index.module.css';
export default function DashboardFile({
file,
reduce,
id,
onOpen,
}: {
file: File;
reduce?: boolean;
id?: string;
onOpen?: (fileId: string) => void;
}) {
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
const [open, setOpen] = useState(false);
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}
>
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
<DashboardFileType key={file.id} file={file} />
</Card>
</>
+320
View File
@@ -0,0 +1,320 @@
import type { File as DbFile } from '@/lib/db/models/file';
import { useSettingsStore } from '@/lib/store/settings';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Asciinema from '../render/Asciinema';
import Pdf from '../render/Pdf';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { useUserStore } from '@/lib/store/user';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
);
}
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
);
}
function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(5px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</div>
);
}
export default function DashboardFileType({
file,
show,
password,
code,
allowZoom,
}: {
file: DbFile | File;
show?: boolean;
password?: string | null;
code?: boolean;
allowZoom?: boolean;
}) {
const user = useUserStore((state) => state.user);
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
const thumbnailRoute = user
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
: `/raw/${(file as DbFile).thumbnail?.path}`;
const dbFile = 'id' in file;
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
const [fileContent, setFileContent] = useState('');
const [type, setType] = useState(file.type.split('/')[0]);
const [open, setOpen] = useState(false);
const getText = useCallback(async () => {
try {
if (!dbFile) {
const reader = new FileReader();
reader.onload = () => {
if ((reader.result! as string).length > 1 * 1024 * 1024) {
setFileContent(
reader.result!.slice(0, 1 * 1024 * 1024) +
'\n...\nThe file is too big to display click the download icon to view/download it.',
);
} else {
setFileContent(reader.result as string);
}
};
reader.readAsText(file);
return;
}
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
});
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
);
return;
}
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text);
} catch {
setFileContent('Error loading file.');
}
}, [dbFile, file, password]);
useEffect(() => {
if (code) {
setType('text');
getText();
} else if (type === 'text') {
getText();
} else {
return;
}
}, []);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [open]);
if (disableMediaPreview && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (dbFile && file.password === true && !show)
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
if (dbFile && file.password === true && show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(`/view/${file.name}${password ? `?pw=${password}` : ''}`)}
/>
</Paper>
);
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
switch (true) {
case type === 'video':
return show ? (
<video
width='100%'
autoPlay
muted
controls
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
<Center
pos='absolute'
h='100%'
top='50%'
left='50%'
style={{
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
) : (
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'image':
return show ? (
<Center>
<MantineImage
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
maxWidth: '70vw',
maxHeight: '70vw',
}}
onClick={() => setOpen(true)}
/>
{allowZoom && open && (
<FileZoomModal setOpen={setOpen}>
<MantineImage
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</Center>
) : (
<MantineImage
fit='contain'
mah={400}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name || 'Image'}
/>
);
case type === 'audio':
return show ? (
<audio
autoPlay
muted
controls
style={{ width: '100%' }}
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
/>
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'text':
return show ? (
fileContent.trim() === '' ? (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
) : (
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
)
) : (
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
);
case isAsciicast === true:
return show && dbFile ? (
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
case file.type === 'application/pdf':
return show && dbFile ? (
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
default:
if (dbFile && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (dbFile && show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
else return <IconFileUnknown size={48} />;
}
}
@@ -1,30 +0,0 @@
import { Box } from '@mantine/core';
export default function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<Box
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</Box>
);
}
@@ -1,34 +0,0 @@
import { Box } from '@mantine/core';
export default function FullscreenFrame({
fullscreen,
parent,
children,
}: {
fullscreen?: boolean;
parent?: HTMLElement | null;
children: React.ReactNode;
}) {
if (!fullscreen) return <>{children}</>;
return (
<Box
style={
parent
? {
width: '100%',
height: 'auto',
maxHeight: 'none',
overflow: 'visible',
}
: {
width: 'min(96vw, calc(100vw - 3rem))',
maxHeight: 'none',
overflow: 'visible',
}
}
>
{children}
</Box>
);
}
@@ -1,23 +0,0 @@
import { Center, Stack, Text } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
export default function Placeholder({
text,
Icon,
...props
}: {
text: string;
Icon: Icon;
onClick?: () => void;
}) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
@@ -1,294 +0,0 @@
import Asciinema from '@/components/render/Asciinema';
import Pdf from '@/components/render/Pdf';
import Render from '@/components/render/Render';
import { renderMode } from '@/components/render/renderMode';
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File as DbFile } from '@/lib/db/models/file';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import fileIcon from '../fileIcon';
import FileZoomModal from './FileZoomModal';
import FullscreenFrame from './FullscreenFrame';
import useFileContents from './useFileContent';
import useFileUrls, { isDbFile } from './useFileUrls';
export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
function FullscreenSizedMedia({ children }: { children: React.ReactNode }) {
return (
<Box
style={{
flex: 1,
alignSelf: 'stretch',
minHeight: 0,
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</Box>
);
}
export default function DashboardFileType({
file,
show,
token,
code,
allowZoom,
fullscreen,
scrollParent,
}: {
file: DbFile | File;
show?: boolean;
token?: string | null;
code?: boolean;
allowZoom?: boolean;
fullscreen?: boolean;
scrollParent?: HTMLElement | null;
}) {
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const mediaAutoMuted = useSettingsStore((state) => state.settings.mediaAutoMuted);
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
const db = isDbFile(file) ? file : null;
const extension = file.name.split('.').pop() || '';
const renderIn = renderMode(extension);
const type = code ? 'text' : file.type.split('/')[0];
const fileContent = useFileContents({ enabled: type === 'text', file, fileUrl });
const [zoomOpen, setZoomOpen] = useState(false);
useEffect(() => {
if (zoomOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [zoomOpen]);
if (disableMediaPreview && !show) {
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
}
if (db?.password === true && !show) {
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
}
if (db?.password === true && show) {
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(viewUrl!)}
/>
</Paper>
);
}
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
if (type === 'video') {
if (!fileUrl) return <Loader />;
if (!show) {
if (thumbnailUrl) {
return (
<Box pos='relative'>
<MantineImage src={thumbnailUrl} alt={file.name || 'Video thumbnail'} />
<Center pos='absolute' inset={0}>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
);
}
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
}
const video = (
<video
width={fullscreen ? undefined : '100%'}
autoPlay
muted={mediaAutoMuted}
controls
src={fileUrl}
style={{
cursor: 'pointer',
objectFit: 'contain',
...(fullscreen
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
: { maxWidth: '85vw', maxHeight: '85vh', width: '100%' }),
}}
/>
);
return fullscreen ? <FullscreenSizedMedia>{video}</FullscreenSizedMedia> : video;
}
if (type === 'image') {
if (!fileUrl) return <Loader />;
if (!show) {
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
}
const image = (
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
fit='contain'
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
objectFit: 'contain',
display: 'block',
...(fullscreen
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
: { maxWidth: '70vw', maxHeight: '70vw' }),
}}
onClick={() => allowZoom && setZoomOpen(true)}
/>
);
return (
<>
{fullscreen ? <FullscreenSizedMedia>{image}</FullscreenSizedMedia> : <Center>{image}</Center>}
{allowZoom && zoomOpen && (
<FileZoomModal setOpen={setZoomOpen}>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</>
);
}
if (type === 'audio') {
if (!fileUrl) return <Loader />;
return show ? (
<audio autoPlay muted={mediaAutoMuted} controls style={{ width: '100%' }} src={fileUrl} />
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (type === 'text') {
if (!show) return <Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />;
if (fileContent.trim() === '') {
return (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
);
}
return (
<FullscreenFrame fullscreen={fullscreen} parent={scrollParent}>
<Render
mode={renderIn}
language={extension}
code={fileContent}
noClamp={fullscreen}
scrollParent={scrollParent}
/>
</FullscreenFrame>
);
}
if (isAsciicast) {
if (!fileUrl) return <Loader />;
return show ? (
<FullscreenFrame fullscreen={fullscreen}>
<Asciinema src={fileUrl} />
</FullscreenFrame>
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
}
if (file.type === 'application/pdf') {
if (!fileUrl) return <Loader />;
return show ? (
fullscreen ? (
<Box style={{ height: 'calc(100vh - 7.5rem)', width: 'min(96vw, calc(100vw - 3rem))' }}>
<Pdf src={fileUrl} />
</Box>
) : (
<Pdf src={fileUrl} />
)
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(fileUrl)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
}
@@ -1,67 +0,0 @@
import type { File as DbFile } from '@/lib/db/models/file';
import useSWR from 'swr';
import { isDbFile } from './useFileUrls';
const MAX_BYTES = 1 * 1024 * 1024;
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
async function readBlobText(file: File) {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => resolve((reader.result ?? '') as string);
reader.readAsText(file);
});
return raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) + FILE_BIG : raw;
}
async function readText(fileUrl: string) {
const res = await fetch(fileUrl, {
headers: {
Range: `bytes=0-${MAX_BYTES}`,
},
});
if (!res.ok) throw new Error('Failed to fetch file');
return await res.text();
}
export default function useFileContent({
enabled,
file,
fileUrl,
}: {
enabled: boolean;
file: DbFile | File;
fileUrl: string;
}) {
const { data, error } = useSWR<string>(
() => {
if (!enabled) return null;
if (isDbFile(file)) return ['dbfile', file.id] as const;
const f = file as File;
return ['blobfile', f.name] as const;
},
async () => {
if (!isDbFile(file)) return readBlobText(file as File);
if (file.size > MAX_BYTES) {
const text = await readText(fileUrl);
return text + FILE_BIG;
}
return readText(fileUrl);
},
{
revalidateOnFocus: false,
shouldRetryOnError: false,
},
);
if (error) return 'Error loading file.';
return data ?? '';
}
@@ -1,36 +0,0 @@
import { useUserStore } from '@/lib/client/store/user';
import type { File as DbFile } from '@/lib/db/models/file';
import { useMemo } from 'react';
function appendToken(url: string, token?: string | null) {
if (!token) return url;
return `${url}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
export function isDbFile(file: DbFile | File): file is DbFile {
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
}
export default function useFileUrls({ file, token }: { file: DbFile | File; token?: string | null }): {
fileUrl: string;
thumbnailUrl: string | null;
viewUrl: string | null;
} {
const user = useUserStore((state) => state.user);
const blobUrl = useMemo(() => (isDbFile(file) ? null : URL.createObjectURL(file as File)), [file]);
return useMemo(() => {
if (!isDbFile(file)) return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
const thumb = file.thumbnail?.path;
const thumbnailUrl = thumb ? (user ? `/api/user/files/${thumb}/raw` : `/raw/${thumb}`) : null;
return {
fileUrl: appendToken(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, token),
viewUrl: appendToken(`/view/${file.name}`, token),
thumbnailUrl,
};
}, [token, blobUrl, file, user]);
}
+15 -10
View File
@@ -3,8 +3,7 @@ import { Response } from '@/lib/api/response';
import type { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { conditionalWarning } from '@/lib/client/warningModal';
import { getDomain } from '@/lib/client/webDomain';
import { conditionalWarning } from '@/lib/warningModal';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -30,11 +29,13 @@ export function downloadFile(file: File) {
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = raw
? getDomain(`/raw/${file.name}`)
? `${domain}/raw/${file.name}`
: file.url
? getDomain(`${file.url}`)
: getDomain(`/view/${file.name}`);
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
clipboard.copy(url);
@@ -140,10 +141,14 @@ export async function createFolderAndAdd(file: File, folderName: string | null)
}
export async function removeFromFolder(file: File) {
const { data, error } = await fetchApi<{ folder: Folder }>(`/api/user/folders/${file.folderId}`, 'DELETE', {
delete: 'file',
id: file.id,
});
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/folders/${file.folderId}`,
'DELETE',
{
delete: 'file',
id: file.id,
},
);
if (error) {
notifications.show({
@@ -155,7 +160,7 @@ export async function removeFromFolder(file: File) {
} else {
notifications.show({
title: 'File removed from folder',
message: `${file.name} has been removed from ${data?.folder.name}`,
message: `${file.name} has been removed from ${data!.name}`,
color: 'green',
icon: <IconFolderMinus size='1rem' />,
});
+116 -120
View File
@@ -2,8 +2,7 @@ import { useConfig } from '@/components/ConfigProvider';
import Stat from '@/components/Stat';
import type { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/client/hooks/useLogin';
import { useSettingsStore } from '@/lib/client/store/settings';
import useLogin from '@/lib/hooks/useLogin';
import { isAdministrator } from '@/lib/role';
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import {
@@ -18,12 +17,11 @@ import { lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
const ActivityChart = lazy(() => import('./parts/ActivityChart'));
const Recents = lazy(() => import('./parts/Recents'));
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function DashboardHome() {
const { user } = useLogin();
const { homeShowActivity, homeShowRecents, homeShowTypes } = useSettingsStore((state) => state.settings);
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
const config = useConfig();
@@ -40,32 +38,6 @@ 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' ? (
@@ -88,9 +60,41 @@ 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'
@@ -109,98 +113,90 @@ 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>
) : (
<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' }}>
{[...Array(8)].map((_, i) => (
<Skeleton key={i} height={105} />
))}
</SimpleGrid>
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
</SimpleGrid>
)}
<Title order={3} mt='lg' mb='xs'>
File types
</Title>
{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>
<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.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.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.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>
</>
)
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
</>
) : (
<>
<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>
{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>
</>
)}
</>
)}
</>
);
@@ -1,204 +0,0 @@
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>
);
}
@@ -1,36 +0,0 @@
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,13 +1,14 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { 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, DashboardFilesModalsUpdate } from '.';
import { DashboardFilesModals } from '.';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
@@ -37,7 +38,7 @@ export default function PendingFilesModal({
setModals,
}: {
modals: DashboardFilesModals;
setModals: DashboardFilesModalsUpdate;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
@@ -71,7 +72,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)}>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
+12 -1
View File
@@ -1,4 +1,4 @@
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import {
closestCenter,
DndContext,
@@ -14,6 +14,17 @@ import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
import { IconGripVertical } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
export const NAMES = {
name: 'Name',
originalName: 'Original Name',
tags: 'Tags',
type: 'Type',
size: 'Size',
createdAt: 'Created At',
favorite: 'Favorite',
views: 'Views',
};
function SortableTableField({ item }: { item: FieldSettings }) {
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
+12 -21
View File
@@ -1,5 +1,6 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { useViewStore } from '@/lib/client/store/view';
import useObjectState from '@/lib/hooks/useObjectState';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
import {
IconDots,
@@ -9,7 +10,6 @@ import {
IconTableOptions,
IconTags,
} from '@tabler/icons-react';
import { parseAsBoolean, useQueryStates } from 'nuqs';
import { Link } from 'react-router-dom';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from './tags/TagsModal';
@@ -24,21 +24,15 @@ 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 [modals, setModals] = useModals();
const [modals, setModals] = useObjectState<DashboardFilesModals>({
table: false,
idSearch: false,
tags: false,
pending: false,
});
return (
<>
@@ -65,15 +59,12 @@ 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>
@@ -82,13 +73,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'>
<Button type='submit' variant='outline' radius='sm'>
Create tag
</Button>
</Stack>
@@ -99,7 +99,7 @@ export default function EditTagModal({
{...form.getInputProps('color')}
/>
<Button type='submit' variant='outline' disabled={!form.isDirty}>
<Button type='submit' variant='outline' radius='sm' disabled={!form.isDirty}>
Edit tag
</Button>
</Stack>
+2 -7
View File
@@ -11,13 +11,8 @@ export default function TagPill({
if (!tag) return null;
return (
<Pill
bg={tag.color || undefined}
c={isLightColor(tag.color) ? 'black' : 'white'}
title={tag.name}
{...other}
>
{tag.name.length <= 24 ? tag.name : tag.name.slice(0, 21) + '...'}
<Pill bg={tag.color || undefined} c={isLightColor(tag.color) ? 'black' : 'white'} {...other}>
{tag.name}
</Pill>
);
}
@@ -2,12 +2,13 @@ import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { 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, DashboardFilesModalsUpdate } from '..';
import { DashboardFilesModals } from '..';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
@@ -17,7 +18,7 @@ export default function TagsModals({
setModals,
}: {
modals: DashboardFilesModals;
setModals: DashboardFilesModalsUpdate;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -54,7 +55,7 @@ export default function TagsModals({
<Modal
opened={modals.tags}
onClose={() => setModals({ tags: false })}
onClose={() => setModals('tags', false)}
title={
<Group>
<Title>Tags</Title>
@@ -1,8 +1,7 @@
import type { Response } from '@/lib/api/response';
import { Response } from '@/lib/api/response';
import useSWR from 'swr';
type ApiPaginationOptions = {
route?: string;
page?: number;
filter?: string;
perpage?: number;
@@ -27,15 +26,14 @@ type ApiPaginationOptions = {
};
};
const fetcher = async <T,>(
const fetcher = async (
{ options }: { options: ApiPaginationOptions; key: string } = {
options: {
page: 1,
},
key: '/api/user/files',
},
): Promise<T> => {
const route = options.route ?? '/api/user/files';
): Promise<Response['/api/user/files']> => {
const searchParams = new URLSearchParams();
if (options.page) searchParams.append('page', options.page.toString());
if (options.filter) searchParams.append('filter', options.filter);
@@ -50,7 +48,7 @@ const fetcher = async <T,>(
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`${route}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
if (!res.ok) {
const json = await res.json();
@@ -61,18 +59,14 @@ const fetcher = async <T,>(
return res.json();
};
export function useApiPagination<T = Response['/api/user/files']>(
export function useApiPagination(
options: ApiPaginationOptions = {
page: 1,
},
swrConfig?: Parameters<typeof useSWR<T>>[2],
) {
const { data, error, isLoading, mutate } = useSWR<T>(
{ key: options.route ?? '/api/user/files', options },
{
fetcher: (k) => fetcher<T>(k),
...swrConfig,
},
const { data, error, isLoading, mutate } = useSWR<Response['/api/user/files']>(
{ key: '/api/user/files', options },
{ fetcher },
);
return {
@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -15,12 +16,11 @@ 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', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
@@ -1,5 +1,4 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Button,
Center,
@@ -14,19 +13,17 @@ import {
Title,
} from '@mantine/core';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { lazy, Suspense, useEffect, useMemo } from 'react';
import { lazy, Suspense, useState } from 'react';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import { useApiPagination } from '../useApiPagination';
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45, 60];
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const { data, isLoading } = useApiPagination({
page,
@@ -40,28 +37,8 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 1;
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => {
if (!open) setCurrent(null);
}}
file={currentFile}
user={id}
sequenced
/>
<SimpleGrid
my='sm'
cols={{
@@ -77,7 +54,7 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} id={id} onOpen={(fileId) => setCurrent(fileId)} />
<DashboardFile file={file} id={id} />
</Suspense>
))
) : (
@@ -3,13 +3,13 @@ import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/compo
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
import { useSettingsStore } from '@/lib/client/store/settings';
import { type File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
Box,
@@ -30,7 +30,7 @@ import {
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard, useDebouncedValue } from '@mantine/hooks';
import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconDownload,
@@ -40,25 +40,25 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { parseAsInteger, useQueryState } from 'nuqs';
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
import TableEditModal from '../TableEditModal';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { DashboardFilesModals } from '..';
import TableEditModal, { NAMES } from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
action: { field: string; query: string };
};
const PER_PAGE_OPTIONS = [10, 20, 50, 70, 100];
const PER_PAGE_OPTIONS = [10, 20, 50];
function SearchFilter({
setSearchField,
@@ -187,7 +187,7 @@ export default function FileTable({
id?: string;
folderId?: string;
modals?: Partial<DashboardFilesModals>;
setModals?: DashboardFilesModalsUpdate;
setModals?: UpdateFn<DashboardFilesModals>;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -201,8 +201,8 @@ export default function FileTable({
return buildFolderHierarchy(folders);
}, [folders]);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(20));
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
const [sort, setSort] = useState<
| 'id'
| 'createdAt'
@@ -231,7 +231,7 @@ export default function FileTable({
}),
{ name: '', originalName: '', type: '', tags: '', id: '' },
);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -266,15 +266,10 @@ export default function FileTable({
}),
});
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const selectedFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
const selectedFile = selectedFileId
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
: null;
const FIELDS = [
{
@@ -347,7 +342,6 @@ export default function FileTable({
{
accessor: 'favorite',
sortable: true,
title: 'Favorite?',
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
},
{
@@ -360,12 +354,6 @@ export default function FileTable({
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
},
{
accessor: 'anonymous',
sortable: true,
title: 'Anonymous?',
render: (file: File) => (file.anonymous ? <Text c='green'>Yes</Text> : 'No'),
},
];
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
@@ -379,24 +367,29 @@ export default function FileTable({
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
return (
<>
<DashboardFileModal
<FileModal
open={!!selectedFile}
setOpen={(open) => {
if (!open) setCurrent(null);
if (!open) setSelectedFile(null);
}}
file={selectedFile}
user={id}
sequenced
/>
{modals && setModals && (
<TableEditModal opened={!!modals.table} onClose={() => setModals({ table: false })} />
{modals && setModals && modals.table && (
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
)}
<Box>
<Collapse expanded={selectedFiles.length > 0}>
<Collapse in={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
<Text size='sm' c='dimmed' mb='xs'>
Selections are saved across page changes
@@ -487,7 +480,7 @@ export default function FileTable({
</Collapse>
{modals && setModals && modals.idSearch && (
<Collapse expanded={modals.idSearch}>
<Collapse in={modals.idSearch}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
@@ -508,6 +501,7 @@ export default function FileTable({
{/*@ts-ignore*/}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
@@ -586,7 +580,7 @@ export default function FileTable({
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setCurrent(record.id)}
onCellClick={({ record }) => setSelectedFile(record.id)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -15,12 +16,11 @@ 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', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 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' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
<Group justify='space-between'>
<Group gap='xs'>
+4 -6
View File
@@ -1,7 +1,6 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { getDomain } from '@/lib/client/webDomain';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -10,14 +9,13 @@ import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
const url = getDomain(`/folder/${folder.id}`);
clipboard.copy(url);
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} to={`/folder/${folder.id}`}>
{url}
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
</Anchor>
),
color: 'green',
@@ -50,7 +48,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
});
}
mutateFolder();
mutateFolder(folder.id);
}
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
@@ -78,7 +76,7 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
});
}
mutateFolder();
mutateFolder(folder.id);
}
export async function mutateFolder(folderId?: string) {
+4 -4
View File
@@ -3,8 +3,8 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import { SEPARATOR, useTitle } from '@/lib/client/hooks/useTitle';
import { useViewStore } from '@/lib/client/store/view';
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
import { useViewStore } from '@/lib/store/view';
import {
Alert,
Anchor,
@@ -158,7 +158,7 @@ export default function DashboardFolders() {
{...form.getInputProps('isPublic', { type: 'checkbox' })}
/>
<Button type='submit' variant='outline' leftSection={<IconFolderPlus size='1rem' />}>
<Button type='submit' variant='outline' radius='sm' leftSection={<IconFolderPlus size='1rem' />}>
Create
</Button>
</Stack>
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse expanded={filesOpen}>
<Collapse in={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconFolderSymlink } from '@tabler/icons-react';
@@ -168,6 +168,7 @@ export default function FolderTableView({
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
+5 -14
View File
@@ -1,25 +1,19 @@
import RelativeDate from '@/components/RelativeDate';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/client/store/settings';
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconDots, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react';
import { copyInviteUrl, deleteInvite } from './actions';
import { useClipboard } from '@mantine/hooks';
import { useSettingsStore } from '@/lib/store/settings';
export default function InviteCard({
invite,
setQrOpen,
}: {
invite: Invite;
setQrOpen: (invite: Invite) => void;
}) {
export default function InviteCard({ invite }: { invite: Invite }) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
return (
<>
<Card withBorder shadow='sm'>
<Card withBorder shadow='sm' radius='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group justify='space-between'>
<Anchor href={`/invite/${invite.code}`} target='_blank' fw={400}>
@@ -42,9 +36,6 @@ export default function InviteCard({
>
Copy URL
</Menu.Item>
<Menu.Item leftSection={<IconQrcode size='1rem' />} onClick={() => setQrOpen(invite)}>
Show QR Code
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
+3 -5
View File
@@ -1,8 +1,7 @@
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { conditionalWarning } from '@/lib/client/warningModal';
import { getDomain } from '@/lib/client/webDomain';
import { conditionalWarning } from '@/lib/warningModal';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
@@ -19,14 +18,13 @@ export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
}
export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useClipboard>) {
const url = getDomain(`/invite/${invite.code}`);
clipboard.copy(url);
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} to={`/invite/${invite.code}`}>
{url}
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
</Anchor>
),
color: 'green',
+8 -2
View File
@@ -2,7 +2,7 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/client/store/view';
import { useViewStore } from '@/lib/store/view';
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
@@ -96,7 +96,13 @@ export default function DashboardInvites() {
{...form.getInputProps('maxUses')}
/>
<Button type='submit' variant='outline' fullWidth leftSection={<IconPlus size='1rem' />}>
<Button
type='submit'
variant='outline'
fullWidth
radius='sm'
leftSection={<IconPlus size='1rem' />}
>
Create
</Button>
</Stack>
@@ -4,23 +4,13 @@ import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '
import { IconLink } from '@tabler/icons-react';
import useSWR from 'swr';
import InviteCard from '../InviteCard';
import { useState } from 'react';
import QRCodeModal from '@/components/QRCodeModal';
export default function InviteGridView() {
const { data: folders, isLoading } =
useSWR<Extract<Response['/api/auth/invites'], Invite[]>>('/api/auth/invites');
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
return (
<>
<QRCodeModal
opened={!!qrOpen}
onClose={() => setQrOpen(null)}
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
/>
{isLoading ? (
<SimpleGrid
my='sm'
@@ -48,7 +38,7 @@ export default function InviteGridView() {
pos='relative'
>
{folders?.map((invite) => (
<InviteCard setQrOpen={setQrOpen} key={invite.id} invite={invite} />
<InviteCard key={invite.id} invite={invite} />
))}
</SimpleGrid>
) : (
@@ -1,15 +1,14 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/client/store/settings';
import { useSettingsStore } from '@/lib/store/settings';
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyInviteUrl, deleteInvite } from '../actions';
import QRCodeModal from '@/components/QRCodeModal';
export default function InviteTableView() {
const clipboard = useClipboard();
@@ -37,18 +36,11 @@ export default function InviteTableView() {
});
}, [data, sortStatus]);
const [qrOpen, setQrOpen] = useState<Invite | null>(null);
return (
<>
<QRCodeModal
opened={!!qrOpen}
onClose={() => setQrOpen(null)}
url={qrOpen ? `/invite/${qrOpen.code}` : ''}
/>
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
@@ -109,16 +101,6 @@ export default function InviteTableView() {
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Show QR code'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setQrOpen(invite);
}}
>
<IconQrcode size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete invite'>
<ActionIcon
color='red'
+25 -32
View File
@@ -14,39 +14,32 @@ export default function TotpModal({
}) {
return (
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
<form onSubmit={onVerify}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button
leftSection={<IconShieldQuestion size='1rem' />}
loading={state.disabled}
onClick={onVerify}
type='submit'
>
Verify
</Button>
</Group>
</form>
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
Verify
</Button>
</Group>
</Modal>
);
}
+7 -7
View File
@@ -3,11 +3,11 @@ import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { lazy, useState } from 'react';
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
import { StatsCardsSkeleton } from './parts/StatsCards';
import { StatsTablesSkeleton } from './parts/StatsTables';
import { useApiStats } from './useStats';
const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph'));
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
const StatsCards = lazy(() => import('./parts/StatsCards'));
@@ -133,16 +133,16 @@ export default function DashboardMetrics() {
<StatsCardsSkeleton />
<StatsTablesSkeleton />
</div>
) : data?.points.length ? (
) : data?.length ? (
<div>
<StatsCards points={data.points} />
<StatsTables latest={data.latest} />
<StatsCards data={data} />
<StatsTables data={data} />
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
<FilesUrlsCountGraph points={data.points} />
<ViewsGraph points={data.points} />
<FilesUrlsCountGraph metrics={data} />
<ViewsGraph metrics={data} />
</SimpleGrid>
<div>
<StorageGraph points={data.points} />
<StorageGraph metrics={data} />
</div>
</div>
) : (
@@ -1,28 +1,23 @@
import { MetricsPoint } from '@/lib/metrics';
import { Metric } from '@/lib/db/models/metric';
import { ChartTooltip, LineChart } from '@mantine/charts';
import { Paper, Title } from '@mantine/core';
import { useMemo } from 'react';
import { defaultChartProps } from '../statsHelpers';
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],
export default function FilesUrlsCountGraph({ metrics }: { metrics: Metric[] }) {
const sortedMetrics = metrics.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return (
<Paper radius='md' withBorder p='sm'>
<Paper radius='sm' withBorder p='sm'>
<Title order={3}>Count</Title>
<LineChart
data={data}
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
files: metric.data.files,
urls: metric.data.urls,
}))}
series={[
{
name: 'files',
@@ -1,5 +1,5 @@
import { bytes } from '@/lib/bytes';
import { MetricsPoint } from '@/lib/metrics';
import { Metric } from '@/lib/db/models/metric';
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
import {
IconArrowDown,
@@ -21,8 +21,8 @@ function StatCard({
Icon,
}: {
title: string;
first: number | bigint;
last: number | bigint;
first: number;
last: number;
Icon: TablerIcon;
formatter?: (value: number) => string;
}) {
@@ -35,9 +35,9 @@ function StatCard({
}[color];
return (
<Paper radius='md' withBorder p='sm'>
<Paper radius='sm' withBorder p='sm'>
<Group justify='space-between'>
<Text size='xl' fw={900}>
<Text size='xl' fw='bolder'>
{title}
</Text>
@@ -45,8 +45,8 @@ function StatCard({
</Group>
<Group justify='flex-start' gap='xs'>
<Text size='lg' fw={600}>
{formatter ? formatter(Number(first)) : first}
<Text size='xl' fw='bolder'>
{formatter ? formatter(first) : first}
</Text>
<Paper
@@ -54,6 +54,7 @@ function StatCard({
py={2}
pl={5}
pr={8}
radius='sm'
display='flex'
bg={rgba(`var(--mantine-color-${color}-6)`, 0.25)}
>
@@ -86,11 +87,14 @@ export function StatsCardsSkeleton() {
);
}
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 = points[0];
const last = points[points.length - 1];
const recent = sortedMetrics[0];
const last = sortedMetrics[sortedMetrics.length - 1];
return (
<SimpleGrid
@@ -101,18 +105,28 @@ export default function StatsCards({ points }: { points: MetricsPoint[] }) {
}}
mb='sm'
>
<StatCard title='Files' first={recent.files} last={last.files} Icon={IconFiles} />
<StatCard title='URLs' first={recent.urls} last={last.urls} Icon={IconLink} />
<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='Storage Used'
first={recent.storage}
last={last.storage}
first={recent.data.storage}
last={last.data.storage}
formatter={bytes}
Icon={IconDatabase}
/>
<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} />
<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}
/>
</SimpleGrid>
);
}
@@ -17,7 +17,7 @@ export function StatsTablesSkeleton() {
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='md' withBorder>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
@@ -42,7 +42,7 @@ export function StatsTablesSkeleton() {
</ScrollArea.Autosize>
</Paper>
<Paper withBorder mah={500} radius='md'>
<Paper radius='sm' withBorder mah={500}>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
@@ -65,7 +65,7 @@ export function StatsTablesSkeleton() {
</ScrollArea.Autosize>
</Paper>
<Paper withBorder radius='md'>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
@@ -86,7 +86,7 @@ export function StatsTablesSkeleton() {
</ScrollArea.Autosize>
</Paper>
<Paper withBorder p='sm'>
<Paper radius='sm' withBorder p='sm'>
<Skeleton height={500} />
</Paper>
</SimpleGrid>
@@ -94,18 +94,18 @@ export function StatsTablesSkeleton() {
);
}
export default function StatsTables({ latest }: { latest: Metric | null }) {
if (!latest) return null;
export default function StatsTables({ data }: { data: Metric[] }) {
if (!data.length) return null;
const recent = latest;
const recent = data[0]; // it is sorted by desc so 0 is the first one.
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
return (
<>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Paper radius='md' withBorder>
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
@@ -131,8 +131,8 @@ export default function StatsTables({ latest }: { latest: Metric | null }) {
</ScrollArea.Autosize>
</Paper>
<Paper radius='md' withBorder mah={500}>
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
<Paper radius='sm' withBorder mah={500}>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
@@ -156,8 +156,8 @@ export default function StatsTables({ latest }: { latest: Metric | null }) {
</ScrollArea.Autosize>
</Paper>
<Paper radius='md' withBorder>
<ScrollArea.Autosize mah={500} type='auto' bdrs='md'>
<Paper radius='sm' withBorder>
<ScrollArea.Autosize mah={500} type='auto'>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
@@ -179,7 +179,7 @@ export default function StatsTables({ latest }: { latest: Metric | null }) {
</ScrollArea.Autosize>
</Paper>
<Paper radius='md' withBorder p='sm'>
<Paper radius='sm' withBorder p='sm'>
<TypesPieChart metric={recent} />
</Paper>
</SimpleGrid>
@@ -1,30 +1,25 @@
import { bytes } from '@/lib/bytes';
import { MetricsPoint } from '@/lib/metrics';
import { ChartTooltip, LineChart } from '@mantine/charts';
import { Metric } from '@/lib/db/models/metric';
import { LineChart, ChartTooltip } from '@mantine/charts';
import { Paper, Title } from '@mantine/core';
import { useMemo } from 'react';
import { defaultChartProps } from '../statsHelpers';
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],
export default function StorageGraph({ metrics }: { metrics: Metric[] }) {
const sortedMetrics = metrics.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return (
<Paper radius='md' withBorder p='sm' mt='md'>
<Paper radius='sm' withBorder p='sm' mt='md'>
<Title order={3} mb='sm'>
Storage Used
</Title>
<LineChart
data={data}
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
storage: metric.data.storage,
}))}
series={[
{
name: 'storage',
@@ -1,27 +1,22 @@
import { MetricsPoint } from '@/lib/metrics';
import { Metric } from '@/lib/db/models/metric';
import { ChartTooltip, LineChart } from '@mantine/charts';
import { Paper, Title } from '@mantine/core';
import { useMemo } from 'react';
import { defaultChartProps } from '../statsHelpers';
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],
export default function ViewsGraph({ metrics }: { metrics: Metric[] }) {
const sortedMetrics = metrics.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return (
<Paper radius='md' withBorder p='sm'>
<Paper radius='sm' withBorder p='sm'>
<Title order={3}>Views</Title>
<LineChart
data={data}
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
files: metric.data.fileViews,
urls: metric.data.urlViews,
}))}
series={[
{
name: 'files',
+1 -4
View File
@@ -13,10 +13,7 @@ export const defaultChartProps: Partial<LineChartProps> & { dataKey: string } =
dataKey: 'date',
};
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);
export function percentChange(a: number, b: number): [string, string] {
const change = Math.round(((b - a) / a) * 100);
const color = change > 0 ? 'green' : change < 0 ? 'red' : 'gray';
@@ -5,7 +5,14 @@ const ICON_SIZE = '1.75rem';
export default function ActionButton({ onClick, Icon }: { onClick: () => void; Icon?: React.FC<any> }) {
return (
<ActionIcon onClick={onClick} variant='filled' radius='md' size='xl' className='zip-click-action-button'>
<ActionIcon
onClick={onClick}
variant='filled'
color='blue'
radius='md'
size='xl'
className='zip-click-action-button'
>
{Icon ? <Icon size={ICON_SIZE} /> : <IconPlayerPlayFilled size={ICON_SIZE} />}
</ActionIcon>
);
@@ -139,7 +139,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse expanded={osOpened}>
<Collapse in={osOpened}>
<HighlightCode language='json' code={JSON.stringify(export3.request.os, null, 2)} />
</Collapse>
@@ -147,7 +147,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse expanded={envOpened}>
<Collapse in={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -195,7 +195,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse expanded={osOpened}>
<Collapse in={osOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -217,7 +217,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse expanded={envOpened}>
<Collapse in={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
@@ -32,7 +32,7 @@ export default function Export4ImportSettings({
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
<Collapse expanded={showSettings}>
<Collapse in={showSettings}>
<Paper withBorder>
<Table>
<Table.Thead>

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