Compare commits

...

90 Commits

Author SHA1 Message Date
diced cb2590aae5 feat(v4.5.2): version 2026-03-28 23:58:39 -07:00
diced 93ff18a120 fix: reformat routes to include catch-all 2026-03-28 23:57:51 -07:00
diced 4343f130fb fix: refine batch uploads 2026-03-28 23:36:52 -07:00
diced 5e9778d18a fix: mfa showing when disabled 2026-03-28 20:35:49 -07:00
diced 9bcccbc8aa fix: #1031 2026-03-28 19:41:02 -07:00
diced 00ddf86ea8 feat(v4.5.1): version 2026-03-27 23:13:22 -07:00
diced cc582f6d20 fix: clean up tampered settings scroller 2026-03-27 23:11:49 -07:00
diced 318b09feae fix: use query for file modals 2026-03-27 23:11:39 -07:00
diced d55e36375d fix: tags overflow 2026-03-27 23:11:25 -07:00
diced 40b917df30 refactor: put client stuff in lib/client 2026-03-27 13:58:35 -07:00
diced 053a50d1bc refactor: uploading files
may fix: #1019
2026-03-27 13:46:32 -07:00
diced 78b554cbe8 fix: #1021 2026-03-27 12:41:50 -07:00
diced bf7a4e92e3 chore: update packages 2026-03-25 20:56:18 -07:00
diced 3b56e7f1ce fix: maybe fix #1021 again 2026-03-24 15:52:20 -07:00
diced 1d91a008e1 fix: linting 2026-03-24 12:45:43 -07:00
diced ff1fc0eb75 fix: limit copying jpeg/webp qr codes 2026-03-24 12:43:48 -07:00
diced 430774082c fix: maybe fix #1021 2026-03-24 12:40:29 -07:00
Benjamin Jørgensen ef67fdd553 fix: encode password #1007 (#1023)
* Url Encode Password Query Parameter

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>

* Fix lint error

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>

* fix: use appendPassword

---------

