mirror of
https://github.com/diced/zipline.git
synced 2026-06-22 22:36:05 -07:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2590aae5 | |||
| 93ff18a120 | |||
| 4343f130fb | |||
| 5e9778d18a | |||
| 9bcccbc8aa | |||
| 00ddf86ea8 | |||
| cc582f6d20 | |||
| 318b09feae | |||
| d55e36375d | |||
| 40b917df30 | |||
| 053a50d1bc | |||
| 78b554cbe8 | |||
| bf7a4e92e3 | |||
| 3b56e7f1ce | |||
| 1d91a008e1 | |||
| ff1fc0eb75 | |||
| 430774082c | |||
| ef67fdd553 | |||
| 73b5528586 | |||
| 3f65029464 | |||
| 6da1719fda | |||
| 4d85b41ec3 | |||
| ac5c0a1cb3 | |||
| eb22598f20 | |||
| 7a4c29d9d4 | |||
| 255336d74f | |||
| dc625fc682 | |||
| d457cb8693 | |||
| 331c4b4a4e | |||
| d8ca9dc9b5 | |||
| 0eee082035 | |||
| 3e287e8ad7 | |||
| 5ec471050e | |||
| 842dac2660 | |||
| dee86aaa86 | |||
| 13e3a58035 | |||
| f4382d5bd9 | |||
| 8990801268 | |||
| 01b9c06513 | |||
| fc180de616 | |||
| f907133d3a | |||
| 9ae9734a3d | |||
| 770b5cf706 | |||
| 56625c664d | |||
| 056a19b946 | |||
| 281ab666c1 | |||
| 31df5341b5 | |||
| ec7024242f | |||
| ef6e0e00a0 | |||
| 3c757374e1 | |||
| c0e1aa9ac6 | |||
| 40fd0b19eb | |||
| 41240b7aff | |||
| 01f177fbc3 | |||
| ab1d394a46 | |||
| d08f1ba5da | |||
| 641a7c9b7b | |||
| a467ffe861 | |||
| 33ff667990 | |||
| e96015f5e0 | |||
| d4d1cdc885 | |||
| a7d831934d | |||
| e9ef6a2d40 | |||
| 7520efa835 | |||
| cff8454ac7 | |||
| 847779601a | |||
| 49c2088ea3 | |||
| 78600103af | |||
| ce8b3ed36d | |||
| 67641c2116 | |||
| acbbb7d40a | |||
| 1f672cda3a | |||
| 2332d529e0 | |||
| e910fe9da5 | |||
| 4656599bb0 | |||
| d6c33b6123 | |||
| defcc7950d | |||
| 3d55ce0def | |||
| 8c9df5af5d | |||
| 5c33ae134a | |||
| b628489330 | |||
| e9a6e31d4f | |||
| ebe37cf7c1 | |||
| 529708110b | |||
| 9066dd37fb | |||
| 45848925f4 | |||
| 2ba1da1671 | |||
| 35c7d6b70c | |||
| f45d1b770f | |||
| 3650178ab3 |
Executable → Regular
Executable → Regular
@@ -17,7 +17,7 @@ body:
|
||||
id: runtime-type
|
||||
attributes:
|
||||
label: How is Zipline being run?
|
||||
description:
|
||||
description:
|
||||
options:
|
||||
- On docker (docker, docker compose, etc.)
|
||||
- Built from source (running it through `pnpm start` or `node`, etc.)
|
||||
@@ -34,7 +34,7 @@ body:
|
||||
- If version checking is enabled (it is by default): paste the response from `http://<domain>/api/version`
|
||||
- If using docker (and can't do the above): specify the tag you are using (`latest`, `trunk`, or a tag digest)
|
||||
- A simple version number (e.g. "4.2.1") may also suffice
|
||||
placeholder: "4.2.1"
|
||||
placeholder: '4.2.1'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -68,4 +68,3 @@ body:
|
||||
description: |
|
||||
Please list the exact steps required to reproduce the issue.
|
||||
Include any relevant configuration options, settings, or external services that may affect Zipline’s functionality.
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -13,7 +13,7 @@ jobs:
|
||||
matrix:
|
||||
node: [24.x]
|
||||
arch: [amd64]
|
||||
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
services:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Install
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
@@ -78,13 +78,12 @@ jobs:
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run app
|
||||
- name: Run generator
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
ZIPLINE_OUTPUT_OPENAPI: true
|
||||
|
||||
run: pnpm start
|
||||
NODE_ENV: production
|
||||
run: pnpm openapi
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+8
-3
@@ -33,8 +33,6 @@ COPY code.json ./code.json
|
||||
COPY vite-env.d.ts ./vite-env.d.ts
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN ZIPLINE_BUILD=true pnpm run build
|
||||
|
||||
FROM base
|
||||
@@ -52,8 +50,15 @@ RUN pnpm prisma generate
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ZIPLINE_ROOT=/zipline
|
||||
|
||||
ARG ZIPLINE_GIT_SHA
|
||||
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
|
||||
|
||||
CMD ["node", "--enable-source-maps", "build/server"]
|
||||
# add scripts
|
||||
COPY docker/entrypoint.sh /zipline/entrypoint
|
||||
COPY docker/ziplinectl.sh /zipline/ziplinectl
|
||||
|
||||
RUN ln -s /zipline/ziplinectl /usr/local/bin/ziplinectl
|
||||
|
||||
ENTRYPOINT ["/zipline/entrypoint"]
|
||||
|
||||
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
|
||||
|
||||
This is the recommended way to run Zipline:
|
||||
|
||||
```yml
|
||||
```yaml
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -260,10 +260,6 @@ DATASOURCE_LOCAL_DIRECTORY="/path/to/your/local/files"
|
||||
# DATASOURCE_S3_BUCKET="your-bucket"
|
||||
# DATASOURCE_S3_ENDPOINT="your-endpoint"
|
||||
# ^ if using a custom endpoint other than aws s3
|
||||
|
||||
# optional but both are required if using ssl
|
||||
# SSL_KEY="/path/to/your/ssl/key"
|
||||
# SSL_CERT="/path/to/your/ssl/cert"
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 4.2.x | :white_check_mark: |
|
||||
| 4.4.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
cd ${ZIPLINE_ROOT:-/zipline}
|
||||
exec node --enable-source-maps build/server
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
cd ${ZIPLINE_ROOT:-/zipline}
|
||||
exec node --enable-source-maps build/ctl "$@"
|
||||
|
||||
Generated
+645
-39
@@ -6,7 +6,8 @@
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv"
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
@@ -18,11 +19,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748883665,
|
||||
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -32,22 +33,142 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_2": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_3": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"git-hooks": "git-hooks_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_2",
|
||||
"crate2nix_stable": "crate2nix_stable",
|
||||
"devshell": "devshell_2",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nix-test-runner": "nix-test-runner_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": "pre-commit-hooks_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773440526,
|
||||
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix_stable": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_3",
|
||||
"crate2nix_stable": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"devshell": "devshell",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-test-runner": "nix-test-runner",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769627083,
|
||||
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "0.15.0",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat",
|
||||
"git-hooks": "git-hooks",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"git-hooks": "git-hooks_3",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753888869,
|
||||
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
|
||||
"lastModified": 1774134162,
|
||||
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
|
||||
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -68,14 +189,87 @@
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_3": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,16 +282,17 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nix",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733312601,
|
||||
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,15 +302,58 @@
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_3": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_4": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753121425,
|
||||
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -128,20 +366,82 @@
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_3": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_5",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750779888,
|
||||
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||
"lastModified": 1772893680,
|
||||
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -151,6 +451,102 @@
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_5": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
@@ -178,7 +574,10 @@
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
@@ -195,43 +594,101 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752773918,
|
||||
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
|
||||
"lastModified": 1774103430,
|
||||
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
|
||||
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.30",
|
||||
"ref": "devenv-2.32",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773634079,
|
||||
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1752827260,
|
||||
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1751159883,
|
||||
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -240,12 +697,161 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1769433173,
|
||||
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1773964973,
|
||||
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_3",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_4",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"flake-parts": "flake-parts_4",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773630837,
|
||||
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixd",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772660329,
|
||||
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# node 24.4.1, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||
# node 24.14, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/812b3986fd1568f7a858f97fcf425ad996ba7d25";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
|
||||
devenv.url = "github:cachix/devenv";
|
||||
@@ -58,7 +58,6 @@
|
||||
ffmpeg
|
||||
|
||||
# for testing docker
|
||||
colima
|
||||
docker
|
||||
docker-compose
|
||||
];
|
||||
@@ -75,11 +74,6 @@
|
||||
downall.exec = ''
|
||||
process-compose down
|
||||
'';
|
||||
|
||||
# ensure that volumes are mounted with write access for docker containers
|
||||
start_colima.exec = ''
|
||||
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
|
||||
'';
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
|
||||
Executable → Regular
Executable → Regular
+51
-47
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.4.1",
|
||||
"version": "4.5.2",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
@@ -12,13 +12,14 @@
|
||||
"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",
|
||||
"db:migrate": "prisma migrate dev --create-only",
|
||||
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
|
||||
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
|
||||
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
|
||||
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
|
||||
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
|
||||
"docker:compose:dev:build": "docker compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
|
||||
"docker:compose:dev:up": "docker compose --file docker-compose.dev.yml up -d",
|
||||
"docker:compose:dev:down": "docker compose --file docker-compose.dev.yml down",
|
||||
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
@@ -27,82 +28,85 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/swagger": "^9.6.1",
|
||||
"@mantine/charts": "^8.3.9",
|
||||
"@mantine/code-highlight": "^8.3.9",
|
||||
"@mantine/core": "^8.3.9",
|
||||
"@mantine/dates": "^8.3.9",
|
||||
"@mantine/dropzone": "^8.3.9",
|
||||
"@mantine/form": "^8.3.9",
|
||||
"@mantine/hooks": "^8.3.9",
|
||||
"@mantine/modals": "^8.3.9",
|
||||
"@mantine/notifications": "^8.3.9",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^8.3.18",
|
||||
"@mantine/code-highlight": "^8.3.18",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/dropzone": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.12.1",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.2",
|
||||
"commander": "^14.0.3",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
"katex": "^0.16.27",
|
||||
"mantine-datatable": "^8.3.9",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"katex": "^0.16.42",
|
||||
"mantine-datatable": "^8.3.13",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"multer": "2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-window": "1.8.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.3.7",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
"swr": "^2.4.1",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
@@ -111,11 +115,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.94.2",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.98.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -124,5 +128,5 @@
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
}
|
||||
|
||||
+1597
-1551
File diff suppressed because it is too large
Load Diff
Executable → Regular
Executable → Regular
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Folder" ADD COLUMN "parentId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "public"."Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `sessions` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."User" DROP COLUMN "sessions";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."UserSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ua" TEXT NOT NULL,
|
||||
"client" TEXT NOT NULL,
|
||||
"device" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxFilesPerUpload" INTEGER NOT NULL DEFAULT 1000;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."File" ADD COLUMN "anonymous" BOOLEAN NOT NULL DEFAULT false;
|
||||
Executable → Regular
+19
-1
@@ -46,6 +46,7 @@ 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)
|
||||
@@ -163,7 +164,7 @@ model User {
|
||||
|
||||
totpSecret String?
|
||||
passkeys UserPasskey[]
|
||||
sessions String[]
|
||||
sessions UserSession[]
|
||||
|
||||
quota UserQuota?
|
||||
|
||||
@@ -191,6 +192,18 @@ model Export {
|
||||
userId String
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ua String
|
||||
client String
|
||||
device String
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
userId String
|
||||
}
|
||||
|
||||
model UserQuota {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -270,6 +283,7 @@ model File {
|
||||
maxViews Int?
|
||||
favorite Boolean @default(false)
|
||||
password String?
|
||||
anonymous Boolean @default(false)
|
||||
|
||||
tags Tag[]
|
||||
|
||||
@@ -306,6 +320,10 @@ model Folder {
|
||||
|
||||
files File[]
|
||||
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
children Folder[] @relation("FolderToFolder")
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
userId String
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,4 +1,6 @@
|
||||
export function step(name: string, command: string, condition: () => boolean = () => true) {
|
||||
type StepCommand = string | (() => void | Promise<void>);
|
||||
|
||||
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
@@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) {
|
||||
|
||||
try {
|
||||
log(`> Running step "${name}/${step.name}"...`);
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
if (typeof step.command === 'string') {
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
} else {
|
||||
await step.command();
|
||||
}
|
||||
} catch {
|
||||
console.error(`x Step "${name}/${step.name}" failed.`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { run, step } from '.';
|
||||
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
|
||||
|
||||
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
|
||||
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
|
||||
|
||||
const ALL_ERRORS = Object.keys(API_ERRORS)
|
||||
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
|
||||
.sort((a, b) => a.code - b.code);
|
||||
|
||||
const ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
description: 'Generic error for API endpoints.',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
|
||||
},
|
||||
code: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description:
|
||||
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
|
||||
enum: ALL_ERRORS.map((entry) => entry.code),
|
||||
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
|
||||
},
|
||||
statusCode: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description: 'HTTP status code returned alongside this error payload.',
|
||||
},
|
||||
},
|
||||
required: ['error', 'code', 'statusCode'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
|
||||
examples[`E${entry.code}`] = {
|
||||
summary: `${entry.error}`,
|
||||
value: entry,
|
||||
};
|
||||
|
||||
return examples;
|
||||
}, {});
|
||||
|
||||
const generic4xxResponse = {
|
||||
description: 'API error response (4xx)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ERROR_SCHEMA,
|
||||
examples: ERROR_EXAMPLES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function addErrorResponse(responses: Record<string, any>): void {
|
||||
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
|
||||
|
||||
response.description ??= generic4xxResponse.description;
|
||||
response.content ??= {};
|
||||
|
||||
const jsonContent = (response.content['application/json'] ??= {});
|
||||
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
|
||||
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
|
||||
}
|
||||
|
||||
function filterRoutes(paths = {}): Record<string, any> {
|
||||
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
|
||||
}
|
||||
|
||||
async function fixSpec() {
|
||||
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
|
||||
|
||||
spec.paths = filterRoutes(spec.paths);
|
||||
|
||||
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of ALL_METHODS) {
|
||||
const operation = (<any>pathItem)[method];
|
||||
if (!operation) continue;
|
||||
|
||||
operation.responses ??= {};
|
||||
addErrorResponse(operation.responses);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(GEN_PATH, JSON.stringify(spec));
|
||||
}
|
||||
|
||||
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
|
||||
|
||||
run(
|
||||
'openapi',
|
||||
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
|
||||
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
|
||||
step('check', async () => {
|
||||
try {
|
||||
await readFile(GEN_PATH);
|
||||
} catch (e) {
|
||||
console.error('\nSomething went wrong...', e);
|
||||
|
||||
throw new Error('No OpenAPI spec found at ./openapi.json');
|
||||
}
|
||||
}),
|
||||
step('fix', fixSpec),
|
||||
);
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default function Root({
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
<Outlet />
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 in={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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
+124
-298
@@ -1,61 +1,53 @@
|
||||
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import LocalLogin from '@/components/pages/login/LocalLogin';
|
||||
import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton';
|
||||
import SecureWarningModal from '@/components/pages/login/SecureWarningModal';
|
||||
import TotpModal from '@/components/pages/login/TotpModal';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogleFilled,
|
||||
IconCheck,
|
||||
IconCircleKeyFilled,
|
||||
IconKey,
|
||||
IconShieldQuestion,
|
||||
IconUserPlus,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
|
||||
const location = useLocation();
|
||||
const query = new URLSearchParams(location.search);
|
||||
const navigate = useNavigate();
|
||||
const { user, mutate } = useLogin();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const webClient = JSON.stringify(getWebClient());
|
||||
|
||||
const {
|
||||
data: config,
|
||||
error: configError,
|
||||
isLoading: configLoading,
|
||||
} = useSWR<Response['/api/server/public']>('/api/server/public', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
const { data: config, error: configError, isLoading: configLoading } = useSWR('/api/server/public');
|
||||
|
||||
const showLocalLogin =
|
||||
query.get('local') === 'true' ||
|
||||
@@ -69,219 +61,122 @@ export default function Login() {
|
||||
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
|
||||
query.get('local') !== 'true';
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [pinDisabled, setPinDisabled] = useState(false);
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
const [passkeyErrored, setPasskeyErrored] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: (value) => (value.length > 1 ? null : 'Username is required'),
|
||||
password: (value) => (value.length > 1 ? null : 'Password is required'),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
|
||||
setPinDisabled(true);
|
||||
setPinError('');
|
||||
|
||||
const { username, password } = values;
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Invalid username or password') {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else if (error.error === 'Invalid code') setPinError(error.error!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
if (data!.totp) {
|
||||
setTotpOpen(true);
|
||||
setPinDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(data as Response['/api/user']);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinChange = (value: string) => {
|
||||
setPin(value);
|
||||
|
||||
if (value.length === 6) {
|
||||
onSubmit(form.values, value);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
setPasskeyLoading(true);
|
||||
const { data: options, error: optionsError } = await fetchApi<Response['/api/auth/webauthn/options']>(
|
||||
'/api/auth/webauthn/options',
|
||||
'GET',
|
||||
);
|
||||
if (optionsError) {
|
||||
setPasskeyErrored(true);
|
||||
setPasskeyLoading(false);
|
||||
notifications.show({
|
||||
title: 'Error while authenticating with passkey',
|
||||
message: optionsError.error,
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await startAuthentication({ optionsJSON: options!.options! });
|
||||
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
|
||||
response: res,
|
||||
});
|
||||
if (error) {
|
||||
setPasskeyErrored(true);
|
||||
setPasskeyLoading(false);
|
||||
notifications.show({
|
||||
title: 'Error while authenticating with passkey',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
mutate(data as Response['/api/user']);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setPasskeyErrored(true);
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (willRedirect && config) {
|
||||
const provider = Object.keys(config.oauthEnabled).find(
|
||||
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
|
||||
);
|
||||
|
||||
if (provider) {
|
||||
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
|
||||
}
|
||||
if (provider) window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
|
||||
}
|
||||
}, [willRedirect, config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (passkeyErrored) {
|
||||
setTimeout(() => {
|
||||
setPasskeyErrored(false);
|
||||
}, 3000);
|
||||
const [totp, setTotp] = useObjectState({
|
||||
open: false,
|
||||
disabled: false,
|
||||
error: '',
|
||||
pin: '',
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Error while authenticating with passkey',
|
||||
message: 'Please try again',
|
||||
color: 'red',
|
||||
icon: <IconX size='1rem' />,
|
||||
});
|
||||
}
|
||||
}, [passkeyErrored]);
|
||||
const [secureModal, setSecureModal] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { username: '', password: '' },
|
||||
validate: {
|
||||
username: (v) => (v.length >= 1 ? null : 'Username is required'),
|
||||
password: (v) => (v.length >= 1 ? null : 'Password is required'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate('/dashboard');
|
||||
if (config?.firstSetup) navigate('/auth/setup');
|
||||
}, [config]);
|
||||
}, [user, config, navigate]);
|
||||
|
||||
if (configLoading) return <LoadingOverlay visible />;
|
||||
const handleLoginSubmit = async (values: any, code?: string) => {
|
||||
setTotp({ disabled: true, error: '' });
|
||||
|
||||
if (configError)
|
||||
return (
|
||||
<GenericError
|
||||
title='Error loading configuration'
|
||||
message='Could not load server configuration...'
|
||||
details={configError}
|
||||
/>
|
||||
const { data, error } = await fetchApi(
|
||||
'/api/auth/login',
|
||||
'POST',
|
||||
{ ...values, code },
|
||||
{ 'x-zipline-client': webClient },
|
||||
);
|
||||
|
||||
if (!config) return <LoadingOverlay visible />;
|
||||
if (error) {
|
||||
if (ApiError.check(error, 1044)) {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else {
|
||||
setTotp('error', error.error || 'Login failed');
|
||||
}
|
||||
setTotp('disabled', false);
|
||||
} else if (data?.totp) {
|
||||
setTotp({ open: true, disabled: false });
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Logging in...',
|
||||
icon: <IconCheck size='1rem' />,
|
||||
autoClose: 700,
|
||||
});
|
||||
mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
if (configLoading || !config) return <LoadingOverlay visible />;
|
||||
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
|
||||
|
||||
const hasBg = !!config.website.loginBackground;
|
||||
|
||||
return (
|
||||
<>
|
||||
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
|
||||
|
||||
<Modal onClose={() => {}} title='Enter code' opened={totpOpen} withCloseButton={false}>
|
||||
<Center>
|
||||
<PinInput
|
||||
data-autofocus
|
||||
length={6}
|
||||
oneTimeCode
|
||||
type='number'
|
||||
placeholder=''
|
||||
onChange={handlePinChange}
|
||||
autoFocus={true}
|
||||
error={!!pinError}
|
||||
disabled={pinDisabled}
|
||||
size='xl'
|
||||
/>
|
||||
</Center>
|
||||
{pinError && (
|
||||
<Text ta='center' size='sm' c='red' mt={0}>
|
||||
{pinError}
|
||||
</Text>
|
||||
)}
|
||||
<TotpModal
|
||||
state={totp}
|
||||
onPinChange={(val) => setTotp('pin', val)}
|
||||
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
|
||||
onCancel={() => {
|
||||
setTotp('open', false);
|
||||
form.reset();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group mt='sm' grow>
|
||||
<Button
|
||||
leftSection={<IconX size='1rem' />}
|
||||
color='red'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setTotpOpen(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel login attempt
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconShieldQuestion size='1rem' />}
|
||||
loading={pinDisabled}
|
||||
type='submit'
|
||||
onClick={() => onSubmit(form.values, pin)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<SecureWarningModal
|
||||
opened={secureModal}
|
||||
onClose={() => setSecureModal(false)}
|
||||
returnHttps={config.returnHttps}
|
||||
/>
|
||||
|
||||
{isHttps && !config.returnHttps && (
|
||||
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
|
||||
<Text size='sm' c='red' ta='center'>
|
||||
You are accessing this instance through a <b>secure</b> context but the server is not configured
|
||||
to use HTTPS. Click <Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isHttps && config.returnHttps && (
|
||||
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
|
||||
<Text size='sm' c='red' ta='center'>
|
||||
You are accessing this instance through an <b>insecure</b> context but the server is configured to
|
||||
use HTTPS. This may cause issues when logging in. Click{' '}
|
||||
<Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Center h='100vh'>
|
||||
{config.website.loginBackground && (
|
||||
{hasBg && (
|
||||
<Image
|
||||
src={config.website.loginBackground}
|
||||
alt={config.website.loginBackground + ' failed to load'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
|
||||
}}
|
||||
pos='absolute'
|
||||
inset={0}
|
||||
w='100%'
|
||||
h='100%'
|
||||
fit='cover'
|
||||
style={{ filter: config.website.loginBackgroundBlur ? 'blur(10px)' : undefined }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -290,98 +185,29 @@ export default function Login() {
|
||||
p='xl'
|
||||
shadow='xl'
|
||||
withBorder
|
||||
pos='relative'
|
||||
style={{
|
||||
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
|
||||
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
|
||||
backgroundColor: hasBg ? 'transparent' : undefined,
|
||||
backdropFilter: hasBg ? 'blur(35px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
|
||||
<Title
|
||||
order={1}
|
||||
ta='center'
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
fontSize: `clamp(20px, ${Math.max(
|
||||
50 - (config.website.title?.length ?? 0) / 2,
|
||||
20,
|
||||
)}px, 50px)`,
|
||||
}}
|
||||
>
|
||||
<b>{config.website.title ?? 'Zipline'}</b>
|
||||
</Title>
|
||||
</div>
|
||||
<Title order={1} ta='center' mb='md'>
|
||||
<b>{config.website.title ?? 'Zipline'}</b>
|
||||
</Title>
|
||||
|
||||
{showLocalLogin && (
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<Stack my='sm'>
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
},
|
||||
}}
|
||||
{...form.getInputProps('username', { withError: true })}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='current-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
},
|
||||
}}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size='md'
|
||||
fullWidth
|
||||
type='submit'
|
||||
loading={!config}
|
||||
variant={config.website.loginBackground ? 'outline' : 'filled'}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Stack my='xs'>
|
||||
{(config.features.oauthRegistration || config.features.userRegistration) && (
|
||||
<Divider label='or' />
|
||||
<Stack>
|
||||
{showLocalLogin && (
|
||||
<LocalLogin
|
||||
form={form}
|
||||
onSubmit={handleLoginSubmit}
|
||||
loading={totp.disabled}
|
||||
hasBackground={hasBg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && (
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
leftSection={<IconKey size='1rem' />}
|
||||
color={passkeyErrored ? 'red' : undefined}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
Login with passkey
|
||||
</Button>
|
||||
)}
|
||||
<Divider label='or' />
|
||||
|
||||
{config.features.userRegistration && (
|
||||
<Button
|
||||
component={Link}
|
||||
to='/auth/register'
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
leftSection={<IconUserPlus size='1rem' />}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
|
||||
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function Logout() {
|
||||
useTitle('Log out');
|
||||
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
|
||||
if (res.ok) {
|
||||
setUser(null);
|
||||
mutate('/api/user', null);
|
||||
navigate('/auth/login');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <LoadingOverlay visible />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -22,6 +22,8 @@ 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';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Register');
|
||||
@@ -64,8 +66,8 @@ export function Component() {
|
||||
tos: false,
|
||||
},
|
||||
validate: {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
username: (value) => (value.length >= 1 ? null : 'Username is required'),
|
||||
password: (value) => (value.length >= 1 ? null : 'Password is required'),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
@@ -99,14 +101,21 @@ export function Component() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
});
|
||||
const { data, error } = await fetchApi(
|
||||
'/api/auth/register',
|
||||
'POST',
|
||||
{
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
},
|
||||
{
|
||||
'x-zipline-client': JSON.stringify(getWebClient()),
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Username is taken') {
|
||||
if (ApiError.check(error, 1039)) {
|
||||
form.setFieldError('username', 'Username is taken');
|
||||
} else {
|
||||
notifications.show({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
@@ -62,8 +62,8 @@ export function Component() {
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
username: (value) => (value.length >= 1 ? null : 'Username is required'),
|
||||
password: (value) => (value.length >= 1 ? null : 'Password is required'),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Container, LoadingOverlay } from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Terms of Service');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardServerActions from '@/components/pages/serverActions';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Actions');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardInvites from '@/components/pages/invites';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Invites');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardServerSettings from '@/components/pages/serverSettings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Params, redirect, useLoaderData } from 'react-router-dom';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardUsers from '@/components/pages/users';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Users');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFiles from '@/components/pages/files';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Files');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFolders from '@/components/pages/folders';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Folders');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardHome from '@/components/pages/dashboard';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardMetrics from '@/components/pages/metrics';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { redirect } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardSettings from '@/components/pages/settings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload File');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadText from '@/components/pages/upload/Text';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload Text');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardURLs from '@/components/pages/urls';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('URLs');
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { IconUpload } from '@tabler/icons-react';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFolder, IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
@@ -17,19 +31,79 @@ export async function loader({ params }: { params: Params<string> }) {
|
||||
};
|
||||
}
|
||||
|
||||
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
return (
|
||||
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1.2rem' />
|
||||
<Text fw={500}>{folder.name}</Text>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Card.Section inheritPadding py='xs'>
|
||||
<Stack gap={2}>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{folder._count?.files ?? 0} files
|
||||
</Text>
|
||||
{(folder._count?.children ?? 0) > 0 && (
|
||||
<Text size='xs' c='dimmed'>
|
||||
{folder._count?.children} subfolders
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useTitle(folder.name ?? '');
|
||||
useTitle(folder.name);
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const items: FolderBreadcrumb[] = [];
|
||||
|
||||
let current = folder.parent as Partial<Folder> | undefined;
|
||||
while (current && current.public) {
|
||||
items.unshift({ id: current.id!, name: current.name!, public: true });
|
||||
current = current.parent as Partial<Folder> | undefined;
|
||||
}
|
||||
|
||||
items.push({ id: folder.id!, name: folder.name!, public: true });
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
const children = (folder.children ?? []) as Partial<Folder>[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
{breadcrumbs.length > 1 && (
|
||||
<Breadcrumbs mb='md'>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Anchor
|
||||
key={item.id}
|
||||
onClick={() => navigate(`/folder/${item.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
fw={index === breadcrumbs.length - 1 ? 600 : 400}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<Title order={1}>{folder.name}</Title>
|
||||
|
||||
{folder.allowUploads && (
|
||||
<Link to={`/folder/${folder.id}/upload`}>
|
||||
<Link to={`/folder/${folder.id}/upload`} reloadDocument>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
@@ -37,21 +111,54 @@ export function Component() {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 3,
|
||||
md: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{children.length > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Subfolders
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 4,
|
||||
md: 3,
|
||||
sm: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{children.map((child) => (
|
||||
<PublicFolderCard key={child.id} folder={child} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(folder.files?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 3,
|
||||
md: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
|
||||
<Text c='dimmed' mt='md'>
|
||||
This folder is empty.
|
||||
</Text>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,14 @@ import ConfigProvider from '@/components/ConfigProvider';
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Anchor, Center, Container, Text } from '@mantine/core';
|
||||
import { data, Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
|
||||
if (!res.ok) {
|
||||
throw data('Folder not found', { status: 404 });
|
||||
}
|
||||
const res = await fetch(`/api/server/folder/${params.id}`);
|
||||
if (!res.ok) throw data('Folder not found', { status: 404 });
|
||||
|
||||
return {
|
||||
folder: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
@@ -40,7 +38,7 @@ export function Component() {
|
||||
{folder.public ? (
|
||||
<>
|
||||
This folder is{' '}
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`} reloadDocument>
|
||||
public
|
||||
</Anchor>
|
||||
. Anyone with the link can view its contents and upload files.
|
||||
|
||||
@@ -26,7 +26,7 @@ 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';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
type SsrData = {
|
||||
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
|
||||
@@ -56,7 +56,7 @@ export default function ViewFileId() {
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
|
||||
useTitle(file.name ?? 'View File');
|
||||
useTitle(file.originalName ?? file.name ?? 'View File');
|
||||
|
||||
return password && !pw ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
@@ -101,7 +101,7 @@ export default function ViewFileId() {
|
||||
<>
|
||||
<Paper withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
|
||||
<Group justify='space-between' py={5} px='xs'>
|
||||
<Text c='dimmed'>{file.name}</Text>
|
||||
<Text c='dimmed'>{file.originalName ?? file.name}</Text>
|
||||
|
||||
<Group>
|
||||
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
|
||||
@@ -112,7 +112,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
@@ -167,7 +167,7 @@ export default function ViewFileId() {
|
||||
<Group justify='space-between' mb='sm'>
|
||||
<Group>
|
||||
<Text size='lg' fw={700} display='flex'>
|
||||
{file.name}{' '}
|
||||
{file.originalName ?? file.name}{' '}
|
||||
</Text>
|
||||
{user?.view!.showTags && (
|
||||
<Group gap={4}>
|
||||
@@ -180,7 +180,13 @@ export default function ViewFileId() {
|
||||
file.Folder &&
|
||||
(file.Folder.public ? (
|
||||
<Tooltip label='View folder'>
|
||||
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
ml='sm'
|
||||
to={`/folder/${file.Folder.id}`}
|
||||
target='_blank'
|
||||
reloadDocument
|
||||
>
|
||||
{file.Folder.name}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
@@ -202,7 +208,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}${pw ? `?pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size='1rem' />
|
||||
@@ -213,7 +219,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
|
||||
+23
-31
@@ -6,9 +6,13 @@ import DashboardErrorBoundary from './error/DashboardErrorBoundary';
|
||||
import RootErrorBoundary from './error/RootErrorBoundary';
|
||||
import FourOhFour from './pages/404';
|
||||
import Login from './pages/auth/login';
|
||||
import Logout from './pages/auth/logout';
|
||||
import Root from './Root';
|
||||
|
||||
const fourOhFourCatchall = {
|
||||
path: '*',
|
||||
Component: FourOhFour,
|
||||
};
|
||||
|
||||
export async function dashboardLoader() {
|
||||
try {
|
||||
const res = await fetch('/api/server/settings/web');
|
||||
@@ -29,22 +33,21 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
Component: Root,
|
||||
path: '/',
|
||||
HydrateFallback: () => null,
|
||||
children: [
|
||||
{
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{ path: '*', Component: FourOhFour },
|
||||
fourOhFourCatchall,
|
||||
{
|
||||
path: '/auth',
|
||||
children: [
|
||||
{ path: 'login', Component: Login },
|
||||
{ path: 'logout', Component: Logout },
|
||||
{ path: 'register', lazy: () => import('./pages/auth/register') },
|
||||
{ path: 'auth/login', Component: Login },
|
||||
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
|
||||
{
|
||||
path: 'setup',
|
||||
path: 'auth/setup',
|
||||
lazy: () => import('./pages/auth/setup'),
|
||||
},
|
||||
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
|
||||
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,39 +62,28 @@ export const router = createBrowserRouter([
|
||||
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
|
||||
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
|
||||
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
|
||||
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
|
||||
// admin routes
|
||||
{
|
||||
path: 'upload',
|
||||
children: [
|
||||
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loader: async () => {
|
||||
const res = await fetch('/api/user');
|
||||
if (!res.ok) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
if (!res.ok) return redirect('/auth/login');
|
||||
|
||||
const { user } = await res.json();
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: 'users',
|
||||
children: [
|
||||
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: ':id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
path: 'admin/users/:id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FastifyRequest } from 'fastify';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
|
||||
import { createRoutes } from './routes';
|
||||
import { stripHtml } from '@/lib/stripHtml';
|
||||
|
||||
export const getFile = async (id: string) =>
|
||||
prisma.file.findFirst({
|
||||
@@ -166,49 +167,53 @@ 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 meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
? `<meta property="og:title" content="${
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
parseString(user.view.embedTitle, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedDescription && user.view.embed
|
||||
? `<meta property="og:description" content="${
|
||||
? `<meta property="og:description" content="${stripHtml(
|
||||
parseString(user.view.embedDescription, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedSiteName && user.view.embed
|
||||
? `<meta property="og:site_name" content="${
|
||||
? `<meta property="og:site_name" content="${stripHtml(
|
||||
parseString(user.view.embedSiteName, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedColor && user.view.embed
|
||||
? `<meta property="theme-color" content="${
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -216,11 +221,11 @@ export async function render(
|
||||
file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="${host}/raw/${file.name}" />
|
||||
<meta property="twitter:title" content="${file.name}" />
|
||||
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:title" content="${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -230,7 +235,7 @@ export async function render(
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:video:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
@@ -241,18 +246,18 @@ export async function render(
|
||||
file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<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" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${safeType}" />
|
||||
<meta name="twitter:title" content="${safeFilename}" />
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${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}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<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}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -260,12 +265,12 @@ export async function render(
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<title>${file.originalName ?? file.name}</title>
|
||||
<title>${file.originalName ? safeOriginalName : safeFilename}</title>
|
||||
`;
|
||||
|
||||
return {
|
||||
|
||||
Executable → Regular
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useConfig } from './ConfigProvider';
|
||||
import { Select, TextInput } from '@mantine/core';
|
||||
import { IconGlobe } from '@tabler/icons-react';
|
||||
|
||||
export default function DomainSelect({
|
||||
onChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Select> & { onChange?: (value: string) => void }) {
|
||||
const config = useConfig();
|
||||
|
||||
const domains = useMemo(() => {
|
||||
const settingsDomains = config.domains;
|
||||
if (!settingsDomains) return [];
|
||||
if (!Array.isArray(settingsDomains)) return [];
|
||||
|
||||
return settingsDomains;
|
||||
}, [config]);
|
||||
|
||||
const selectData = [
|
||||
{ value: '', label: 'Default domain' },
|
||||
...domains.map((domain) => ({
|
||||
value: domain,
|
||||
label: domain,
|
||||
})),
|
||||
];
|
||||
|
||||
if (domains.length === 0)
|
||||
return (
|
||||
<TextInput
|
||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
placeholder='example.com'
|
||||
{...(onChange
|
||||
? {
|
||||
onChange: (e) => onChange(e.currentTarget.value),
|
||||
}
|
||||
: {})}
|
||||
{...(props as React.ComponentProps<typeof TextInput>)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={selectData}
|
||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
{...(onChange
|
||||
? {
|
||||
onChange,
|
||||
}
|
||||
: {})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/store/view';
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/client/store/view';
|
||||
import { Center, SegmentedControl } from '@mantine/core';
|
||||
import { IconLayoutGrid, IconLayoutList } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
Executable → Regular
+16
-13
@@ -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,10 +47,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, Outlet, useLoaderData, useLocation } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -158,6 +158,7 @@ export default function Layout() {
|
||||
const clipboard = useClipboard();
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
|
||||
const loaderData = useLoaderData<typeof dashboardLoader>();
|
||||
const config = loaderData.config;
|
||||
@@ -165,6 +166,12 @@ 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?',
|
||||
@@ -239,6 +246,7 @@ export default function Layout() {
|
||||
color={theme.colors.gray[6]}
|
||||
mr='xl'
|
||||
hiddenFrom='sm'
|
||||
bdrs='md'
|
||||
/>
|
||||
|
||||
{config.website.titleLogo && (
|
||||
@@ -304,12 +312,7 @@ export default function Layout() {
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color='red'
|
||||
leftSection={<IconLogout size='1rem' />}
|
||||
component={Link}
|
||||
to='/auth/logout'
|
||||
>
|
||||
<Menu.Item color='red' leftSection={<IconLogout size='1rem' />} onClick={logout}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
@@ -398,7 +401,7 @@ export default function Layout() {
|
||||
|
||||
<AppShell.Main>
|
||||
<ConfigProvider data={loaderData}>
|
||||
<Paper m='lg' withBorder p='xs'>
|
||||
<Paper withBorder m='md' p='xs' radius='md'>
|
||||
<Outlet />
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Button, Group, Image, Modal, Select, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconClipboardCheck, IconClipboardX, IconCopy, IconDownload } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Type = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
||||
const UNSUPPORTED_COPY = ['image/jpeg', 'image/webp'];
|
||||
|
||||
export default function QRCodeModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
}) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [type, setType] = useState<Type>('image/png');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
import('qrcode')
|
||||
.then((QRCode) => QRCode.toDataURL(getDomain(url), { width: 500, type }))
|
||||
.then(setDataUrl)
|
||||
.catch(() => setDataUrl(null));
|
||||
}, [opened, url, type]);
|
||||
|
||||
const copyImageToClipboard = async () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
showNotification({
|
||||
message: 'QR code image copied to clipboard',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCheck size='1rem' />,
|
||||
});
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
title: 'Failed to copy QR code image',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
color: 'red',
|
||||
icon: <IconClipboardX size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.style.display = 'none';
|
||||
link.download = `qr-code.${type.split('/')[1]}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title='QR Code' opened={opened} onClose={onClose} size='sm' centered>
|
||||
{dataUrl ? (
|
||||
<Image src={dataUrl} alt='QR Code' />
|
||||
) : (
|
||||
<Text c='red' ta='center'>
|
||||
Failed to generate QR code.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Select
|
||||
mt='md'
|
||||
label='Format'
|
||||
value={type}
|
||||
onChange={(value) => setType(value as Type)}
|
||||
data={[
|
||||
{ value: 'image/png', label: 'png' },
|
||||
{ value: 'image/jpeg', label: 'jpeg' },
|
||||
{ value: 'image/webp', label: 'webp' },
|
||||
]}
|
||||
size='xs'
|
||||
/>
|
||||
|
||||
{dataUrl && (
|
||||
<Group gap='xs' mt='md' grow>
|
||||
<Tooltip
|
||||
label={
|
||||
UNSUPPORTED_COPY.includes(type)
|
||||
? 'Copying this format is not supported in some browsers. You can copy the image normally via right-click or holding it.'
|
||||
: ''
|
||||
}
|
||||
hidden={!UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
<Button
|
||||
onClick={copyImageToClipboard}
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
disabled={UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
Copy Image
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={downloadImage} leftSection={<IconDownload size='1rem' />}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { ZiplineTheme, findTheme, themeComponents } from '@/lib/theme';
|
||||
import dark_blue from '@/lib/theme/builtins/dark_blue';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useVersion from '@/lib/hooks/useVersion';
|
||||
import useVersion from '@/lib/client/hooks/useVersion';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
|
||||
function DataDisplay({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string; href?: string; color?: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Stack gap='xs'>
|
||||
@@ -29,7 +33,7 @@ function DataDisplay({ items }: { items: { label: string; value: string; href?:
|
||||
{item.value}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{item.value}</Text>
|
||||
<Text c={item.color ?? undefined}>{item.value}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
@@ -105,10 +109,14 @@ export default function VersionBadge() {
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!,
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -131,6 +139,7 @@ export default function VersionBadge() {
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Executable → Regular
+61
-33
@@ -1,9 +1,10 @@
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutateFiles } from '../actions';
|
||||
|
||||
export default function EditFileDetailsModal({
|
||||
@@ -15,13 +16,41 @@ export default function EditFileDetailsModal({
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!file) return null;
|
||||
const [formData, setFormData] = useObjectState<{
|
||||
name: string;
|
||||
maxViews: number | null;
|
||||
password: string | null;
|
||||
originalName: string | null;
|
||||
type: string | null;
|
||||
}>({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
|
||||
const [name, setName] = useState<string>(file.name ?? '');
|
||||
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
||||
const [password, setPassword] = useState<string | null>('');
|
||||
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
||||
const [type, setType] = useState<string | null>(file?.type ?? null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
maxViews: null,
|
||||
password: null,
|
||||
originalName: null,
|
||||
type: null,
|
||||
});
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleRemovePassword = async () => {
|
||||
if (!file.password) return;
|
||||
@@ -58,12 +87,12 @@ export default function EditFileDetailsModal({
|
||||
name?: string;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (originalName !== null) data['originalName'] = originalName?.trim();
|
||||
if (type !== null) data['type'] = type?.trim();
|
||||
if (name !== file.name) data['name'] = name.trim();
|
||||
if (formData.maxViews !== null) data['maxViews'] = formData.maxViews;
|
||||
if (formData.originalName !== null) data['originalName'] = formData.originalName?.trim();
|
||||
if (formData.type !== null) data['type'] = formData.type?.trim();
|
||||
if (formData.name !== file.name) data['name'] = formData.name.trim();
|
||||
|
||||
const passwordTrimmed = password?.trim();
|
||||
const passwordTrimmed = formData.password?.trim();
|
||||
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
|
||||
|
||||
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
||||
@@ -85,29 +114,19 @@ export default function EditFileDetailsModal({
|
||||
|
||||
onClose();
|
||||
|
||||
setPassword(null);
|
||||
setFormData('password', null);
|
||||
mutateFiles();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(file.name ?? '');
|
||||
setMaxViews(file.maxViews ?? null);
|
||||
setPassword(file.password ? '' : null);
|
||||
setOriginalName(file.originalName ?? null);
|
||||
setType(file.type ?? null);
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
return (
|
||||
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
||||
<Stack gap='xs' my='sm'>
|
||||
<TextInput
|
||||
label='Name'
|
||||
description='Rename the file.'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value.trim())}
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData('name', event.currentTarget.value.trim())}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
@@ -115,17 +134,20 @@ export default function EditFileDetailsModal({
|
||||
placeholder='Unlimited'
|
||||
description='The maximum number of views this file can have before it is deleted. Leave blank to allow as many views as you want.'
|
||||
min={0}
|
||||
value={maxViews || ''}
|
||||
onChange={(value) => setMaxViews(value === '' ? null : Number(value))}
|
||||
value={formData.maxViews || ''}
|
||||
onChange={(value) => setFormData('maxViews', value === '' ? null : Number(value))}
|
||||
leftSection={<IconEye size='1rem' />}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Original Name'
|
||||
description='Add an original name. When downloading this file, instead of using the generated file name (if chosen), it will download with this "original name" instead.'
|
||||
value={originalName ?? ''}
|
||||
value={formData.originalName ?? ''}
|
||||
onChange={(event) =>
|
||||
setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'originalName',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -137,9 +159,12 @@ export default function EditFileDetailsModal({
|
||||
doing, this can mess with how Zipline renders specific file types.
|
||||
</>
|
||||
}
|
||||
value={type ?? ''}
|
||||
value={formData.type ?? ''}
|
||||
onChange={(event) =>
|
||||
setType(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'type',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
c='red'
|
||||
/>
|
||||
@@ -159,10 +184,13 @@ export default function EditFileDetailsModal({
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Set a password for this file. Leave blank to disable password protection.'
|
||||
value={password ?? ''}
|
||||
value={formData.password ?? ''}
|
||||
autoComplete='off'
|
||||
onChange={(event) =>
|
||||
setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'password',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
leftSection={<IconKey size='1rem' />}
|
||||
/>
|
||||
|
||||
Executable → Regular
+41
-32
@@ -1,11 +1,13 @@
|
||||
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 { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -46,9 +48,11 @@ import {
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
addToFolder,
|
||||
@@ -102,18 +106,21 @@ export default function FileModal({
|
||||
|
||||
const [editFileOpen, setEditFileOpen] = useState(false);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true' + (user ? `&user=${user}` : ''),
|
||||
);
|
||||
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') {
|
||||
createFolderAndAdd(file!, search.trim());
|
||||
await createFolderAndAdd(file!, search.trim());
|
||||
} else {
|
||||
addToFolder(file!, value);
|
||||
await addToFolder(file!, value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,6 +230,7 @@ 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 && (
|
||||
@@ -231,12 +239,7 @@ export default function FileModal({
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
withinPortal={false}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
>
|
||||
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
@@ -312,7 +315,7 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
withinPortal={false}
|
||||
zIndex={90000}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
@@ -325,11 +328,17 @@ export default function FileModal({
|
||||
folderCombobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => folderCombobox.openDropdown()}
|
||||
onFocus={() => folderCombobox.openDropdown()}
|
||||
onClick={() => {
|
||||
folderCombobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
folderCombobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
folderCombobox.closeDropdown();
|
||||
setSearch(search || '');
|
||||
setSearch('');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
@@ -337,24 +346,24 @@ export default function FileModal({
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f: { name: string }) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase().trim()),
|
||||
)
|
||||
.map((f: { name: string; id: string }) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
{folders?.length === 0 && (
|
||||
<Combobox.Empty>
|
||||
You have no folders. Start typing to create a new folder for this file.
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
|
||||
{!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 && (
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
additionalOptions={
|
||||
!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 ? (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create folder "{search}"
|
||||
</Combobox.Option>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+83
-64
@@ -1,5 +1,6 @@
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
@@ -11,13 +12,23 @@ import {
|
||||
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 { useCallback, useEffect, useState } from 'react';
|
||||
import Asciinema from '../render/Asciinema';
|
||||
import Pdf from '../render/Pdf';
|
||||
import Render from '../render/Render';
|
||||
import { renderMode } from '../render/renderMode';
|
||||
import fileIcon from './fileIcon';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
|
||||
const MAX_BYTES = 1 * 1024 * 1024;
|
||||
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
|
||||
|
||||
function appendPassword(url: string, password?: string | null) {
|
||||
return `${url}${password ? `?pw=${encodeURIComponent(password)}` : ''}`;
|
||||
}
|
||||
|
||||
function isDbFile(file: DbFile | File): file is DbFile {
|
||||
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
|
||||
}
|
||||
|
||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
return (
|
||||
@@ -82,16 +93,37 @@ export default function DashboardFileType({
|
||||
}) {
|
||||
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 dbFile = isDbFile(file);
|
||||
|
||||
const fileRoute = dbFile ? (user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`) : '';
|
||||
|
||||
const thumbnailRoute = dbFile
|
||||
? file.thumbnail?.path
|
||||
? user
|
||||
? `/api/user/files/${file.thumbnail.path}/raw`
|
||||
: `/raw/${file.thumbnail.path}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const dbFileUrl = dbFile ? appendPassword(fileRoute, password) : '';
|
||||
const [blobUrl, setBlobUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (dbFile) return setBlobUrl('');
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setBlobUrl(objectUrl);
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [dbFile, file]);
|
||||
|
||||
const fileUrl = dbFile ? dbFileUrl : blobUrl;
|
||||
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const renderIn = renderMode(extension);
|
||||
const type = code ? 'text' : file.type.split('/')[0];
|
||||
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [type, setType] = useState(file.type.split('/')[0]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const getText = useCallback(async () => {
|
||||
@@ -99,52 +131,41 @@ export default function DashboardFileType({
|
||||
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.',
|
||||
);
|
||||
const content = reader.result as string;
|
||||
if (content.length > MAX_BYTES) {
|
||||
setFileContent(content.slice(0, MAX_BYTES) + FILE_BIG);
|
||||
} else {
|
||||
setFileContent(reader.result as string);
|
||||
setFileContent(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
|
||||
if (file.size > MAX_BYTES) {
|
||||
const res = await fetch(fileUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
Range: `bytes=0-${MAX_BYTES}`,
|
||||
},
|
||||
});
|
||||
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.',
|
||||
);
|
||||
setFileContent(text + FILE_BIG);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
}, [dbFile, file, password]);
|
||||
}, [dbFile, file, fileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setType('text');
|
||||
getText();
|
||||
} else if (type === 'text') {
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
if (type === 'text') getText();
|
||||
}, [type, getText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -152,6 +173,10 @@ export default function DashboardFileType({
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (disableMediaPreview && !show)
|
||||
@@ -166,7 +191,7 @@ export default function DashboardFileType({
|
||||
<Placeholder
|
||||
text={`Click to view protected ${file.name}`}
|
||||
Icon={IconShieldLockFilled}
|
||||
onClick={() => window.open(`/view/${file.name}${password ? `?pw=${password}` : ''}`)}
|
||||
onClick={() => window.open(appendPassword(`/view/${file.name}`, password))}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
@@ -175,16 +200,17 @@ export default function DashboardFileType({
|
||||
|
||||
switch (true) {
|
||||
case type === 'video':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<video
|
||||
width='100%'
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||
/>
|
||||
) : (file as DbFile).thumbnail && dbFile ? (
|
||||
) : thumbnailRoute ? (
|
||||
<Box pos='relative'>
|
||||
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
|
||||
|
||||
@@ -209,22 +235,23 @@ export default function DashboardFileType({
|
||||
);
|
||||
|
||||
case type === 'image':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vw',
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => allowZoom && setOpen(true)}
|
||||
/>
|
||||
{allowZoom && open && (
|
||||
<FileZoomModal setOpen={setOpen}>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
@@ -238,23 +265,13 @@ export default function DashboardFileType({
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<MantineImage
|
||||
fit='contain'
|
||||
mah={400}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
/>
|
||||
<MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />
|
||||
);
|
||||
|
||||
case type === 'audio':
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<audio
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
/>
|
||||
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
@@ -278,15 +295,16 @@ export default function DashboardFileType({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
|
||||
<Render mode={renderIn} language={extension} 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}` : ''}`} />
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Asciinema src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
@@ -295,26 +313,27 @@ export default function DashboardFileType({
|
||||
);
|
||||
|
||||
case file.type === 'application/pdf':
|
||||
return show && dbFile ? (
|
||||
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<Pdf src={fileUrl} />
|
||||
) : (
|
||||
<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 (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (dbFile && show)
|
||||
if (show)
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
|
||||
onClick={() => window.open(fileUrl)}
|
||||
text={`Click to view file ${file.name} in a new tab`}
|
||||
Icon={fileIcon(file.type)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
else return <IconFileUnknown size={48} />;
|
||||
|
||||
return <IconFileUnknown size={48} />;
|
||||
}
|
||||
}
|
||||
|
||||
Executable → Regular
+39
-47
@@ -1,8 +1,10 @@
|
||||
import { mutateFolder } from '@/components/pages/folders/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { conditionalWarning } from '@/lib/warningModal';
|
||||
import { conditionalWarning } from '@/lib/client/warningModal';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -28,13 +30,11 @@ export function downloadFile(file: File) {
|
||||
}
|
||||
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
|
||||
const domain = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const url = raw
|
||||
? `${domain}/raw/${file.name}`
|
||||
? getDomain(`/raw/${file.name}`)
|
||||
: file.url
|
||||
? `${domain}${file.url}`
|
||||
: `${domain}/view/${file.name}`;
|
||||
? getDomain(`${file.url}`)
|
||||
: getDomain(`/view/${file.name}`);
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
@@ -110,43 +110,40 @@ export async function favoriteFile(file: File) {
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
export function createFolderAndAdd(file: File, folderName: string | null) {
|
||||
fetchApi<Extract<Response['/api/user/folders'], Folder>>('/api/user/folders', 'POST', {
|
||||
name: folderName,
|
||||
files: [file.id],
|
||||
}).then(({ data, error }) => {
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Error while creating folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder created',
|
||||
message: `${data!.name} has been created with ${file.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
}
|
||||
});
|
||||
export async function createFolderAndAdd(file: File, folderName: string | null) {
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/folders'], Folder>>(
|
||||
'/api/user/folders',
|
||||
'POST',
|
||||
{
|
||||
name: folderName,
|
||||
files: [file.id],
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Error while creating folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder created',
|
||||
message: `${data!.name} has been created with ${file.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function removeFromFolder(file: File) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/folders/${file.folderId}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
},
|
||||
);
|
||||
const { data, error } = await fetchApi<{ folder: Folder }>(`/api/user/folders/${file.folderId}`, 'DELETE', {
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
@@ -158,13 +155,13 @@ export async function removeFromFolder(file: File) {
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'File removed from folder',
|
||||
message: `${file.name} has been removed from ${data!.name}`,
|
||||
message: `${file.name} has been removed from ${data?.folder.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -195,7 +192,7 @@ export async function addToFolder(file: File, folderId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -227,7 +224,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -235,8 +232,3 @@ export function mutateFiles() {
|
||||
mutate('/api/user/recent');
|
||||
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
|
||||
}
|
||||
|
||||
export function mutateFolders() {
|
||||
mutate('/api/user/folders');
|
||||
mutate('/api/user/folders?noincl=true');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FolderHierarchyItem } from '@/lib/folderHierarchy';
|
||||
import { Combobox, Text } from '@mantine/core';
|
||||
|
||||
export default function FolderComboboxOptions({
|
||||
folderOptions,
|
||||
searchValue,
|
||||
additionalOptions,
|
||||
}: {
|
||||
folderOptions: FolderHierarchyItem[];
|
||||
searchValue: string;
|
||||
additionalOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Combobox.Options>
|
||||
{additionalOptions}
|
||||
{folderOptions
|
||||
.filter((f) => f.path.toLowerCase().includes(searchValue.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
<Text size='sm' style={{ paddingLeft: f.depth * 12 }}>
|
||||
{f.depth > 0 ? '└ ' : ''}
|
||||
{f.name}
|
||||
</Text>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+43
-9
@@ -1,10 +1,20 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import Stat from '@/components/Stat';
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
IconDeviceSdCard,
|
||||
IconEyeFilled,
|
||||
IconFiles,
|
||||
IconGraphFilled,
|
||||
IconLink,
|
||||
IconStarFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
@@ -13,6 +23,9 @@ export default function DashboardHome() {
|
||||
const { user } = useLogin();
|
||||
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();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
@@ -47,9 +60,18 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Title order={2} mt='md' mb='xs'>
|
||||
Recent files
|
||||
</Title>
|
||||
<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' }}>
|
||||
@@ -71,9 +93,21 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Title order={2} mt='md'>
|
||||
Stats
|
||||
</Title>
|
||||
<Group mt='md' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Stats</Title>
|
||||
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/metrics'
|
||||
leftSection={<IconGraphFilled size='1rem' />}
|
||||
>
|
||||
View instance metrics
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
These statistics are based on your uploads only.
|
||||
</Text>
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
<Badge variant='light' color='gray'>
|
||||
Pending
|
||||
</Badge>
|
||||
),
|
||||
PROCESSING: (
|
||||
<Badge variant='light' color='yellow'>
|
||||
Processing
|
||||
</Badge>
|
||||
),
|
||||
COMPLETE: (
|
||||
<Badge variant='light' color='green'>
|
||||
Complete
|
||||
</Badge>
|
||||
),
|
||||
FAILED: (
|
||||
<Badge variant='light' color='red'>
|
||||
Failed
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
|
||||
export default function PendingFilesButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
>('/api/user/files/incomplete');
|
||||
|
||||
const handleDelete = async (incompleteFile: IncompleteFile) => {
|
||||
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
|
||||
'/api/user/files/incomplete',
|
||||
'DELETE',
|
||||
{
|
||||
id: [incompleteFile.id],
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: `Failed to delete pending file: ${error.error}`,
|
||||
color: 'red',
|
||||
icon: <IconFileDots size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Cleared Pending File!',
|
||||
color: 'green',
|
||||
icon: <IconTrashFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
<Group justify='space-between'>
|
||||
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
|
||||
{badgeMap[incompleteFile.status]}
|
||||
</Group>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Text size='xs' c='dimmed' fw='bold'>
|
||||
{incompleteFile.metadata.file.type}
|
||||
</Text>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.id}
|
||||
</Text>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Button
|
||||
fullWidth
|
||||
size='compact-sm'
|
||||
mt='xs'
|
||||
color='red'
|
||||
variant='light'
|
||||
onClick={() => handleDelete(incompleteFile)}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{incompleteFiles?.length === 0 && (
|
||||
<Paper withBorder px='sm' py='xs'>
|
||||
No pending files
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='View pending files'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconFileDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '.';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
<Badge variant='light' color='gray'>
|
||||
Pending
|
||||
</Badge>
|
||||
),
|
||||
PROCESSING: (
|
||||
<Badge variant='light' color='yellow'>
|
||||
Processing
|
||||
</Badge>
|
||||
),
|
||||
COMPLETE: (
|
||||
<Badge variant='light' color='green'>
|
||||
Complete
|
||||
</Badge>
|
||||
),
|
||||
FAILED: (
|
||||
<Badge variant='light' color='red'>
|
||||
Failed
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
|
||||
export default function PendingFilesModal({
|
||||
modals,
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
}) {
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
>('/api/user/files/incomplete');
|
||||
|
||||
const handleDelete = async (incompleteFile: IncompleteFile) => {
|
||||
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
|
||||
'/api/user/files/incomplete',
|
||||
'DELETE',
|
||||
{
|
||||
id: [incompleteFile.id],
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: `Failed to delete pending file: ${error.error}`,
|
||||
color: 'red',
|
||||
icon: <IconFileDots size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Cleared Pending File!',
|
||||
color: 'green',
|
||||
icon: <IconTrashFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
<Group justify='space-between'>
|
||||
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
|
||||
{badgeMap[incompleteFile.status]}
|
||||
</Group>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Text size='xs' c='dimmed' fw='bold'>
|
||||
{incompleteFile.metadata.file.type}
|
||||
</Text>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.id}
|
||||
</Text>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Button
|
||||
fullWidth
|
||||
size='compact-sm'
|
||||
mt='xs'
|
||||
color='red'
|
||||
variant='light'
|
||||
onClick={() => handleDelete(incompleteFile)}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{incompleteFiles?.length === 0 && (
|
||||
<Paper withBorder px='sm' py='xs'>
|
||||
No pending files
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -14,17 +14,6 @@ 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);
|
||||
|
||||
@@ -53,7 +42,7 @@ function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
|
||||
export default function TableEditModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
@@ -73,7 +62,7 @@ export default function TableEditModal({ opened, onCLose }: { opened: boolean; o
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
|
||||
<Modal opened={opened} onClose={onClose} title='Table Options' centered>
|
||||
<Text mb='md' size='sm' c='dimmed'>
|
||||
Select and drag fields below to make them appear/disappear/reorder in the file table view.
|
||||
</Text>
|
||||
|
||||
Executable → Regular
Executable → Regular
+101
-42
@@ -1,23 +1,77 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
import useObjectState, { type UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
IconFileDots,
|
||||
IconFileUpload,
|
||||
IconGridPatternFilled,
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from './tags/TagsModal';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
import FileTable from './views/FileTable';
|
||||
import Files from './views/Files';
|
||||
import TagsButton from './tags/TagsButton';
|
||||
import PendingFilesButton from './PendingFilesButton';
|
||||
import { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import Files from './views/FilesGridView';
|
||||
import FileTable from './views/FilesTableView';
|
||||
|
||||
export type DashboardFilesModals = {
|
||||
table: boolean;
|
||||
idSearch: boolean;
|
||||
tags: boolean;
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true';
|
||||
|
||||
const [modals, setModalState] = useObjectState<DashboardFilesModals>({
|
||||
table: modalQS('table'),
|
||||
idSearch: modalQS('idSearch'),
|
||||
tags: modalQS('tags'),
|
||||
pending: modalQS('pending'),
|
||||
});
|
||||
|
||||
const updateModalQuery = (updates: Partial<DashboardFilesModals>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
for (const key of modalKeys) {
|
||||
if (!(key in updates)) continue;
|
||||
|
||||
if (updates[key]) next.set(key, 'true');
|
||||
else next.delete(key);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const setModals: UpdateFn<DashboardFilesModals> = (keyOrObj: any, value?: any) => {
|
||||
if (typeof keyOrObj === 'object' && value === undefined) {
|
||||
setModalState(keyOrObj);
|
||||
updateModalQuery(keyOrObj);
|
||||
return;
|
||||
}
|
||||
|
||||
setModalState(keyOrObj, value);
|
||||
updateModalQuery({ [keyOrObj]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagsModal modals={modals} setModals={setModals} />
|
||||
<PendingFilesModal modals={modals} setModals={setModals} />
|
||||
|
||||
<Group>
|
||||
<Title>Files</Title>
|
||||
|
||||
@@ -29,29 +83,43 @@ export default function DashboardFiles() {
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
<TagsButton />
|
||||
<PendingFilesButton />
|
||||
|
||||
{view === 'table' && (
|
||||
<>
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
|
||||
<IconTableOptions size='1rem' />
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Tooltip label='More actions'>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<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)}
|
||||
>
|
||||
View Pending Files
|
||||
</Menu.Item>
|
||||
{view === 'table' && (
|
||||
<>
|
||||
<Menu.Label>Table Options</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconGridPatternFilled size='1rem' />}
|
||||
onClick={() => setModals('idSearch', !modals.idSearch)}
|
||||
>
|
||||
Search by ID
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTableOptions size='1rem' />}
|
||||
onClick={() => setModals('table', !modals.table)}
|
||||
>
|
||||
Table Options
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
@@ -63,16 +131,7 @@ export default function DashboardFiles() {
|
||||
<Files />
|
||||
</>
|
||||
) : (
|
||||
<FileTable
|
||||
idSearch={{
|
||||
open: idSearchOpen,
|
||||
setOpen: setIdSearchOpen,
|
||||
}}
|
||||
tableEdit={{
|
||||
open: tableEditOpen,
|
||||
setOpen: setTableEditOpen,
|
||||
}}
|
||||
/>
|
||||
<FileTable modals={modals} setModals={setModals} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+7
-2
@@ -11,8 +11,13 @@ export default function TagPill({
|
||||
if (!tag) return null;
|
||||
|
||||
return (
|
||||
<Pill bg={tag.color || undefined} c={isLightColor(tag.color) ? 'black' : 'white'} {...other}>
|
||||
{tag.name}
|
||||
<Pill
|
||||
bg={tag.color || undefined}
|
||||
c={isLightColor(tag.color) ? 'black' : 'white'}
|
||||
title={tag.name}
|
||||
{...other}
|
||||
>
|
||||
{tag.name.length <= 24 ? tag.name : tag.name.slice(0, 21) + '...'}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
Executable → Regular
+12
-11
@@ -2,17 +2,24 @@ import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
import EditTagModal from './EditTagModal';
|
||||
import TagPill from './TagPill';
|
||||
|
||||
export default function TagsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
export default function TagsModals({
|
||||
modals,
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
}) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
@@ -47,8 +54,8 @@ export default function TagsButton() {
|
||||
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
opened={modals.tags}
|
||||
onClose={() => setModals('tags', false)}
|
||||
title={
|
||||
<Group>
|
||||
<Title>Tags</Title>
|
||||
@@ -94,12 +101,6 @@ export default function TagsButton() {
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='View tags'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconTags size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+2
@@ -19,6 +19,7 @@ type ApiPaginationOptions = {
|
||||
| 'favorite';
|
||||
order?: 'asc' | 'desc';
|
||||
id?: string;
|
||||
folderId?: string;
|
||||
search?: {
|
||||
field?: string;
|
||||
query: string;
|
||||
@@ -45,6 +46,7 @@ const fetcher = async (
|
||||
if (options.search.field) searchParams.append('searchField', options.search.field);
|
||||
searchParams.append('searchQuery', options.search.query);
|
||||
}
|
||||
if (options.folderId) searchParams.append('folder', options.folderId);
|
||||
|
||||
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
|
||||
|
||||
|
||||
Executable → Regular
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
|
||||
Executable → Regular
+3
-2
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -21,7 +21,7 @@ const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function Files({ id }: { id?: string }) {
|
||||
page,
|
||||
perpage,
|
||||
id,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
src/components/pages/files/views/FileTable.tsx → src/components/pages/files/views/FilesTableView.tsx
Executable → Regular
+65
-50
@@ -1,13 +1,15 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -38,10 +40,13 @@ import {
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { lazy, useEffect, useReducer, useState } from 'react';
|
||||
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
|
||||
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
@@ -108,7 +113,7 @@ function TagsFilter({
|
||||
const combobox = useCombobox();
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
|
||||
|
||||
const [value, setValue] = useState(searchQuery.tags.split(','));
|
||||
const [value, setValue] = useState(() => searchQuery.tags.split(','));
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
@@ -175,27 +180,26 @@ function TagsFilter({
|
||||
|
||||
export default function FileTable({
|
||||
id,
|
||||
tableEdit,
|
||||
idSearch,
|
||||
folderId,
|
||||
modals,
|
||||
setModals,
|
||||
}: {
|
||||
id?: string;
|
||||
tableEdit: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
idSearch: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
folderId?: string;
|
||||
modals?: Partial<DashboardFilesModals>;
|
||||
setModals?: UpdateFn<DashboardFilesModals>;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
|
||||
const fields = useFileTableSettingsStore((state) => state.fields);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
const { data: folders } = useFolders();
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!folders) return [];
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(20);
|
||||
@@ -253,6 +257,7 @@ export default function FileTable({
|
||||
sort,
|
||||
order,
|
||||
id,
|
||||
folderId,
|
||||
...(searchQuery[searchField].trim() !== '' && {
|
||||
search: {
|
||||
field: searchField,
|
||||
@@ -337,6 +342,7 @@ export default function FileTable({
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
title: 'Favorite?',
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
@@ -349,6 +355,12 @@ 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,7 +391,9 @@ export default function FileTable({
|
||||
user={id}
|
||||
/>
|
||||
|
||||
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
|
||||
{modals && setModals && (
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
@@ -434,11 +448,17 @@ export default function FileTable({
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onClick={() => {
|
||||
combobox.openDropdown();
|
||||
setFolderSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
combobox.openDropdown();
|
||||
setFolderSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setFolderSearch(folderSearch || '');
|
||||
setFolderSearch('');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
@@ -446,15 +466,7 @@ export default function FileTable({
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
<FolderComboboxOptions folderOptions={folderOptions} searchValue={folderSearch} />
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
@@ -474,30 +486,33 @@ export default function FileTable({
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={idSearch.open}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
value={searchQuery.id}
|
||||
onChange={(e) => {
|
||||
setSearchField('id');
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: e.target.value,
|
||||
});
|
||||
}}
|
||||
size='sm'
|
||||
/>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{modals && setModals && modals.idSearch && (
|
||||
<Collapse in={modals.idSearch}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
value={searchQuery.id}
|
||||
onChange={(e) => {
|
||||
setSearchField('id');
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: e.target.value,
|
||||
});
|
||||
}}
|
||||
size='sm'
|
||||
/>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
{/* @ts-ignore */}
|
||||
{/*@ts-ignore*/}
|
||||
<DataTable
|
||||
mt='xs'
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
noRecordsText='No files'
|
||||
columns={[
|
||||
...columns,
|
||||
{
|
||||
Executable → Regular
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
|
||||
Executable → Regular
+76
-26
@@ -5,7 +5,10 @@ import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconFiles,
|
||||
IconFileZip,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderSymlink,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPencil,
|
||||
@@ -14,73 +17,115 @@ import {
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import ViewFilesModal from './ViewFilesModal';
|
||||
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from './actions';
|
||||
import EditFolderNameModal from './EditFolderNameModal';
|
||||
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from './actions';
|
||||
import DeleteFolderModal from './modals/DeleteFolderModal';
|
||||
import EditFolderNameModal from './modals/EditFolderNameModal';
|
||||
import MoveFolderModal from './modals/MoveFolderModal';
|
||||
import ViewFilesModal from './modals/ViewFilesModal';
|
||||
import { withoutPropagation } from './views/FolderTableView';
|
||||
|
||||
export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
export default function FolderCard({
|
||||
folder,
|
||||
onNavigate,
|
||||
}: {
|
||||
folder: Folder;
|
||||
onNavigate?: (folderId: string | null) => void;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const childrenCount = folder._count?.children ?? 0;
|
||||
const filesCount = folder._count?.files ?? folder.files?.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewFilesModal opened={viewOpen} onClose={() => setViewOpen(false)} folder={folder} />
|
||||
<EditFolderNameModal folder={folder} opened={editOpen} onClose={() => setEditOpen(false)} />
|
||||
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
|
||||
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<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'>
|
||||
<Text fw={400}>
|
||||
{folder.public ? (
|
||||
<Anchor href={`/folder/${folder.id}`} target='_blank'>
|
||||
{folder.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
folder.name
|
||||
)}
|
||||
</Text>
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1rem' />
|
||||
<Text fw={400}>
|
||||
{folder.public ? (
|
||||
<Anchor href={`/folder/${folder.id}`} target='_blank' onClick={(e) => e.stopPropagation()}>
|
||||
{folder.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
folder.name
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Menu withinPortal position='bottom-end' shadow='sm'>
|
||||
<Group gap={2}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant='transparent'>
|
||||
<ActionIcon variant='transparent' onClick={(e) => e.stopPropagation()}>
|
||||
<IconDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
</Group>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconFiles size='1rem' />} onClick={() => setViewOpen(true)}>
|
||||
View Files
|
||||
{onNavigate && (
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderOpen size='1rem' />}
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
Open Folder
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderSymlink size='1rem' />}
|
||||
onClick={withoutPropagation(() => setMoveOpen(true))}
|
||||
>
|
||||
Move Folder
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileZip size='1rem' />}
|
||||
component='a'
|
||||
href={`/api/user/folders/${folder.id}/export`}
|
||||
target='_blank'
|
||||
onClick={withoutPropagation(() => {})}
|
||||
>
|
||||
Export as ZIP
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
|
||||
onClick={() => editFolderVisibility(folder, !folder.public)}
|
||||
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
|
||||
>
|
||||
{folder.public ? 'Make Private' : 'Make Public'}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
|
||||
onClick={() => editFolderUploads(folder, !folder.allowUploads)}
|
||||
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
|
||||
>
|
||||
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPencil size='1rem' />} onClick={() => setEditOpen(true)}>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size='1rem' />}
|
||||
onClick={withoutPropagation(() => setEditOpen(true))}
|
||||
>
|
||||
Edit Name
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
disabled={!folder.public}
|
||||
onClick={() => copyFolderUrl(folder, clipboard)}
|
||||
onClick={withoutPropagation(() => copyFolderUrl(folder, clipboard))}
|
||||
>
|
||||
Copy URL
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
onClick={() => deleteFolder(folder)}
|
||||
onClick={withoutPropagation(() => setDeleteOpen(true))}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
@@ -89,7 +134,7 @@ export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section inheritPadding py='xs'>
|
||||
<Card.Section inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
|
||||
<Stack gap={1}>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Created:</b> <RelativeDate date={folder.createdAt} />
|
||||
@@ -101,8 +146,13 @@ export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
<b>Public:</b> {folder.public ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Files:</b> {folder.files!.length}
|
||||
<b>Files:</b> {filesCount}
|
||||
</Text>
|
||||
{childrenCount > 0 && (
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Subfolders:</b> {childrenCount}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
Executable → Regular
+9
-45
@@ -1,37 +1,23 @@
|
||||
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 { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export async function deleteFolder(folder: Folder) {
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: `Delete ${folder.name}?`,
|
||||
children: `Are you sure you want to delete ${folder.name}? This action cannot be undone.`,
|
||||
labels: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Delete',
|
||||
},
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => handleDeleteFolder(folder),
|
||||
onCancel: modals.closeAll,
|
||||
});
|
||||
}
|
||||
|
||||
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
|
||||
const url = getDomain(`/folder/${folder.id}`);
|
||||
clipboard.copy(url);
|
||||
|
||||
notifications.show({
|
||||
title: 'Copied link',
|
||||
message: (
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
|
||||
{url}
|
||||
</Anchor>
|
||||
),
|
||||
color: 'green',
|
||||
@@ -64,7 +50,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder();
|
||||
}
|
||||
|
||||
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
@@ -92,33 +78,11 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder();
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(folder: Folder) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'folder',
|
||||
},
|
||||
);
|
||||
export async function mutateFolder(folderId?: string) {
|
||||
if (folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to delete folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder deleted',
|
||||
message: `${data?.name} has been deleted`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
|
||||
}
|
||||
|
||||
Executable → Regular
+179
-13
@@ -2,20 +2,60 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tooltip } from '@mantine/core';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import { SEPARATOR, useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { IconFolderPlus, IconHome, IconPlus, IconShare } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import FilesGridView from '../files/views/FilesGridView';
|
||||
import FilesTableView from '../files/views/FilesTableView';
|
||||
import { mutateFolder } from './actions';
|
||||
import FolderGridView from './views/FolderGridView';
|
||||
import FolderTableView from './views/FolderTableView';
|
||||
|
||||
export default function DashboardFolders() {
|
||||
const view = useViewStore((state) => state.folders);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(true);
|
||||
|
||||
const folderPath = useMemo(() => {
|
||||
const pathname = location.pathname.replace('/dashboard/folders', '');
|
||||
if (!pathname || pathname === '/') return [];
|
||||
return pathname.split('/').filter(Boolean);
|
||||
}, [location.pathname]);
|
||||
|
||||
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
|
||||
|
||||
const {
|
||||
data: currentFolder,
|
||||
error: currentFolderError,
|
||||
isLoading,
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -34,6 +74,7 @@ export default function DashboardFolders() {
|
||||
{
|
||||
name: values.name,
|
||||
isPublic: values.isPublic,
|
||||
parentId: currentFolderId ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,15 +84,71 @@ export default function DashboardFolders() {
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder();
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
if (folderId === null) {
|
||||
navigate('/dashboard/folders');
|
||||
} else {
|
||||
const newPath = [...folderPath, folderId];
|
||||
navigate(`/dashboard/folders/${newPath.join('/')}`);
|
||||
}
|
||||
},
|
||||
[navigate, folderPath],
|
||||
);
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const items: FolderBreadcrumb[] = [{ id: null, name: 'Root', path: '/dashboard/folders' }];
|
||||
|
||||
if (currentFolder) {
|
||||
const path: Partial<Folder>[] = [];
|
||||
let folder: Partial<Folder> | undefined | null = currentFolder;
|
||||
|
||||
while (folder) {
|
||||
path.unshift(folder);
|
||||
folder = folder.parent;
|
||||
}
|
||||
|
||||
const folderIds: string[] = [];
|
||||
for (const f of path) {
|
||||
folderIds.push(f.id!);
|
||||
items.push({
|
||||
id: f.id!,
|
||||
name: f.name!,
|
||||
path: `/dashboard/folders/${folderIds.join('/')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
|
||||
useTitle(currentFolder ? `Folders ${SEPARATOR} ${currentFolder.name}` : 'Folders');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFolderId) return;
|
||||
if (isLoading) return;
|
||||
|
||||
if (currentFolderError || !currentFolder) {
|
||||
navigate('/dashboard/folders', { replace: true });
|
||||
}
|
||||
}, [currentFolderId, currentFolder, currentFolderError, isLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal centered opened={open} onClose={() => setOpen(false)} title='Create a folder'>
|
||||
<Modal
|
||||
centered
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={currentFolderId ? 'Create a subfolder' : 'Create a folder'}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap='sm'>
|
||||
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
|
||||
@@ -71,16 +168,85 @@ export default function DashboardFolders() {
|
||||
<Group>
|
||||
<Title>Folders</Title>
|
||||
|
||||
<Tooltip label='Create a new folder'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-sm'
|
||||
leftSection={<IconPlus size='1rem' />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Create{currentFolderId ? ' Subfolder' : ' Folder'}
|
||||
</Button>
|
||||
|
||||
<GridTableSwitcher type='folders' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <FolderGridView /> : <FolderTableView />}
|
||||
{breadcrumbs.length > 1 && (
|
||||
<Breadcrumbs my='sm'>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Anchor
|
||||
key={item.id ?? 'root'}
|
||||
onClick={() => navigate(item.path!)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
fw={index === breadcrumbs.length - 1 ? 600 : 400}
|
||||
>
|
||||
{index === 0 ? <IconHome size='1rem' /> : item.name}
|
||||
</Anchor>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
|
||||
{view === 'grid' ? (
|
||||
<FolderGridView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
|
||||
) : (
|
||||
<FolderTableView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
|
||||
)}
|
||||
|
||||
{currentFolderId && currentFolder && (
|
||||
<Box>
|
||||
<Divider mx='-xs' my='xs' />
|
||||
{currentFolder?.allowUploads && (
|
||||
<Alert
|
||||
icon={<IconShare size='1rem' />}
|
||||
variant='outline'
|
||||
mb='sm'
|
||||
styles={{ message: { marginTop: 0 } }}
|
||||
>
|
||||
This folder allows anonymous uploads. Share the link below to allow others to let others upload
|
||||
files to this folder.
|
||||
<br />
|
||||
<Anchor href={`/folder/${currentFolder.id}/upload`} target='_blank'>
|
||||
{`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}
|
||||
</Anchor>
|
||||
<CopyButton value={`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}>
|
||||
{({ copied, copy }) => (
|
||||
<Button mx='sm' size='compact-xs' color={copied ? 'teal' : 'blue'} onClick={copy}>
|
||||
{copied ? 'Copied url' : 'Copy url'}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
)}
|
||||
<Text
|
||||
mt='sm'
|
||||
c='dimmed'
|
||||
size='sm'
|
||||
onClick={() => setFilesOpen((o) => !o)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{filesOpen ? '▼' : '▶'} {currentFolder.name}'s files{' '}
|
||||
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
|
||||
</Text>
|
||||
<Collapse in={filesOpen}>
|
||||
{view === 'grid' ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<FilesGridView folderId={currentFolderId} />
|
||||
</Paper>
|
||||
) : (
|
||||
<FilesTableView folderId={currentFolderId} />
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
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 { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade';
|
||||
|
||||
export default function DeleteFolderModal({
|
||||
folder,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('root');
|
||||
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const combobox = useCombobox();
|
||||
|
||||
const { data: allFolders } = useFolders(undefined, opened);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!allFolders || !folder) return [];
|
||||
// Exclude the folder being deleted
|
||||
const excludeIds = new Set([folder.id]);
|
||||
return buildFolderHierarchy(allFolders, excludeIds);
|
||||
}, [allFolders, folder]);
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
const hasChildren = (folder._count?.children ?? 0) > 0;
|
||||
const hasFiles = (folder._count?.files ?? 0) > 0;
|
||||
const hasContent = hasChildren || hasFiles;
|
||||
|
||||
const getDisplayValue = () => {
|
||||
const selected = folderOptions.find((f) => f.id === targetFolderId);
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
body,
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to delete folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder deleted',
|
||||
message: `${folder.name} has been deleted`,
|
||||
color: 'green',
|
||||
});
|
||||
mutateFolder();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
|
||||
{hasContent && (
|
||||
<>
|
||||
<Text size='sm'>
|
||||
This folder contains {hasFiles && `${folder._count?.files} file(s)`}
|
||||
{hasChildren && hasFiles && ' and '}
|
||||
{hasChildren && `${folder._count?.children} subfolder(s)`}. What would you like to do with them?
|
||||
</Text>
|
||||
|
||||
<Radio.Group value={childrenAction} onChange={(v) => setChildrenAction(v as ChildrenAction)}>
|
||||
<Stack gap='xs'>
|
||||
<Radio value='root' label='Move contents to root folder' />
|
||||
<Radio value='folder' label='Move contents to another folder' />
|
||||
<Radio
|
||||
value='cascade'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete everything (cascade delete)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{childrenAction === 'folder' && (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={true}
|
||||
onOptionSubmit={(value) => {
|
||||
setTargetFolderId(value);
|
||||
setSearch(folderOptions.find((f) => f.id === value)?.path || '');
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label='Target Folder'
|
||||
placeholder='Select a folder'
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search || getDisplayValue()}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
rightSectionPointerEvents='none'
|
||||
required
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<FolderComboboxOptions folderOptions={folderOptions} searchValue={search} />
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
|
||||
{childrenAction === 'cascade' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all contents within this folder (subfolders will be
|
||||
deleted, and files will be unlinked from their folders).
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
loading={loading}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
>
|
||||
Delete Folder
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -1,4 +1,3 @@
|
||||
import { mutateFolders } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import type { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
@@ -7,6 +6,8 @@ import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
export default function EditFolderNameModal({
|
||||
folder,
|
||||
onClose,
|
||||
@@ -28,7 +29,7 @@ export default function EditFolderNameModal({
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
if (!folder) return;
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder?.id}`,
|
||||
'PATCH',
|
||||
{
|
||||
@@ -42,10 +43,10 @@ export default function EditFolderNameModal({
|
||||
message: error.error,
|
||||
});
|
||||
} else {
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
showNotification({
|
||||
title: 'Folder name updated',
|
||||
message: 'Folder name has been updated successfully to ' + name,
|
||||
message: 'Folder name has been updated successfully to ' + data?.name,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
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 { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderSymlink } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
export default function MoveFolderModal({
|
||||
folder,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [selectedParentId, setSelectedParentId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const combobox = useCombobox();
|
||||
|
||||
const { data: allFolders } = useFolders(undefined, opened);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!allFolders || !folder) return [];
|
||||
|
||||
const descendantIds = getDescendantIds(folder.id, allFolders);
|
||||
// Exclude the folder being moved and its descendants
|
||||
const excludeIds = new Set([folder.id, ...descendantIds]);
|
||||
|
||||
return buildFolderHierarchy(allFolders, excludeIds);
|
||||
}, [allFolders, folder]);
|
||||
|
||||
const getDisplayValue = () => {
|
||||
if (selectedParentId === '__root__' || selectedParentId === null) {
|
||||
return '/ (Root)';
|
||||
}
|
||||
const selected = folderOptions.find((f) => f.id === selectedParentId);
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleMove = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const newParentId = selectedParentId === '__root__' ? null : selectedParentId;
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'PATCH',
|
||||
{ parentId: newParentId },
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to move folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder moved',
|
||||
message: `${folder.name} has been moved`,
|
||||
color: 'green',
|
||||
});
|
||||
mutateFolder();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal key={folder.id} centered opened={opened} onClose={onClose} title={`Move "${folder.name}"`}>
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Select a destination folder for this folder.
|
||||
</Text>
|
||||
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={true}
|
||||
onOptionSubmit={(value) => {
|
||||
setSelectedParentId(value);
|
||||
setSearch(
|
||||
value === '__root__' ? '/ (Root)' : folderOptions.find((f) => f.id === value)?.path || '',
|
||||
);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label='Destination'
|
||||
placeholder='Select a folder'
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search || getDisplayValue()}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
additionalOptions={<Combobox.Option value='__root__'>/ (Root)</Combobox.Option>}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
loading={loading}
|
||||
leftSection={<IconFolderSymlink size='1rem' />}
|
||||
variant='outline'
|
||||
>
|
||||
Move Folder
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
+16
-8
@@ -1,13 +1,21 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconLink } from '@tabler/icons-react';
|
||||
import { IconFolder } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
import FolderCard from '../FolderCard';
|
||||
|
||||
export default function FolderGridView() {
|
||||
const { data: folders, isLoading } =
|
||||
useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
|
||||
export default function FolderGridView({
|
||||
currentFolderId,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}) {
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,7 +34,7 @@ export default function FolderGridView() {
|
||||
<Skeleton key={i} height={120} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (folders?.length ?? 0 !== 0) ? (
|
||||
) : (folders?.length ?? 0) !== 0 ? (
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
spacing='md'
|
||||
@@ -38,7 +46,7 @@ export default function FolderGridView() {
|
||||
pos='relative'
|
||||
>
|
||||
{folders?.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} />
|
||||
<FolderCard key={folder.id} folder={folder} onNavigate={onNavigate} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
@@ -46,11 +54,11 @@ export default function FolderGridView() {
|
||||
<Center>
|
||||
<Stack>
|
||||
<Group>
|
||||
<IconLink size='2rem' />
|
||||
<IconFolder size='2rem' />
|
||||
<Title order={2}>No Folders found</Title>
|
||||
</Group>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Create a folder to see it here
|
||||
{currentFolderId ? 'This folder is empty' : 'Create a folder to see it here'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
Executable → Regular
+137
-72
@@ -1,30 +1,128 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon, Badge, Box, Checkbox, Group, Menu, Text, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconFiles,
|
||||
IconDots,
|
||||
IconFileZip,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderSymlink,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPencil,
|
||||
IconShare,
|
||||
IconShareOff,
|
||||
IconTrashFilled,
|
||||
IconZip,
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from '../actions';
|
||||
import EditFolderNameModal from '../EditFolderNameModal';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from '../actions';
|
||||
import DeleteFolderModal from '../modals/DeleteFolderModal';
|
||||
import EditFolderNameModal from '../modals/EditFolderNameModal';
|
||||
import MoveFolderModal from '../modals/MoveFolderModal';
|
||||
import ViewFilesModal from '../modals/ViewFilesModal';
|
||||
|
||||
export default function FolderTableView() {
|
||||
export const withoutPropagation = (fn: () => void) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
function FolderDotsMenu({
|
||||
folder,
|
||||
onNavigate,
|
||||
setDeleteOpen,
|
||||
setMoveOpen,
|
||||
setEditNameOpen,
|
||||
}: {
|
||||
folder: Folder;
|
||||
onNavigate: (folderId: string) => void;
|
||||
setDeleteOpen: (folder: Folder) => void;
|
||||
setMoveOpen: (folder: Folder) => void;
|
||||
setEditNameOpen: (folder: Folder) => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu shadow='md' width={200} opened={opened} onChange={setOpened}>
|
||||
<Menu.Target>
|
||||
<Tooltip label='More actions'>
|
||||
<ActionIcon onClick={withoutPropagation(() => setOpened((o) => !o))}>
|
||||
<IconDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onNavigate && (
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderOpen size='1rem' />}
|
||||
onClick={withoutPropagation(() => onNavigate(folder.id!))}
|
||||
>
|
||||
Open Folder
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderSymlink size='1rem' />}
|
||||
onClick={withoutPropagation(() => setMoveOpen(folder))}
|
||||
>
|
||||
Move Folder
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileZip size='1rem' />}
|
||||
component='a'
|
||||
href={`/api/user/folders/${folder.id}/export`}
|
||||
target='_blank'
|
||||
onClick={withoutPropagation(() => {})}
|
||||
>
|
||||
Export as ZIP
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
|
||||
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
|
||||
>
|
||||
{folder.public ? 'Make Private' : 'Make Public'}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
|
||||
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
|
||||
>
|
||||
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size='1rem' />}
|
||||
onClick={withoutPropagation(() => setEditNameOpen(folder))}
|
||||
>
|
||||
Edit Name
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
onClick={withoutPropagation(() => setDeleteOpen(folder))}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FolderTableView({
|
||||
currentFolderId,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
@@ -32,6 +130,8 @@ export default function FolderTableView() {
|
||||
});
|
||||
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
|
||||
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
|
||||
const [moveOpen, setMoveOpen] = useState<Folder | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState<Folder | null>(null);
|
||||
|
||||
const sorted = useMemo<Folder[]>(() => {
|
||||
if (!data) return [];
|
||||
@@ -62,35 +162,45 @@ export default function FolderTableView() {
|
||||
onClose={() => setEditNameOpen(null)}
|
||||
/>
|
||||
|
||||
<MoveFolderModal opened={!!moveOpen} folder={moveOpen} onClose={() => setMoveOpen(null)} />
|
||||
|
||||
<DeleteFolderModal opened={!!deleteOpen} folder={deleteOpen} onClose={() => setDeleteOpen(null)} />
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
onRowClick={({ record }) => onNavigate(record.id)}
|
||||
rowStyle={() => ({ cursor: 'pointer' })}
|
||||
noRecordsText='No subfolders'
|
||||
columns={[
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
render: (folder) =>
|
||||
folder.public ? (
|
||||
<Anchor href={`/folder/${folder.id}`} target='_blank'>
|
||||
{folder.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
folder.name
|
||||
),
|
||||
render: (folder) => (
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1rem' />
|
||||
<Text>{folder.name}</Text>
|
||||
{(folder._count?.children ?? 0) > 0 && (
|
||||
<Badge size='xs' variant='light'>
|
||||
{folder._count?.children} subfolder{(folder._count?.children ?? 0) > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'public',
|
||||
sortable: true,
|
||||
render: (folder) => <Checkbox checked={folder.public} />,
|
||||
render: (folder) => <Checkbox checked={folder.public} readOnly />,
|
||||
},
|
||||
{
|
||||
accessor: 'allowUploads',
|
||||
title: 'Uploads?',
|
||||
sortable: true,
|
||||
render: (folder) => <Checkbox checked={folder.allowUploads} />,
|
||||
render: (folder) => <Checkbox checked={folder.allowUploads} readOnly />,
|
||||
},
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
@@ -109,16 +219,14 @@ export default function FolderTableView() {
|
||||
textAlign: 'right',
|
||||
render: (folder) => (
|
||||
<Group gap='sm' justify='right' wrap='nowrap'>
|
||||
<Tooltip label='View files'>
|
||||
<ActionIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolder(folder);
|
||||
}}
|
||||
>
|
||||
<IconFiles size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<FolderDotsMenu
|
||||
folder={folder}
|
||||
onNavigate={onNavigate}
|
||||
setDeleteOpen={setDeleteOpen}
|
||||
setMoveOpen={setMoveOpen}
|
||||
setEditNameOpen={setEditNameOpen}
|
||||
/>
|
||||
|
||||
<Tooltip label='Copy folder link'>
|
||||
<ActionIcon
|
||||
onClick={(e) => {
|
||||
@@ -130,55 +238,12 @@ export default function FolderTableView() {
|
||||
<IconCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
|
||||
<ActionIcon
|
||||
color={folder.public ? 'blue' : 'gray'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editFolderVisibility(folder, !folder.public);
|
||||
}}
|
||||
>
|
||||
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={folder.allowUploads ? 'Disable anonymous uploads' : 'Allow anonymous uploads'}
|
||||
>
|
||||
<ActionIcon
|
||||
color={folder.allowUploads ? 'blue' : 'gray'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editFolderUploads(folder, !folder.allowUploads);
|
||||
}}
|
||||
>
|
||||
{folder.allowUploads ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Edit Folder Name'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditNameOpen(folder);
|
||||
}}
|
||||
>
|
||||
<IconPencil size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Export folder as ZIP'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
|
||||
>
|
||||
<IconZip size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete Folder'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteFolder(folder);
|
||||
setDeleteOpen(folder);
|
||||
}}
|
||||
>
|
||||
<IconTrashFilled size='1rem' />
|
||||
|
||||
Executable → Regular
+13
-4
@@ -1,12 +1,18 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
|
||||
import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { copyInviteUrl, deleteInvite } from './actions';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { IconCopy, IconDots, IconQrcode, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { copyInviteUrl, deleteInvite } from './actions';
|
||||
|
||||
export default function InviteCard({ invite }: { invite: Invite }) {
|
||||
export default function InviteCard({
|
||||
invite,
|
||||
setQrOpen,
|
||||
}: {
|
||||
invite: Invite;
|
||||
setQrOpen: (invite: Invite) => void;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -36,6 +42,9 @@ export default function InviteCard({ invite }: { invite: Invite }) {
|
||||
>
|
||||
Copy URL
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconQrcode size='1rem' />} onClick={() => setQrOpen(invite)}>
|
||||
Show QR Code
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user