Signed-off-by: Benjamin Jørgensen <me@benmi.me>
Signed-off-by: Benjamin Jørgensen <me@benmi.dev>
Co-authored-by: dicedtomato <git@diced.sh>
2026-03-24 12:27:29 -07:00
dicedtomato 73b5528586 Merge commit from fork 2026-03-24 12:19:02 -07:00
diced 3f65029464 fix: use title in folder page 2026-03-23 17:34:17 -07:00
diced 6da1719fda refactor: fix up DashboardFileType 2026-03-23 17:33:14 -07:00
diced 4d85b41ec3 feat: add qrcode gen to invites 2026-03-22 22:47:18 -07:00
diced ac5c0a1cb3 fix: table hydration error 2026-03-22 22:42:41 -07:00
diced eb22598f20 feat: add qrcode for urls (#812) 2026-03-22 22:37:54 -07:00
diced 7a4c29d9d4 fix: mutate folders on update 2026-03-22 21:58:55 -07:00
diced 255336d74f fix: version badge 2026-03-22 21:51:33 -07:00
diced dc625fc682 fix: add Domain setting (#1009) 2026-03-22 12:46:02 -07:00
diced d457cb8693 fix: don't render when not opened 2026-03-22 12:45:53 -07:00
diced 331c4b4a4e chore: update flake.nix 2026-03-21 22:39:56 -07:00
diced d8ca9dc9b5 feat(v4.5.0): version 2026-03-21 11:50:36 -07:00
diced 0eee082035 fix: #1017 2026-03-20 15:01:12 -07:00
diced 3e287e8ad7 feat: identify anon uploads
- anon uploads will be identifiable now
- still anonymous but will show that they were uploaded anonymously
- reworked table edit modal to support new fields and merge local
  state
2026-03-19 17:11:21 -07:00
diced 5ec471050e fix: #1018 2026-03-18 10:22:22 -07:00
dicedtomato 842dac2660 Merge commit from fork
* fix: advisory

* fix: typo
2026-03-16 13:13:06 -07:00
diced dee86aaa86 fix: once and for all fix #907 2026-03-10 21:37:26 -07:00
diced 13e3a58035 fix: #1014 2026-03-10 20:50:30 -07:00
diced f4382d5bd9 fix: #947 2026-03-10 15:55:31 -07:00
diced 8990801268 fix: build errors 2026-03-07 16:17:17 -08:00
diced 01b9c06513 fix: clean up stuff 2026-03-07 16:16:20 -08:00
diced fc180de616 fix: build errors 2026-03-07 14:30:36 -08:00
diced f907133d3a fix: mobile nav not closing on change 2026-03-07 14:25:22 -08:00
diced 9ae9734a3d feat: add reload page when cached modules 2026-03-07 14:14:14 -08:00
diced 770b5cf706 fix: validation errors 2026-03-07 14:14:06 -08:00
diced 56625c664d fix: lint 2026-03-03 23:54:28 -08:00
diced 056a19b946 feat: max files per upload (#991) 2026-03-03 23:52:53 -08:00
diced 281ab666c1 feat: add tags to api routes 2026-03-03 23:40:23 -08:00
dicedtomato 31df5341b5 Merge commit from fork 2026-03-03 22:19:32 -08:00
diced ec7024242f fix: remove use of union types in response for now 2026-03-03 21:30:59 -08:00
dicedtomato ef6e0e00a0 feat: response validation (#1012)
* feat: add response schemas (WIP, hella unstable!!)

* refactor: models to zod

* feat: descriptions for api routes

* fix: finish up api refactor

* refactor: generalized error codes

* fix: responses + add descriptions

* fix: more

* fix: lint

* fix: settings errors

* fix: add errors to spec
2026-03-03 16:32:50 -08:00
diced 3c757374e1 feat: revamp option selection for files page 2026-02-26 16:53:31 -08:00
diced c0e1aa9ac6 feat: revamp folders page 2026-02-26 16:11:49 -08:00
diced 40fd0b19eb feat: add multiple files for text uploads 2026-02-24 02:14:03 -08:00
diced 41240b7aff refactor: upload/partial logic + more sanitzation 2026-02-23 22:04:50 -08:00
diced 01f177fbc3 fix: permissions on docker scripts 2026-02-23 00:43:41 -08:00
diced ab1d394a46 fix: permissions 2026-02-23 00:42:01 -08:00
diced d08f1ba5da fix: #1002 2026-02-23 00:20:36 -08:00
diced 641a7c9b7b fix: maybe fix oauth issues #1001 2026-02-23 00:18:26 -08:00
diced a467ffe861 feat: new notifs position 2026-02-23 00:18:17 -08:00
dicedtomato 33ff667990 Merge commit from fork 2026-02-20 21:48:01 -08:00
diced e96015f5e0 fix: refactor + perf 2026-02-19 22:38:54 -08:00
diced d4d1cdc885 feat: revamped sessions 2026-02-15 21:18:02 -08:00
diced a7d831934d fix: add http but https warning 2026-02-12 16:30:56 -08:00
diced e9ef6a2d40 fix: #983 2026-02-12 16:05:28 -08:00
diced 7520efa835 fix: use exponential moving average for estimation (#996) 2026-02-12 15:55:22 -08:00
diced cff8454ac7 fix: no schema for settings api (from #990) 2026-02-12 14:53:38 -08:00
diced 847779601a fix: dev 2026-02-12 14:53:31 -08:00
Andrew Simonson 49c2088ea3 fix: max interval checks (#990)
* introduce max interval checks

* Update validate.ts

* Update validate.ts

* Update validate.ts

* Update validate.ts
2026-02-12 14:45:50 -08:00
diced 78600103af feat(v4.4.2): version 2026-02-10 20:49:14 -08:00
diced ce8b3ed36d fix: #985 2026-02-10 20:43:33 -08:00
diced 67641c2116 fix: proper length checks for login/register (#987) 2026-02-10 20:41:43 -08:00
diced acbbb7d40a feat: add docker scripts (ENTRYPOINT) + ziplinectl 2026-02-05 16:32:31 -08:00
Huang Cheng Ting 1f672cda3a fix: view route title & handle unicode characters in raw route (#980)
* fix: prioritize file original name in view route title

* fix: update Content-Disposition header to support unicode characters
fix: display issue with raw route when text contains unicode characters

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2026-02-05 16:05:49 -08:00
Huang Cheng Ting 2332d529e0 fix: prevent random character conflicts in uploads and urls (#978)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2026-02-05 16:03:08 -08:00
diced e910fe9da5 fix: folder issues 2026-02-05 15:59:35 -08:00
diced 4656599bb0 fix: use tmpdir() for initial 2026-02-05 15:59:21 -08:00
diced d6c33b6123 fix: clean up #961 2026-02-05 15:34:45 -08:00
Christoph Schlaepfer defcc7950d feat: nested folders (#961)
* Added nested folders feature

* Fixed Linting

* Fixed Linting

* Fixed Linting

* Fixed linting

* Fixed import

* Fixed dashboard view

* Fixed dashboard view

* Added DB Migration

* Fixed dropdown selection

* Added structured dropdowns to file dialog

* Fixes Nested Folder depth lookup & Breadcrumbs

* Fixes Nested Folder dropdowns

* Linting

* Fixes Export Filename

* Fixes export hierarchy

* Implemented Reviewed Feedback, improved code

* Removed more comments
2026-02-05 14:48:44 -08:00
diced 3d55ce0def fix: change buttons + add buttons 2026-02-04 14:29:25 -08:00
diced 8c9df5af5d feat: add domain selection to urls (#977) 2026-02-03 17:48:03 -08:00
diced 5c33ae134a fix: bunch of validation fixes 2026-02-02 21:13:58 -08:00
diced b628489330 fix: build errors 2026-01-31 18:36:34 -08:00
diced e9a6e31d4f fix: add passkey warning for old passkeys 2026-01-31 18:21:22 -08:00
diced ebe37cf7c1 fix: add warning when accessing over secure (login) 2026-01-31 18:11:29 -08:00
diced 529708110b fix: metrics validations 2026-01-31 15:01:49 -08:00
diced 9066dd37fb feat: remove built-in SSL 2026-01-31 14:16:51 -08:00
diced 45848925f4 fix: validation issues edit user 2026-01-28 16:29:54 -08:00
diced 2ba1da1671 fix: don't log db 2026-01-22 15:08:38 -08:00
diced 35c7d6b70c fix: #964 2026-01-22 15:03:23 -08:00
diced f45d1b770f fix: #966 2026-01-22 15:01:32 -08:00
diced 3650178ab3 fix: #968 2026-01-22 14:21:38 -08:00
337 changed files with 9946 additions and 5383 deletions
Executable → Regular
View File
Executable → Regular
View File
+2 -3
View File
@@ -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 Ziplines functionality.
Executable → Regular
View File
Executable → Regular
View File
@@ -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
View File
Executable → Regular
View File
Executable → Regular
+8 -3
View File
@@ -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"]
Executable → Regular
View File
Executable → Regular
+1 -5
View File
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
This is the recommended way to run Zipline:
```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
View File
@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 4.2.x | :white_check_mark: |
| 4.4.x | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/server
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/ctl "$@"
Generated
+645 -39
View File
@@ -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"
}
}
},
+2 -8
View File
@@ -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
View File
Executable → Regular
+51 -47
View File
@@ -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"
}
Generated Executable → Regular
+1597 -1551
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
View File
Executable → Regular
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+110
View File
@@ -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
View File
@@ -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'
+37
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 && (
-35
View File
@@ -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 />;
}
+18 -9
View File
@@ -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({
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Container, LoadingOverlay } from '@mantine/core';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Terms of Service');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/hooks/useTitle';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
+1 -1
View File
@@ -1,5 +1,5 @@
import DashboardInvites from '@/components/pages/invites';
import { useTitle } from '@/lib/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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View 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 -1
View File
@@ -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');
+128 -21
View File
@@ -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>
</>
);
+4 -6
View File
@@ -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.
+14 -8
View File
@@ -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
View File
@@ -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'),
},
],
},
+32 -27
View File
@@ -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 {
View File
+56
View File
@@ -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}
/>
);
}
+1 -1
View File
@@ -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
View File
@@ -1,11 +1,11 @@
import type { Response } from '@/lib/api/response';
import useAvatar from '@/lib/client/hooks/useAvatar';
import useLogin from '@/lib/client/hooks/useLogin';
import { useLogout } from '@/lib/client/hooks/useLogout';
import { useUserStore } from '@/lib/client/store/user';
import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
AppShell,
Avatar,
@@ -47,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>
+118
View File
@@ -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
View File
Executable → Regular
View File
Executable → Regular
+2 -2
View File
@@ -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';
+14 -5
View File
@@ -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',
},
]}
/>
+61 -33
View File
@@ -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' />}
/>
+41 -32
View File
@@ -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 &quot;{search}&quot;
</Combobox.Option>
)}
</Combobox.Options>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
View File
View File
View File
+83 -64
View File
@@ -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
View File
@@ -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>
);
}
+43 -9
View File
@@ -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>
);
}
+3 -14
View File
@@ -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>
View File
+101 -42
View File
@@ -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} />
)}
</>
);
View File
View File
+7 -2
View File
@@ -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>
);
}
@@ -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>
</>
);
}
+2
View File
@@ -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()}` : ''}`);
+1 -1
View File
@@ -1,4 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import {
Accordion,
Button,
@@ -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;
@@ -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,
{
+1 -1
View File
@@ -1,4 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import {
Accordion,
Button,
+76 -26
View File
@@ -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>
+9 -45
View File
@@ -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'));
}
+179 -13
View File
@@ -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}&#39;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>
);
}
@@ -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>
);
}
+16 -8
View File
@@ -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>
+137 -72
View File
@@ -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' />
+13 -4
View File
@@ -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