mirror of
https://github.com/diced/zipline.git
synced 2026-06-29 17:23:41 -07:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ca9dc9b5 | |||
| 0eee082035 | |||
| 3e287e8ad7 | |||
| 5ec471050e | |||
| 842dac2660 | |||
| dee86aaa86 | |||
| 13e3a58035 | |||
| f4382d5bd9 | |||
| 8990801268 | |||
| 01b9c06513 | |||
| fc180de616 | |||
| f907133d3a | |||
| 9ae9734a3d | |||
| 770b5cf706 | |||
| 56625c664d | |||
| 056a19b946 | |||
| 281ab666c1 | |||
| 31df5341b5 | |||
| ec7024242f | |||
| ef6e0e00a0 | |||
| 3c757374e1 | |||
| c0e1aa9ac6 | |||
| 40fd0b19eb | |||
| 41240b7aff | |||
| 01f177fbc3 | |||
| ab1d394a46 | |||
| d08f1ba5da | |||
| 641a7c9b7b | |||
| a467ffe861 | |||
| 33ff667990 | |||
| e96015f5e0 | |||
| d4d1cdc885 | |||
| a7d831934d | |||
| e9ef6a2d40 | |||
| 7520efa835 | |||
| cff8454ac7 | |||
| 847779601a | |||
| 49c2088ea3 | |||
| 78600103af | |||
| ce8b3ed36d | |||
| 67641c2116 | |||
| acbbb7d40a | |||
| 1f672cda3a | |||
| 2332d529e0 | |||
| e910fe9da5 | |||
| 4656599bb0 | |||
| d6c33b6123 | |||
| defcc7950d | |||
| 3d55ce0def | |||
| 8c9df5af5d | |||
| 5c33ae134a | |||
| b628489330 | |||
| e9a6e31d4f | |||
| ebe37cf7c1 | |||
| 529708110b | |||
| 9066dd37fb | |||
| 45848925f4 | |||
| 2ba1da1671 | |||
| 35c7d6b70c | |||
| f45d1b770f | |||
| 3650178ab3 | |||
| 574bd9114c | |||
| 73c46b875d | |||
| e21670f292 | |||
| 09b3ef4e26 | |||
| afdee6994e | |||
| 6f6879c58a | |||
| 66a2f760cf | |||
| fb3199a9d5 | |||
| 274a84397a | |||
| 4b585d8634 | |||
| 260c283872 | |||
| 4d978c11b1 | |||
| 8bdd9e8315 | |||
| d4a3e877d2 | |||
| db3c5f48a5 | |||
| cdcaa926fe | |||
| 01503968ab | |||
| 8aa5ec6917 | |||
| 9befcaaf80 | |||
| bfc0e4d40c | |||
| 4fb21f678e | |||
| f49598c760 | |||
| bfd6a8769d | |||
| 87cf4916a5 | |||
| 12ea806f0a | |||
| 6269b457d8 | |||
| 78f5875464 | |||
| 05df685bd1 | |||
| eaf245a4c9 | |||
| 8a7b401b6e | |||
| bb13e44bc9 | |||
| 2c21e119c4 |
Executable → Regular
Executable → Regular
@@ -17,7 +17,7 @@ body:
|
||||
id: runtime-type
|
||||
attributes:
|
||||
label: How is Zipline being run?
|
||||
description:
|
||||
description:
|
||||
options:
|
||||
- On docker (docker, docker compose, etc.)
|
||||
- Built from source (running it through `pnpm start` or `node`, etc.)
|
||||
@@ -34,7 +34,7 @@ body:
|
||||
- If version checking is enabled (it is by default): paste the response from `http://<domain>/api/version`
|
||||
- If using docker (and can't do the above): specify the tag you are using (`latest`, `trunk`, or a tag digest)
|
||||
- A simple version number (e.g. "4.2.1") may also suffice
|
||||
placeholder: "4.2.1"
|
||||
placeholder: '4.2.1'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -68,4 +68,3 @@ body:
|
||||
description: |
|
||||
Please list the exact steps required to reproduce the issue.
|
||||
Include any relevant configuration options, settings, or external services that may affect Zipline’s functionality.
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -0,0 +1,99 @@
|
||||
name: Generate OpenAPI Spec
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v4, trunk]
|
||||
pull_request:
|
||||
branches: [v4, trunk]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
gen-openapi:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [24.x]
|
||||
arch: [amd64]
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: zipline
|
||||
POSTGRES_PASSWORD: zipline
|
||||
POSTGRES_DB: zipline
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U zipline -d zipline"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use node@${{ matrix.node }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
ZIPLINE_BUILD: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Generate secret
|
||||
id: secret
|
||||
run: |
|
||||
SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9')
|
||||
echo "secret=$SECRET" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Wait for Postgres
|
||||
run: |
|
||||
until pg_isready -h localhost -p 5432 -U zipline; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run generator
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
NODE_ENV: production
|
||||
run: pnpm openapi
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
if [ ! -f "./openapi.json" ]; then
|
||||
echo "openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openapi-json
|
||||
path: ./openapi.json
|
||||
Executable → Regular
+1
@@ -50,3 +50,4 @@ uploads*/
|
||||
*.key
|
||||
src/prisma
|
||||
.memory.log*
|
||||
openapi.json
|
||||
|
||||
Executable → Regular
Executable → Regular
+8
-3
@@ -33,8 +33,6 @@ COPY code.json ./code.json
|
||||
COPY vite-env.d.ts ./vite-env.d.ts
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN ZIPLINE_BUILD=true pnpm run build
|
||||
|
||||
FROM base
|
||||
@@ -52,8 +50,15 @@ RUN pnpm prisma generate
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ZIPLINE_ROOT=/zipline
|
||||
|
||||
ARG ZIPLINE_GIT_SHA
|
||||
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
|
||||
|
||||
CMD ["node", "--enable-source-maps", "build/server"]
|
||||
# add scripts
|
||||
COPY docker/entrypoint.sh /zipline/entrypoint
|
||||
COPY docker/ziplinectl.sh /zipline/ziplinectl
|
||||
|
||||
RUN ln -s /zipline/ziplinectl /usr/local/bin/ziplinectl
|
||||
|
||||
ENTRYPOINT ["/zipline/entrypoint"]
|
||||
|
||||
@@ -260,10 +260,6 @@ DATASOURCE_LOCAL_DIRECTORY="/path/to/your/local/files"
|
||||
# DATASOURCE_S3_BUCKET="your-bucket"
|
||||
# DATASOURCE_S3_ENDPOINT="your-endpoint"
|
||||
# ^ if using a custom endpoint other than aws s3
|
||||
|
||||
# optional but both are required if using ssl
|
||||
# SSL_KEY="/path/to/your/ssl/key"
|
||||
# SSL_CERT="/path/to/your/ssl/cert"
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 4.2.x | :white_check_mark: |
|
||||
| 4.4.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
+1
-1
@@ -6,7 +6,7 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRESQL_USER:-zipline}
|
||||
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESSQL_PASSWORD is required}
|
||||
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESQL_PASSWORD is required}
|
||||
POSTGRES_DB: ${POSTGRESQL_DB:-zipline}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
cd ${ZIPLINE_ROOT:-/zipline}
|
||||
exec node --enable-source-maps build/server
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
cd ${ZIPLINE_ROOT:-/zipline}
|
||||
exec node --enable-source-maps build/ctl "$@"
|
||||
|
||||
@@ -22,10 +22,6 @@ const gitignorePatterns = gitignoreContent
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
const reactRecommendedRules = reactPlugin.configs.recommended.rules;
|
||||
const reactHooksRecommendedRules = reactHooksPlugin.configs['recommended-latest'].rules;
|
||||
const reactRefreshRules = reactRefreshPlugin.configs.vite.rules;
|
||||
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default defineConfig(
|
||||
|
||||
Executable → Regular
Executable → Regular
+15
-8
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"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",
|
||||
@@ -32,7 +33,7 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@fastify/swagger": "^9.6.1",
|
||||
"@mantine/charts": "^8.3.9",
|
||||
"@mantine/code-highlight": "^8.3.9",
|
||||
"@mantine/core": "^8.3.9",
|
||||
@@ -47,6 +48,8 @@
|
||||
"@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",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -59,11 +62,14 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"detect-browser": "^5.3.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
@@ -84,13 +90,14 @@
|
||||
"swr": "^2.3.7",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7",
|
||||
"zod": "^4.1.13",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
@@ -121,5 +128,5 @@
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
}
|
||||
|
||||
+363
-38
@@ -41,9 +41,9 @@ importers:
|
||||
'@fastify/static':
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0
|
||||
'@github/webauthn-json':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
'@fastify/swagger':
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1
|
||||
'@mantine/charts':
|
||||
specifier: ^8.3.9
|
||||
version: 8.3.9(@mantine/core@8.3.9(@mantine/hooks@8.3.9(react@19.2.1))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@mantine/hooks@8.3.9(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(recharts@2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1))
|
||||
@@ -86,6 +86,12 @@ importers:
|
||||
'@prisma/migrate':
|
||||
specifier: 6.13.0
|
||||
version: 6.13.0(@prisma/internals@6.13.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@simplewebauthn/server':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@smithy/node-http-handler':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
@@ -122,6 +128,9 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
detect-browser:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
@@ -134,9 +143,15 @@ importers:
|
||||
fastify-plugin:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
fastify-type-provider-zod:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.3.6)
|
||||
fluent-ffmpeg:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
he:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -196,10 +211,10 @@ importers:
|
||||
version: 8.48.1(eslint@9.39.1(jiti@2.5.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.2.7
|
||||
version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)
|
||||
version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)
|
||||
zod:
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.13
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
zustand:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1))
|
||||
@@ -213,6 +228,9 @@ importers:
|
||||
'@types/fluent-ffmpeg':
|
||||
specifier: ^2.1.28
|
||||
version: 2.1.28
|
||||
'@types/he':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@types/katex':
|
||||
specifier: ^0.16.7
|
||||
version: 0.16.7
|
||||
@@ -239,7 +257,7 @@ importers:
|
||||
version: 1.8.8
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0))
|
||||
version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1(jiti@2.5.1)
|
||||
@@ -284,7 +302,7 @@ importers:
|
||||
version: 1.8.16
|
||||
tsup:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)
|
||||
version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
@@ -1038,6 +1056,9 @@ packages:
|
||||
'@fastify/static@8.3.0':
|
||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
@@ -1059,9 +1080,8 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@github/webauthn-json@2.1.1':
|
||||
resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==}
|
||||
hasBin: true
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
@@ -1109,89 +1129,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1244,6 +1280,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1376,36 +1415,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -1429,6 +1474,43 @@ packages:
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==}
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==}
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==}
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==}
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==}
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==}
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
|
||||
|
||||
'@peculiar/x509@1.14.2':
|
||||
resolution: {integrity: sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@phc/format@1.0.0':
|
||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1563,56 +1645,67 @@ packages:
|
||||
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
|
||||
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.53.3':
|
||||
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
|
||||
@@ -1639,6 +1732,13 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@simplewebauthn/browser@13.2.2':
|
||||
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@smithy/abort-controller@4.0.5':
|
||||
resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1943,8 +2043,8 @@ packages:
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
@@ -1973,6 +2073,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/he@1.2.3':
|
||||
resolution: {integrity: sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -2227,6 +2330,10 @@ packages:
|
||||
asciinema-player@3.12.1:
|
||||
resolution: {integrity: sha512-X4tIjZEIsD7Keeu1cJbrsZZCbPSO85w2OiDRGui68JHQPjthIG2jh68TARDrf2CP2l1Lko4mevnBdwwmJfD0iw==}
|
||||
|
||||
asn1js@3.0.7:
|
||||
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
@@ -2542,8 +2649,8 @@ packages:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.0:
|
||||
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
@@ -2656,6 +2763,9 @@ packages:
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
detect-browser@5.3.0:
|
||||
resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -2910,8 +3020,8 @@ packages:
|
||||
fast-diff@1.3.0:
|
||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||
|
||||
fast-equals@5.3.3:
|
||||
resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==}
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
@@ -2943,6 +3053,14 @@ packages:
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify-type-provider-zod@6.1.0:
|
||||
resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==}
|
||||
peerDependencies:
|
||||
'@fastify/swagger': '>=9.5.1'
|
||||
fastify: ^5.5.0
|
||||
openapi-types: ^12.1.3
|
||||
zod: '>=4.1.5'
|
||||
|
||||
fastify@5.6.2:
|
||||
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
||||
|
||||
@@ -3153,6 +3271,10 @@ packages:
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
@@ -3434,6 +3556,10 @@ packages:
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -3517,6 +3643,9 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -3841,6 +3970,9 @@ packages:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4143,6 +4275,13 @@ packages:
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.1.5:
|
||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -4314,6 +4453,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4749,6 +4891,9 @@ packages:
|
||||
engines: {node: '>=16.20.2'}
|
||||
hasBin: true
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -4776,6 +4921,10 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5079,6 +5228,11 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5101,8 +5255,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
zod@4.1.13:
|
||||
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zustand@5.0.9:
|
||||
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
||||
@@ -6128,6 +6282,16 @@ snapshots:
|
||||
fastq: 1.19.1
|
||||
glob: 11.1.0
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
json-schema-resolver: 3.0.0
|
||||
openapi-types: 12.1.3
|
||||
rfdc: 1.4.1
|
||||
yaml: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -6153,7 +6317,7 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@github/webauthn-json@2.1.1': {}
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
@@ -6296,6 +6460,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@mantine/charts@8.3.9(@mantine/core@8.3.9(@mantine/hooks@8.3.9(react@19.2.1))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@mantine/hooks@8.3.9(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(recharts@2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1))':
|
||||
@@ -6471,6 +6637,102 @@ snapshots:
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pfx': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
dependencies:
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@1.14.2':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-csr': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-pkcs9': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@phc/format@1.0.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
@@ -6724,6 +6986,19 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.53.3':
|
||||
optional: true
|
||||
|
||||
'@simplewebauthn/browser@13.2.2': {}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
dependencies:
|
||||
'@hexagon/base64': 1.1.28
|
||||
'@levischuck/tiny-cbor': 0.2.11
|
||||
'@peculiar/asn1-android': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/x509': 1.14.2
|
||||
|
||||
'@smithy/abort-controller@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -7166,7 +7441,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
@@ -7205,6 +7480,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/he@1.2.3': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -7367,7 +7644,7 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0))':
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
@@ -7375,7 +7652,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.47
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)
|
||||
vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7531,6 +7808,12 @@ snapshots:
|
||||
solid-js: 1.9.10
|
||||
solid-transition-group: 0.2.3(solid-js@1.9.10)
|
||||
|
||||
asn1js@3.0.7:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.1.5
|
||||
tslib: 2.8.1
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
@@ -7807,7 +8090,7 @@ snapshots:
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.0: {}
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
@@ -7818,7 +8101,7 @@ snapshots:
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.0
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
@@ -7906,6 +8189,8 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-browser@5.3.0: {}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
optional: true
|
||||
|
||||
@@ -8168,8 +8453,8 @@ snapshots:
|
||||
'@babel/parser': 7.28.5
|
||||
eslint: 9.39.1(jiti@2.5.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.1.13
|
||||
zod-validation-error: 4.0.2(zod@4.1.13)
|
||||
zod: 4.3.6
|
||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8301,7 +8586,7 @@ snapshots:
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
|
||||
fast-equals@5.3.3: {}
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
@@ -8338,6 +8623,14 @@ snapshots:
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.3.6):
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/swagger': 9.6.1
|
||||
fastify: 5.6.2
|
||||
openapi-types: 12.1.3
|
||||
zod: 4.3.6
|
||||
|
||||
fastify@5.6.2:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
@@ -8598,6 +8891,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
@@ -8895,6 +9190,14 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
fast-uri: 3.1.0
|
||||
rfdc: 1.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -8972,6 +9275,8 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -9497,6 +9802,8 @@ snapshots:
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -9686,13 +9993,14 @@ snapshots:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.6
|
||||
|
||||
postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0):
|
||||
postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
jiti: 2.5.1
|
||||
postcss: 8.5.6
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
postcss-mixins@12.1.2(postcss@8.5.6):
|
||||
dependencies:
|
||||
@@ -9782,6 +10090,12 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.1.5: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
@@ -9875,7 +10189,7 @@ snapshots:
|
||||
|
||||
react-smooth@4.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
fast-equals: 5.3.3
|
||||
fast-equals: 5.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
@@ -9974,7 +10288,7 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
react-is: 18.3.1
|
||||
@@ -9983,6 +10297,8 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -10532,9 +10848,11 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
plimit-lit: 1.6.1
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
|
||||
tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.1)
|
||||
cac: 6.7.14
|
||||
@@ -10545,7 +10863,7 @@ snapshots:
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
picocolors: 1.1.1
|
||||
postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)
|
||||
postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
|
||||
resolve-from: 5.0.0
|
||||
rollup: 4.53.3
|
||||
source-map: 0.7.6
|
||||
@@ -10569,6 +10887,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -10759,7 +11081,7 @@ snapshots:
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.7
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
@@ -10770,7 +11092,7 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0):
|
||||
vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10785,6 +11107,7 @@ snapshots:
|
||||
sass: 1.94.2
|
||||
sugarss: 5.0.1(postcss@8.5.6)
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
@@ -10886,6 +11209,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
@@ -10913,11 +11238,11 @@ snapshots:
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.1.13):
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.1.13
|
||||
zod: 4.3.6
|
||||
|
||||
zod@4.1.13: {}
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1)):
|
||||
optionalDependencies:
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `mfaPasskeys` on the `Zipline` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" DROP COLUMN "mfaPasskeys",
|
||||
ADD COLUMN "mfaPasskeysEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "mfaPasskeysOrigin" TEXT,
|
||||
ADD COLUMN "mfaPasskeysRpID" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';
|
||||
@@ -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
+43
-20
@@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
}
|
||||
|
||||
@@ -31,19 +31,22 @@ model Zipline {
|
||||
tasksMaxViewsInterval String @default("30m")
|
||||
tasksThumbnailsInterval String @default("30m")
|
||||
tasksMetricsInterval String @default("30m")
|
||||
tasksCleanThumbnailsInterval String @default("1d")
|
||||
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
filesAssumeMimetypes Boolean @default(false)
|
||||
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
|
||||
filesRemoveGpsMetadata Boolean @default(false)
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
filesMaxExpiration String?
|
||||
filesAssumeMimetypes Boolean @default(false)
|
||||
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
|
||||
filesRemoveGpsMetadata Boolean @default(false)
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesMaxFilesPerUpload Int @default(1000)
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -64,7 +67,7 @@ model Zipline {
|
||||
featuresMetricsShowUserSpecific Boolean @default(true)
|
||||
|
||||
featuresVersionChecking Boolean @default(true)
|
||||
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||
|
||||
invitesEnabled Boolean @default(true)
|
||||
invitesLength Int @default(6)
|
||||
@@ -107,7 +110,10 @@ model Zipline {
|
||||
|
||||
mfaTotpEnabled Boolean @default(false)
|
||||
mfaTotpIssuer String @default("Zipline")
|
||||
mfaPasskeys Boolean @default(false)
|
||||
|
||||
mfaPasskeysEnabled Boolean @default(false)
|
||||
mfaPasskeysRpID String?
|
||||
mfaPasskeysOrigin String?
|
||||
|
||||
ratelimitEnabled Boolean @default(true)
|
||||
ratelimitMax Int @default(10)
|
||||
@@ -141,7 +147,7 @@ model Zipline {
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -158,7 +164,7 @@ model User {
|
||||
|
||||
totpSecret String?
|
||||
passkeys UserPasskey[]
|
||||
sessions String[]
|
||||
sessions UserSession[]
|
||||
|
||||
quota UserQuota?
|
||||
|
||||
@@ -186,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())
|
||||
@@ -265,6 +283,7 @@ model File {
|
||||
maxViews Int?
|
||||
favorite Boolean @default(false)
|
||||
password String?
|
||||
anonymous Boolean @default(false)
|
||||
|
||||
tags Tag[]
|
||||
|
||||
@@ -295,12 +314,16 @@ model Folder {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
allowUploads Boolean @default(false)
|
||||
|
||||
files File[]
|
||||
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
children Folder[] @relation("FolderToFolder")
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
userId String
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,4 +1,6 @@
|
||||
export function step(name: string, command: string, condition: () => boolean = () => true) {
|
||||
type StepCommand = string | (() => void | Promise<void>);
|
||||
|
||||
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
@@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) {
|
||||
|
||||
try {
|
||||
log(`> Running step "${name}/${step.name}"...`);
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
if (typeof step.command === 'string') {
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
} else {
|
||||
await step.command();
|
||||
}
|
||||
} catch {
|
||||
console.error(`x Step "${name}/${step.name}" failed.`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { run, step } from '.';
|
||||
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
|
||||
|
||||
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
|
||||
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
|
||||
|
||||
const ALL_ERRORS = Object.keys(API_ERRORS)
|
||||
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
|
||||
.sort((a, b) => a.code - b.code);
|
||||
|
||||
const ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
description: 'Generic error for API endpoints.',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
|
||||
},
|
||||
code: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description:
|
||||
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
|
||||
enum: ALL_ERRORS.map((entry) => entry.code),
|
||||
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
|
||||
},
|
||||
statusCode: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description: 'HTTP status code returned alongside this error payload.',
|
||||
},
|
||||
},
|
||||
required: ['error', 'code', 'statusCode'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
|
||||
examples[`E${entry.code}`] = {
|
||||
summary: `${entry.error}`,
|
||||
value: entry,
|
||||
};
|
||||
|
||||
return examples;
|
||||
}, {});
|
||||
|
||||
const generic4xxResponse = {
|
||||
description: 'API error response (4xx)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ERROR_SCHEMA,
|
||||
examples: ERROR_EXAMPLES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function addErrorResponse(responses: Record<string, any>): void {
|
||||
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
|
||||
|
||||
response.description ??= generic4xxResponse.description;
|
||||
response.content ??= {};
|
||||
|
||||
const jsonContent = (response.content['application/json'] ??= {});
|
||||
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
|
||||
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
|
||||
}
|
||||
|
||||
function filterRoutes(paths = {}): Record<string, any> {
|
||||
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
|
||||
}
|
||||
|
||||
async function fixSpec() {
|
||||
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
|
||||
|
||||
spec.paths = filterRoutes(spec.paths);
|
||||
|
||||
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of ALL_METHODS) {
|
||||
const operation = (<any>pathItem)[method];
|
||||
if (!operation) continue;
|
||||
|
||||
operation.responses ??= {};
|
||||
addErrorResponse(operation.responses);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(GEN_PATH, JSON.stringify(spec));
|
||||
}
|
||||
|
||||
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
|
||||
|
||||
run(
|
||||
'openapi',
|
||||
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
|
||||
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
|
||||
step('check', async () => {
|
||||
try {
|
||||
await readFile(GEN_PATH);
|
||||
} catch (e) {
|
||||
console.error('\nSomething went wrong...', e);
|
||||
|
||||
throw new Error('No OpenAPI spec found at ./openapi.json');
|
||||
}
|
||||
}),
|
||||
step('fix', fixSpec),
|
||||
);
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default function Root({
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
<Outlet />
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import GenericError from './GenericError';
|
||||
import ReloadPage from './ReloadPage';
|
||||
|
||||
export default function DashboardErrorBoundary(props: Record<string, any>) {
|
||||
const error = useRouteError();
|
||||
if (error instanceof Error && error.message.startsWith('Failed to fetch dynamically imported module:')) {
|
||||
return <ReloadPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title='Dashboard Client Error'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, Collapse, Container, Text, Title } from '@mantine/core';
|
||||
import { IconReload } from '@tabler/icons-react';
|
||||
import GenericError from './GenericError';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ReloadPage() {
|
||||
const [view, setView] = useState(false);
|
||||
|
||||
return (
|
||||
<Container my='lg'>
|
||||
<Title order={3}>Update available</Title>
|
||||
|
||||
<Text size='lg'>A new version of the app is available. Please reload the page to update.</Text>
|
||||
|
||||
<Button
|
||||
leftSection={<IconReload size='1rem' />}
|
||||
mr='sm'
|
||||
mt='md'
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
|
||||
<Button variant='subtle' mt='md' onClick={() => setView((v) => !v)}>
|
||||
Why am I seeing this?
|
||||
</Button>
|
||||
|
||||
<Collapse in={view}>
|
||||
<GenericError
|
||||
title='Failed to fetch dynamically imported module'
|
||||
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
|
||||
details={{}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+12
-10
@@ -1,12 +1,14 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function FourOhFour() {
|
||||
useTitle('404');
|
||||
|
||||
return (
|
||||
<Center h='100vh'>
|
||||
<Stack>
|
||||
|
||||
+123
-277
@@ -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 { authenticateWeb } from '@/lib/passkey';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { useTitle } from '@/lib/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 { 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';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
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,201 +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'),
|
||||
},
|
||||
});
|
||||
|
||||
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 res = await authenticateWeb();
|
||||
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
|
||||
auth: res.toJSON(),
|
||||
});
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -272,96 +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...'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
},
|
||||
}}
|
||||
{...form.getInputProps('username', { withError: true })}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your 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 && (
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
leftSection={<IconKey size='1rem' />}
|
||||
color={passkeyErrored ? 'red' : undefined}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
Login with passkey
|
||||
</Button>
|
||||
)}
|
||||
<Divider label='or' />
|
||||
|
||||
{config.features.userRegistration && (
|
||||
<Button
|
||||
component={Link}
|
||||
to='/auth/register'
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
leftSection={<IconUserPlus size='1rem' />}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
|
||||
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function Logout() {
|
||||
useTitle('Log out');
|
||||
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
|
||||
if (res.ok) {
|
||||
setUser(null);
|
||||
mutate('/api/user', null);
|
||||
navigate('/auth/login');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <LoadingOverlay visible />;
|
||||
}
|
||||
@@ -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,9 +66,12 @@ 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,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,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({
|
||||
@@ -214,6 +226,7 @@ export function Component() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -225,6 +238,7 @@ export function Component() {
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='new-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
|
||||
@@ -62,9 +62,12 @@ 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,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -180,12 +183,14 @@ export function Component() {
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Enter a password...'
|
||||
autoComplete='new-password'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
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 {
|
||||
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'));
|
||||
|
||||
@@ -16,17 +30,77 @@ 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();
|
||||
|
||||
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>
|
||||
@@ -34,21 +108,54 @@ export function Component() {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 3,
|
||||
md: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{children.length > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Subfolders
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 4,
|
||||
md: 3,
|
||||
sm: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{children.map((child) => (
|
||||
<PublicFolderCard key={child.id} folder={child} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(folder.files?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 3,
|
||||
md: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
|
||||
<Text c='dimmed' mt='md'>
|
||||
This folder is empty.
|
||||
</Text>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,15 +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 { 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]'],
|
||||
@@ -27,6 +26,8 @@ export function Component() {
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
useTitle(`Upload to ${folder.name ?? 'folder'}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
@@ -37,7 +38,7 @@ export function Component() {
|
||||
{folder.public ? (
|
||||
<>
|
||||
This folder is{' '}
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`} reloadDocument>
|
||||
public
|
||||
</Anchor>
|
||||
. Anyone with the link can view its contents and upload files.
|
||||
|
||||
@@ -26,6 +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';
|
||||
|
||||
type SsrData = {
|
||||
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
|
||||
@@ -55,6 +56,8 @@ export default function ViewFileId() {
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
|
||||
useTitle(file.originalName ?? file.name ?? 'View File');
|
||||
|
||||
return password && !pw ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
<form
|
||||
@@ -98,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)}>
|
||||
@@ -164,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}>
|
||||
@@ -177,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>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
|
||||
export async function dashboardLoader() {
|
||||
@@ -38,7 +37,6 @@ export const router = createBrowserRouter([
|
||||
path: '/auth',
|
||||
children: [
|
||||
{ path: 'login', Component: Login },
|
||||
{ path: 'logout', Component: Logout },
|
||||
{ path: 'register', lazy: () => import('./pages/auth/register') },
|
||||
{
|
||||
path: 'setup',
|
||||
@@ -59,7 +57,7 @@ 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',
|
||||
|
||||
@@ -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,16 +265,16 @@ 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 {
|
||||
html,
|
||||
meta: `${meta}\n${createZiplineSsr(data)}`,
|
||||
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
|
||||
};
|
||||
}
|
||||
|
||||
Executable → Regular
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useConfig } from './ConfigProvider';
|
||||
import { Select, TextInput } from '@mantine/core';
|
||||
import { IconGlobe } from '@tabler/icons-react';
|
||||
|
||||
export default function DomainSelect({
|
||||
onChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Select> & { onChange?: (value: string) => void }) {
|
||||
const config = useConfig();
|
||||
|
||||
const domains = useMemo(() => {
|
||||
const settingsDomains = config.domains;
|
||||
if (!settingsDomains) return [];
|
||||
if (!Array.isArray(settingsDomains)) return [];
|
||||
|
||||
return settingsDomains;
|
||||
}, [config]);
|
||||
|
||||
const selectData = [
|
||||
{ value: '', label: 'Default domain' },
|
||||
...domains.map((domain) => ({
|
||||
value: domain,
|
||||
label: domain,
|
||||
})),
|
||||
];
|
||||
|
||||
if (domains.length === 0)
|
||||
return (
|
||||
<TextInput
|
||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
placeholder='example.com'
|
||||
{...(onChange
|
||||
? {
|
||||
onChange: (e) => onChange(e.currentTarget.value),
|
||||
}
|
||||
: {})}
|
||||
{...(props as React.ComponentProps<typeof TextInput>)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={selectData}
|
||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
{...(onChange
|
||||
? {
|
||||
onChange,
|
||||
}
|
||||
: {})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
+13
-10
@@ -3,7 +3,7 @@ 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 { useLogout } from '@/lib/hooks/useLogout';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
@@ -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>
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+61
-33
@@ -1,9 +1,10 @@
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutateFiles } from '../actions';
|
||||
|
||||
export default function EditFileDetailsModal({
|
||||
@@ -15,13 +16,41 @@ export default function EditFileDetailsModal({
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!file) return null;
|
||||
const [formData, setFormData] = useObjectState<{
|
||||
name: string;
|
||||
maxViews: number | null;
|
||||
password: string | null;
|
||||
originalName: string | null;
|
||||
type: string | null;
|
||||
}>({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
|
||||
const [name, setName] = useState<string>(file.name ?? '');
|
||||
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
||||
const [password, setPassword] = useState<string | null>('');
|
||||
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
||||
const [type, setType] = useState<string | null>(file?.type ?? null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
maxViews: null,
|
||||
password: null,
|
||||
originalName: null,
|
||||
type: null,
|
||||
});
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleRemovePassword = async () => {
|
||||
if (!file.password) return;
|
||||
@@ -58,12 +87,12 @@ export default function EditFileDetailsModal({
|
||||
name?: string;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (originalName !== null) data['originalName'] = originalName?.trim();
|
||||
if (type !== null) data['type'] = type?.trim();
|
||||
if (name !== file.name) data['name'] = name.trim();
|
||||
if (formData.maxViews !== null) data['maxViews'] = formData.maxViews;
|
||||
if (formData.originalName !== null) data['originalName'] = formData.originalName?.trim();
|
||||
if (formData.type !== null) data['type'] = formData.type?.trim();
|
||||
if (formData.name !== file.name) data['name'] = formData.name.trim();
|
||||
|
||||
const passwordTrimmed = password?.trim();
|
||||
const passwordTrimmed = formData.password?.trim();
|
||||
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
|
||||
|
||||
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
||||
@@ -85,29 +114,19 @@ export default function EditFileDetailsModal({
|
||||
|
||||
onClose();
|
||||
|
||||
setPassword(null);
|
||||
setFormData('password', null);
|
||||
mutateFiles();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(file.name ?? '');
|
||||
setMaxViews(file.maxViews ?? null);
|
||||
setPassword(file.password ? '' : null);
|
||||
setOriginalName(file.originalName ?? null);
|
||||
setType(file.type ?? null);
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
return (
|
||||
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
||||
<Stack gap='xs' my='sm'>
|
||||
<TextInput
|
||||
label='Name'
|
||||
description='Rename the file.'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value.trim())}
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData('name', event.currentTarget.value.trim())}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
@@ -115,17 +134,20 @@ export default function EditFileDetailsModal({
|
||||
placeholder='Unlimited'
|
||||
description='The maximum number of views this file can have before it is deleted. Leave blank to allow as many views as you want.'
|
||||
min={0}
|
||||
value={maxViews || ''}
|
||||
onChange={(value) => setMaxViews(value === '' ? null : Number(value))}
|
||||
value={formData.maxViews || ''}
|
||||
onChange={(value) => setFormData('maxViews', value === '' ? null : Number(value))}
|
||||
leftSection={<IconEye size='1rem' />}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Original Name'
|
||||
description='Add an original name. When downloading this file, instead of using the generated file name (if chosen), it will download with this "original name" instead.'
|
||||
value={originalName ?? ''}
|
||||
value={formData.originalName ?? ''}
|
||||
onChange={(event) =>
|
||||
setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'originalName',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -137,9 +159,12 @@ export default function EditFileDetailsModal({
|
||||
doing, this can mess with how Zipline renders specific file types.
|
||||
</>
|
||||
}
|
||||
value={type ?? ''}
|
||||
value={formData.type ?? ''}
|
||||
onChange={(event) =>
|
||||
setType(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'type',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
c='red'
|
||||
/>
|
||||
@@ -159,10 +184,13 @@ export default function EditFileDetailsModal({
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Set a password for this file. Leave blank to disable password protection.'
|
||||
value={password ?? ''}
|
||||
value={formData.password ?? ''}
|
||||
autoComplete='off'
|
||||
onChange={(event) =>
|
||||
setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
|
||||
setFormData(
|
||||
'password',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
leftSection={<IconKey size='1rem' />}
|
||||
/>
|
||||
|
||||
Executable → Regular
+47
-42
@@ -1,10 +1,12 @@
|
||||
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 { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -46,9 +48,11 @@ import {
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
addToFolder,
|
||||
@@ -102,25 +106,32 @@ export default function FileModal({
|
||||
|
||||
const [editFileOpen, setEditFileOpen] = useState(false);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
|
||||
user ? `/api/users/${user}/tags` : '/api/user/tags',
|
||||
);
|
||||
|
||||
const tagsCombobox = useCombobox();
|
||||
const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []);
|
||||
|
||||
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
|
||||
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
@@ -170,14 +181,6 @@ export default function FileModal({
|
||||
|
||||
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
setValue(file.tags?.map((x) => x.id) ?? []);
|
||||
} else {
|
||||
setValue([]);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
|
||||
@@ -227,20 +230,16 @@ 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 && !user && (
|
||||
{!reduce && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'>
|
||||
<Box>
|
||||
<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()}
|
||||
@@ -316,7 +315,7 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
withinPortal={false}
|
||||
zIndex={90000}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
@@ -329,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'
|
||||
@@ -341,24 +346,24 @@ export default function FileModal({
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f: { name: string }) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase().trim()),
|
||||
)
|
||||
.map((f: { name: string; id: string }) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
{folders?.length === 0 && (
|
||||
<Combobox.Empty>
|
||||
You have no folders. Start typing to create a new folder for this file.
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
|
||||
{!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 && (
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
additionalOptions={
|
||||
!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 ? (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create folder "{search}"
|
||||
</Combobox.Option>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+2
-2
@@ -6,12 +6,12 @@ import FileModal from './FileModal';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
|
||||
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
|
||||
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
|
||||
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
|
||||
Executable → Regular
Executable → Regular
+34
-41
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -110,43 +111,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 +156,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 +193,7 @@ export async function addToFolder(file: File, folderId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -227,7 +225,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -235,8 +233,3 @@ export function mutateFiles() {
|
||||
mutate('/api/user/recent');
|
||||
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
|
||||
}
|
||||
|
||||
export function mutateFolders() {
|
||||
mutate('/api/user/folders');
|
||||
mutate('/api/user/folders?noincl=true');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FolderHierarchyItem } from '@/lib/folderHierarchy';
|
||||
import { Combobox, Text } from '@mantine/core';
|
||||
|
||||
export default function FolderComboboxOptions({
|
||||
folderOptions,
|
||||
searchValue,
|
||||
additionalOptions,
|
||||
}: {
|
||||
folderOptions: FolderHierarchyItem[];
|
||||
searchValue: string;
|
||||
additionalOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Combobox.Options>
|
||||
{additionalOptions}
|
||||
{folderOptions
|
||||
.filter((f) => f.path.toLowerCase().includes(searchValue.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
<Text size='sm' style={{ paddingLeft: f.depth * 12 }}>
|
||||
{f.depth > 0 ? '└ ' : ''}
|
||||
{f.name}
|
||||
</Text>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+42
-8
@@ -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 { 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/hooks/useObjectState';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '.';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
<Badge variant='light' color='gray'>
|
||||
Pending
|
||||
</Badge>
|
||||
),
|
||||
PROCESSING: (
|
||||
<Badge variant='light' color='yellow'>
|
||||
Processing
|
||||
</Badge>
|
||||
),
|
||||
COMPLETE: (
|
||||
<Badge variant='light' color='green'>
|
||||
Complete
|
||||
</Badge>
|
||||
),
|
||||
FAILED: (
|
||||
<Badge variant='light' color='red'>
|
||||
Failed
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
|
||||
export default function PendingFilesModal({
|
||||
modals,
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
}) {
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
>('/api/user/files/incomplete');
|
||||
|
||||
const handleDelete = async (incompleteFile: IncompleteFile) => {
|
||||
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
|
||||
'/api/user/files/incomplete',
|
||||
'DELETE',
|
||||
{
|
||||
id: [incompleteFile.id],
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: `Failed to delete pending file: ${error.error}`,
|
||||
color: 'red',
|
||||
icon: <IconFileDots size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Cleared Pending File!',
|
||||
color: 'green',
|
||||
icon: <IconTrashFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
<Group justify='space-between'>
|
||||
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
|
||||
{badgeMap[incompleteFile.status]}
|
||||
</Group>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Text size='xs' c='dimmed' fw='bold'>
|
||||
{incompleteFile.metadata.file.type}
|
||||
</Text>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.id}
|
||||
</Text>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Button
|
||||
fullWidth
|
||||
size='compact-sm'
|
||||
mt='xs'
|
||||
color='red'
|
||||
variant='light'
|
||||
onClick={() => handleDelete(incompleteFile)}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{incompleteFiles?.length === 0 && (
|
||||
<Paper withBorder px='sm' py='xs'>
|
||||
No pending files
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -14,17 +14,6 @@ import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
@@ -53,7 +42,7 @@ function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
|
||||
export default function TableEditModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
@@ -73,7 +62,7 @@ export default function TableEditModal({ opened, onCLose }: { opened: boolean; o
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
|
||||
<Modal opened={opened} onClose={onClose} title='Table Options' centered>
|
||||
<Text mb='md' size='sm' c='dimmed'>
|
||||
Select and drag fields below to make them appear/disappear/reorder in the file table view.
|
||||
</Text>
|
||||
|
||||
Executable → Regular
Executable → Regular
+67
-41
@@ -1,23 +1,44 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
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 { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
IconFileDots,
|
||||
IconFileUpload,
|
||||
IconGridPatternFilled,
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from './tags/TagsModal';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
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 [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const [modals, setModals] = useObjectState<DashboardFilesModals>({
|
||||
table: false,
|
||||
idSearch: false,
|
||||
tags: false,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagsModal modals={modals} setModals={setModals} />
|
||||
<PendingFilesModal modals={modals} setModals={setModals} />
|
||||
|
||||
<Group>
|
||||
<Title>Files</Title>
|
||||
|
||||
@@ -29,29 +50,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 +98,7 @@ export default function DashboardFiles() {
|
||||
<Files />
|
||||
</>
|
||||
) : (
|
||||
<FileTable
|
||||
idSearch={{
|
||||
open: idSearchOpen,
|
||||
setOpen: setIdSearchOpen,
|
||||
}}
|
||||
tableEdit={{
|
||||
open: tableEditOpen,
|
||||
setOpen: setTableEditOpen,
|
||||
}}
|
||||
/>
|
||||
<FileTable modals={modals} setModals={setModals} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+12
-11
@@ -2,17 +2,24 @@ import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { UpdateFn } from '@/lib/hooks/useObjectState';
|
||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
import EditTagModal from './EditTagModal';
|
||||
import TagPill from './TagPill';
|
||||
|
||||
export default function TagsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
export default function TagsModals({
|
||||
modals,
|
||||
setModals,
|
||||
}: {
|
||||
modals: DashboardFilesModals;
|
||||
setModals: UpdateFn<DashboardFilesModals>;
|
||||
}) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
@@ -47,8 +54,8 @@ export default function TagsButton() {
|
||||
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
opened={modals.tags}
|
||||
onClose={() => setModals('tags', false)}
|
||||
title={
|
||||
<Group>
|
||||
<Title>Tags</Title>
|
||||
@@ -94,12 +101,6 @@ export default function TagsButton() {
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='View tags'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconTags size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+2
@@ -19,6 +19,7 @@ type ApiPaginationOptions = {
|
||||
| 'favorite';
|
||||
order?: 'asc' | 'desc';
|
||||
id?: string;
|
||||
folderId?: string;
|
||||
search?: {
|
||||
field?: string;
|
||||
query: string;
|
||||
@@ -45,6 +46,7 @@ const fetcher = async (
|
||||
if (options.search.field) searchParams.append('searchField', options.search.field);
|
||||
searchParams.append('searchQuery', options.search.query);
|
||||
}
|
||||
if (options.folderId) searchParams.append('folder', options.folderId);
|
||||
|
||||
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
+8
-13
@@ -1,3 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -11,36 +12,30 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
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);
|
||||
const [cachedPages, setCachedPages] = useState(1);
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
perpage,
|
||||
id,
|
||||
folderId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.pages) {
|
||||
setCachedPages(data.pages);
|
||||
}
|
||||
}, [data?.pages]);
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, data?.total ?? 0);
|
||||
const totalRecords = data?.total ?? 0;
|
||||
const cachedPages = data?.pages ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,7 +54,7 @@ export default function Files({ id }: { id?: string }) {
|
||||
) : (data?.page?.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
<DashboardFile file={file} id={id} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
src/components/pages/files/views/FileTable.tsx → src/components/pages/files/views/FilesTableView.tsx
Executable → Regular
+85
-92
@@ -1,12 +1,14 @@
|
||||
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 { type File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -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/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);
|
||||
@@ -212,35 +216,23 @@ export default function FileTable({
|
||||
| 'favorite'
|
||||
>('createdAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.query,
|
||||
};
|
||||
},
|
||||
(
|
||||
_state: { name: string; originalName: string; type: string; tags: string; id: string },
|
||||
action: { field: keyof ReducerQuery['state']; query: string },
|
||||
) => ({
|
||||
name: action.field === 'name' ? action.query : '',
|
||||
originalName: action.field === 'originalName' ? action.query : '',
|
||||
type: action.field === 'type' ? action.query : '',
|
||||
tags: action.field === 'tags' ? action.query : '',
|
||||
id: action.field === 'id' ? action.query : '',
|
||||
}),
|
||||
{ name: '', originalName: '', type: '', tags: '', id: '' },
|
||||
);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (idSearch.open) return;
|
||||
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: '',
|
||||
});
|
||||
}, [idSearch.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
const combobox = useCombobox();
|
||||
@@ -265,6 +257,7 @@ export default function FileTable({
|
||||
sort,
|
||||
order,
|
||||
id,
|
||||
folderId,
|
||||
...(searchQuery[searchField].trim() !== '' && {
|
||||
search: {
|
||||
field: searchField,
|
||||
@@ -273,6 +266,11 @@ export default function FileTable({
|
||||
}),
|
||||
});
|
||||
|
||||
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
|
||||
const selectedFile = selectedFileId
|
||||
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
|
||||
: null;
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
accessor: 'name',
|
||||
@@ -344,6 +342,7 @@ export default function FileTable({
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
title: 'Favorite?',
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
@@ -356,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);
|
||||
@@ -367,29 +372,14 @@ export default function FileTable({
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) {
|
||||
if (field !== searchField) {
|
||||
setSearchQuery({
|
||||
field,
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [searchField]);
|
||||
|
||||
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
@@ -401,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}>
|
||||
@@ -456,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'
|
||||
@@ -468,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>
|
||||
)}
|
||||
@@ -496,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,
|
||||
{
|
||||
@@ -594,7 +587,7 @@ export default function FileTable({
|
||||
setSort(data.columnAccessor as any);
|
||||
setOrder(data.direction);
|
||||
}}
|
||||
onCellClick={({ record }) => setSelectedFile(record)}
|
||||
onCellClick={({ record }) => setSelectedFile(record.id)}
|
||||
selectedRecords={selectedFiles}
|
||||
onSelectedRecordsChange={setSelectedFiles}
|
||||
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
|
||||
Executable → Regular
Executable → Regular
+76
-26
@@ -5,7 +5,10 @@ import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconFiles,
|
||||
IconFileZip,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderSymlink,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPencil,
|
||||
@@ -14,73 +17,115 @@ import {
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import ViewFilesModal from './ViewFilesModal';
|
||||
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from './actions';
|
||||
import EditFolderNameModal from './EditFolderNameModal';
|
||||
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from './actions';
|
||||
import DeleteFolderModal from './modals/DeleteFolderModal';
|
||||
import EditFolderNameModal from './modals/EditFolderNameModal';
|
||||
import MoveFolderModal from './modals/MoveFolderModal';
|
||||
import ViewFilesModal from './modals/ViewFilesModal';
|
||||
import { withoutPropagation } from './views/FolderTableView';
|
||||
|
||||
export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
export default function FolderCard({
|
||||
folder,
|
||||
onNavigate,
|
||||
}: {
|
||||
folder: Folder;
|
||||
onNavigate?: (folderId: string | null) => void;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const childrenCount = folder._count?.children ?? 0;
|
||||
const filesCount = folder._count?.files ?? folder.files?.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewFilesModal opened={viewOpen} onClose={() => setViewOpen(false)} folder={folder} />
|
||||
<EditFolderNameModal folder={folder} opened={editOpen} onClose={() => setEditOpen(false)} />
|
||||
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
|
||||
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
|
||||
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
|
||||
<Group justify='space-between'>
|
||||
<Text fw={400}>
|
||||
{folder.public ? (
|
||||
<Anchor href={`/folder/${folder.id}`} target='_blank'>
|
||||
{folder.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
folder.name
|
||||
)}
|
||||
</Text>
|
||||
<Group gap='xs'>
|
||||
<IconFolder size='1rem' />
|
||||
<Text fw={400}>
|
||||
{folder.public ? (
|
||||
<Anchor href={`/folder/${folder.id}`} target='_blank' onClick={(e) => e.stopPropagation()}>
|
||||
{folder.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
folder.name
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Menu withinPortal position='bottom-end' shadow='sm'>
|
||||
<Group gap={2}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant='transparent'>
|
||||
<ActionIcon variant='transparent' onClick={(e) => e.stopPropagation()}>
|
||||
<IconDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
</Group>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconFiles size='1rem' />} onClick={() => setViewOpen(true)}>
|
||||
View Files
|
||||
{onNavigate && (
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderOpen size='1rem' />}
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
Open Folder
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderSymlink size='1rem' />}
|
||||
onClick={withoutPropagation(() => setMoveOpen(true))}
|
||||
>
|
||||
Move Folder
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileZip size='1rem' />}
|
||||
component='a'
|
||||
href={`/api/user/folders/${folder.id}/export`}
|
||||
target='_blank'
|
||||
onClick={withoutPropagation(() => {})}
|
||||
>
|
||||
Export as ZIP
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
|
||||
onClick={() => editFolderVisibility(folder, !folder.public)}
|
||||
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
|
||||
>
|
||||
{folder.public ? 'Make Private' : 'Make Public'}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
|
||||
onClick={() => editFolderUploads(folder, !folder.allowUploads)}
|
||||
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
|
||||
>
|
||||
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPencil size='1rem' />} onClick={() => setEditOpen(true)}>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size='1rem' />}
|
||||
onClick={withoutPropagation(() => setEditOpen(true))}
|
||||
>
|
||||
Edit Name
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
disabled={!folder.public}
|
||||
onClick={() => copyFolderUrl(folder, clipboard)}
|
||||
onClick={withoutPropagation(() => copyFolderUrl(folder, clipboard))}
|
||||
>
|
||||
Copy URL
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
onClick={() => deleteFolder(folder)}
|
||||
onClick={withoutPropagation(() => setDeleteOpen(true))}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
@@ -89,7 +134,7 @@ export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section inheritPadding py='xs'>
|
||||
<Card.Section inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
|
||||
<Stack gap={1}>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Created:</b> <RelativeDate date={folder.createdAt} />
|
||||
@@ -101,8 +146,13 @@ export default function FolderCard({ folder }: { folder: Folder }) {
|
||||
<b>Public:</b> {folder.public ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Files:</b> {folder.files!.length}
|
||||
<b>Files:</b> {filesCount}
|
||||
</Text>
|
||||
{childrenCount > 0 && (
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Subfolders:</b> {childrenCount}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
Executable → Regular
+5
-43
@@ -3,27 +3,11 @@ import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
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}`);
|
||||
|
||||
@@ -64,7 +48,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder(folder.id);
|
||||
}
|
||||
|
||||
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
@@ -92,33 +76,11 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder(folder.id);
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(folder: Folder) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'folder',
|
||||
},
|
||||
);
|
||||
export async function mutateFolder(folderId?: string) {
|
||||
if (folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to delete folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder deleted',
|
||||
message: `${data?.name} has been deleted`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/user/folders');
|
||||
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
|
||||
}
|
||||
|
||||
Executable → Regular
+178
-12
@@ -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 { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { IconFolderPlus, IconHome, IconPlus, IconShare } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import FilesGridView from '../files/views/FilesGridView';
|
||||
import FilesTableView from '../files/views/FilesTableView';
|
||||
import { mutateFolder } from './actions';
|
||||
import FolderGridView from './views/FolderGridView';
|
||||
import FolderTableView from './views/FolderTableView';
|
||||
|
||||
export default function DashboardFolders() {
|
||||
const view = useViewStore((state) => state.folders);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(true);
|
||||
|
||||
const folderPath = useMemo(() => {
|
||||
const pathname = location.pathname.replace('/dashboard/folders', '');
|
||||
if (!pathname || pathname === '/') return [];
|
||||
return pathname.split('/').filter(Boolean);
|
||||
}, [location.pathname]);
|
||||
|
||||
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
|
||||
|
||||
const {
|
||||
data: currentFolder,
|
||||
error: currentFolderError,
|
||||
isLoading,
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -34,6 +74,7 @@ export default function DashboardFolders() {
|
||||
{
|
||||
name: values.name,
|
||||
isPublic: values.isPublic,
|
||||
parentId: currentFolderId ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,15 +84,71 @@ export default function DashboardFolders() {
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
mutate('/api/user/folders');
|
||||
mutateFolder();
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
if (folderId === null) {
|
||||
navigate('/dashboard/folders');
|
||||
} else {
|
||||
const newPath = [...folderPath, folderId];
|
||||
navigate(`/dashboard/folders/${newPath.join('/')}`);
|
||||
}
|
||||
},
|
||||
[navigate, folderPath],
|
||||
);
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const items: FolderBreadcrumb[] = [{ id: null, name: 'Root', path: '/dashboard/folders' }];
|
||||
|
||||
if (currentFolder) {
|
||||
const path: Partial<Folder>[] = [];
|
||||
let folder: Partial<Folder> | undefined | null = currentFolder;
|
||||
|
||||
while (folder) {
|
||||
path.unshift(folder);
|
||||
folder = folder.parent;
|
||||
}
|
||||
|
||||
const folderIds: string[] = [];
|
||||
for (const f of path) {
|
||||
folderIds.push(f.id!);
|
||||
items.push({
|
||||
id: f.id!,
|
||||
name: f.name!,
|
||||
path: `/dashboard/folders/${folderIds.join('/')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
|
||||
useTitle(currentFolder ? `Folders ${SEPARATOR} ${currentFolder.name}` : 'Folders');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFolderId) return;
|
||||
if (isLoading) return;
|
||||
|
||||
if (currentFolderError || !currentFolder) {
|
||||
navigate('/dashboard/folders', { replace: true });
|
||||
}
|
||||
}, [currentFolderId, currentFolder, currentFolderError, isLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal centered opened={open} onClose={() => setOpen(false)} title='Create a folder'>
|
||||
<Modal
|
||||
centered
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={currentFolderId ? 'Create a subfolder' : 'Create a folder'}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap='sm'>
|
||||
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
|
||||
@@ -71,16 +168,85 @@ export default function DashboardFolders() {
|
||||
<Group>
|
||||
<Title>Folders</Title>
|
||||
|
||||
<Tooltip label='Create a new folder'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-sm'
|
||||
leftSection={<IconPlus size='1rem' />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Create{currentFolderId ? ' Subfolder' : ' Folder'}
|
||||
</Button>
|
||||
|
||||
<GridTableSwitcher type='folders' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <FolderGridView /> : <FolderTableView />}
|
||||
{breadcrumbs.length > 1 && (
|
||||
<Breadcrumbs my='sm'>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Anchor
|
||||
key={item.id ?? 'root'}
|
||||
onClick={() => navigate(item.path!)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
fw={index === breadcrumbs.length - 1 ? 600 : 400}
|
||||
>
|
||||
{index === 0 ? <IconHome size='1rem' /> : item.name}
|
||||
</Anchor>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
|
||||
{view === 'grid' ? (
|
||||
<FolderGridView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
|
||||
) : (
|
||||
<FolderTableView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
|
||||
)}
|
||||
|
||||
{currentFolderId && currentFolder && (
|
||||
<Box>
|
||||
<Divider mx='-xs' my='xs' />
|
||||
{currentFolder?.allowUploads && (
|
||||
<Alert
|
||||
icon={<IconShare size='1rem' />}
|
||||
variant='outline'
|
||||
mb='sm'
|
||||
styles={{ message: { marginTop: 0 } }}
|
||||
>
|
||||
This folder allows anonymous uploads. Share the link below to allow others to let others upload
|
||||
files to this folder.
|
||||
<br />
|
||||
<Anchor href={`/folder/${currentFolder.id}/upload`} target='_blank'>
|
||||
{`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}
|
||||
</Anchor>
|
||||
<CopyButton value={`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}>
|
||||
{({ copied, copy }) => (
|
||||
<Button mx='sm' size='compact-xs' color={copied ? 'teal' : 'blue'} onClick={copy}>
|
||||
{copied ? 'Copied url' : 'Copy url'}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
)}
|
||||
<Text
|
||||
mt='sm'
|
||||
c='dimmed'
|
||||
size='sm'
|
||||
onClick={() => setFilesOpen((o) => !o)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{filesOpen ? '▼' : '▶'} {currentFolder.name}'s files{' '}
|
||||
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
|
||||
</Text>
|
||||
<Collapse in={filesOpen}>
|
||||
{view === 'grid' ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<FilesGridView folderId={currentFolderId} />
|
||||
</Paper>
|
||||
) : (
|
||||
<FilesTableView folderId={currentFolderId} />
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade';
|
||||
|
||||
export default function DeleteFolderModal({
|
||||
folder,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('root');
|
||||
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const combobox = useCombobox();
|
||||
|
||||
const { data: allFolders } = useFolders(undefined, opened);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!allFolders || !folder) return [];
|
||||
// Exclude the folder being deleted
|
||||
const excludeIds = new Set([folder.id]);
|
||||
return buildFolderHierarchy(allFolders, excludeIds);
|
||||
}, [allFolders, folder]);
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
const hasChildren = (folder._count?.children ?? 0) > 0;
|
||||
const hasFiles = (folder._count?.files ?? 0) > 0;
|
||||
const hasContent = hasChildren || hasFiles;
|
||||
|
||||
const getDisplayValue = () => {
|
||||
const selected = folderOptions.find((f) => f.id === targetFolderId);
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
body,
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to delete folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder deleted',
|
||||
message: `${folder.name} has been deleted`,
|
||||
color: 'green',
|
||||
});
|
||||
mutateFolder();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
|
||||
{hasContent && (
|
||||
<>
|
||||
<Text size='sm'>
|
||||
This folder contains {hasFiles && `${folder._count?.files} file(s)`}
|
||||
{hasChildren && hasFiles && ' and '}
|
||||
{hasChildren && `${folder._count?.children} subfolder(s)`}. What would you like to do with them?
|
||||
</Text>
|
||||
|
||||
<Radio.Group value={childrenAction} onChange={(v) => setChildrenAction(v as ChildrenAction)}>
|
||||
<Stack gap='xs'>
|
||||
<Radio value='root' label='Move contents to root folder' />
|
||||
<Radio value='folder' label='Move contents to another folder' />
|
||||
<Radio
|
||||
value='cascade'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete everything (cascade delete)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{childrenAction === 'folder' && (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={true}
|
||||
onOptionSubmit={(value) => {
|
||||
setTargetFolderId(value);
|
||||
setSearch(folderOptions.find((f) => f.id === value)?.path || '');
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label='Target Folder'
|
||||
placeholder='Select a folder'
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search || getDisplayValue()}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
rightSectionPointerEvents='none'
|
||||
required
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<FolderComboboxOptions folderOptions={folderOptions} searchValue={search} />
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
|
||||
{childrenAction === 'cascade' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all contents within this folder (subfolders will be
|
||||
deleted, and files will be unlinked from their folders).
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
loading={loading}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
>
|
||||
Delete Folder
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -1,4 +1,3 @@
|
||||
import { mutateFolders } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import type { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
@@ -7,6 +6,8 @@ import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconPencil } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
export default function EditFolderNameModal({
|
||||
folder,
|
||||
onClose,
|
||||
@@ -28,7 +29,7 @@ export default function EditFolderNameModal({
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
if (!folder) return;
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder?.id}`,
|
||||
'PATCH',
|
||||
{
|
||||
@@ -42,10 +43,10 @@ export default function EditFolderNameModal({
|
||||
message: error.error,
|
||||
});
|
||||
} else {
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
showNotification({
|
||||
title: 'Folder name updated',
|
||||
message: 'Folder name has been updated successfully to ' + name,
|
||||
message: 'Folder name has been updated successfully to ' + data?.name,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderSymlink } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
export default function MoveFolderModal({
|
||||
folder,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [selectedParentId, setSelectedParentId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const combobox = useCombobox();
|
||||
|
||||
const { data: allFolders } = useFolders(undefined, opened);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!allFolders || !folder) return [];
|
||||
|
||||
const descendantIds = getDescendantIds(folder.id, allFolders);
|
||||
// Exclude the folder being moved and its descendants
|
||||
const excludeIds = new Set([folder.id, ...descendantIds]);
|
||||
|
||||
return buildFolderHierarchy(allFolders, excludeIds);
|
||||
}, [allFolders, folder]);
|
||||
|
||||
const getDisplayValue = () => {
|
||||
if (selectedParentId === '__root__' || selectedParentId === null) {
|
||||
return '/ (Root)';
|
||||
}
|
||||
const selected = folderOptions.find((f) => f.id === selectedParentId);
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleMove = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const newParentId = selectedParentId === '__root__' ? null : selectedParentId;
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'PATCH',
|
||||
{ parentId: newParentId },
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to move folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder moved',
|
||||
message: `${folder.name} has been moved`,
|
||||
color: 'green',
|
||||
});
|
||||
mutateFolder();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal key={folder.id} centered opened={opened} onClose={onClose} title={`Move "${folder.name}"`}>
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Select a destination folder for this folder.
|
||||
</Text>
|
||||
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={true}
|
||||
onOptionSubmit={(value) => {
|
||||
setSelectedParentId(value);
|
||||
setSearch(
|
||||
value === '__root__' ? '/ (Root)' : folderOptions.find((f) => f.id === value)?.path || '',
|
||||
);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label='Destination'
|
||||
placeholder='Select a folder'
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search || getDisplayValue()}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
combobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
additionalOptions={<Combobox.Option value='__root__'>/ (Root)</Combobox.Option>}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
loading={loading}
|
||||
leftSection={<IconFolderSymlink size='1rem' />}
|
||||
variant='outline'
|
||||
>
|
||||
Move Folder
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
+16
-8
@@ -1,13 +1,21 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconLink } from '@tabler/icons-react';
|
||||
import { IconFolder } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
import FolderCard from '../FolderCard';
|
||||
|
||||
export default function FolderGridView() {
|
||||
const { data: folders, isLoading } =
|
||||
useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
|
||||
export default function FolderGridView({
|
||||
currentFolderId,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}) {
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,7 +34,7 @@ export default function FolderGridView() {
|
||||
<Skeleton key={i} height={120} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (folders?.length ?? 0 !== 0) ? (
|
||||
) : (folders?.length ?? 0) !== 0 ? (
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
spacing='md'
|
||||
@@ -38,7 +46,7 @@ export default function FolderGridView() {
|
||||
pos='relative'
|
||||
>
|
||||
{folders?.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} />
|
||||
<FolderCard key={folder.id} folder={folder} onNavigate={onNavigate} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
@@ -46,11 +54,11 @@ export default function FolderGridView() {
|
||||
<Center>
|
||||
<Stack>
|
||||
<Group>
|
||||
<IconLink size='2rem' />
|
||||
<IconFolder size='2rem' />
|
||||
<Title order={2}>No Folders found</Title>
|
||||
</Group>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Create a folder to see it here
|
||||
{currentFolderId ? 'This folder is empty' : 'Create a folder to see it here'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
Executable → Regular
+151
-91
@@ -1,57 +1,152 @@
|
||||
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 { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions';
|
||||
import {
|
||||
IconCopy,
|
||||
IconFiles,
|
||||
IconDots,
|
||||
IconFileZip,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderSymlink,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPencil,
|
||||
IconShare,
|
||||
IconShareOff,
|
||||
IconTrashFilled,
|
||||
IconZip,
|
||||
} from '@tabler/icons-react';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
import EditFolderNameModal from '../EditFolderNameModal';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
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',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Folder;
|
||||
const sorted = useMemo<Folder[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Folder;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,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',
|
||||
@@ -114,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) => {
|
||||
@@ -135,55 +238,12 @@ export default function FolderTableView() {
|
||||
<IconCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
|
||||
<ActionIcon
|
||||
color={folder.public ? 'blue' : 'gray'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editFolderVisibility(folder, !folder.public);
|
||||
}}
|
||||
>
|
||||
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={folder.allowUploads ? 'Disable anonymous uploads' : 'Allow anonymous uploads'}
|
||||
>
|
||||
<ActionIcon
|
||||
color={folder.allowUploads ? 'blue' : 'gray'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editFolderUploads(folder, !folder.allowUploads);
|
||||
}}
|
||||
>
|
||||
{folder.allowUploads ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Edit Folder Name'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditNameOpen(folder);
|
||||
}}
|
||||
>
|
||||
<IconPencil size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Export folder as ZIP'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
|
||||
>
|
||||
<IconZip size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete Folder'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteFolder(folder);
|
||||
setDeleteOpen(folder);
|
||||
}}
|
||||
>
|
||||
<IconTrashFilled size='1rem' />
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+9
-6
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconTagOff } from '@tabler/icons-react';
|
||||
@@ -112,11 +112,14 @@ export default function DashboardInvites() {
|
||||
<Group>
|
||||
<Title>Invites</Title>
|
||||
|
||||
<Tooltip label='Create a new invite'>
|
||||
<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
|
||||
</Button>
|
||||
|
||||
<GridTableSwitcher type='invites' />
|
||||
</Group>
|
||||
|
||||
Executable → Regular
Executable → Regular
+13
-17
@@ -1,14 +1,14 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyInviteUrl, deleteInvite } from '../actions';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
|
||||
export default function InviteTableView() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -20,25 +20,21 @@ export default function InviteTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Invite[]>(data ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Invite;
|
||||
const sorted = useMemo<Invite[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Invite;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Stack, TextInput, PasswordInput, Button } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
|
||||
export default function LocalLogin({
|
||||
form,
|
||||
onSubmit,
|
||||
loading,
|
||||
hasBackground,
|
||||
}: {
|
||||
form: UseFormReturnType<any>;
|
||||
onSubmit: (values: any) => void;
|
||||
loading: boolean;
|
||||
hasBackground: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<Stack my='sm'>
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
|
||||
}}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='current-password'
|
||||
styles={{
|
||||
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
|
||||
}}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size='md'
|
||||
fullWidth
|
||||
type='submit'
|
||||
loading={loading}
|
||||
variant={hasBackground ? 'outline' : 'filled'}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
|
||||
export default function PasskeyAuthButton({ onAuthSuccess }: { onAuthSuccess: (data: any) => void }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: options } = await fetchApi<any>('/api/auth/webauthn/options', 'GET');
|
||||
const res = await startAuthentication({ optionsJSON: options.options });
|
||||
|
||||
const { data, error } = await fetchApi<any>(
|
||||
'/api/auth/webauthn',
|
||||
'POST',
|
||||
{ response: res },
|
||||
{ 'x-zipline-client': JSON.stringify(getWebClient()) },
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.error);
|
||||
onAuthSuccess(data);
|
||||
} catch (e: any) {
|
||||
setErrored(true);
|
||||
setTimeout(() => setErrored(false), 3000);
|
||||
notifications.show({ title: 'Auth Failed', message: e.message, color: 'red' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size='md'
|
||||
fullWidth
|
||||
variant='outline'
|
||||
leftSection={<IconKey size='1rem' />}
|
||||
color={errored ? 'red' : undefined}
|
||||
loading={loading}
|
||||
>
|
||||
Login with passkey
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Anchor, Code, Modal, Text } from '@mantine/core';
|
||||
|
||||
export default function SecureWarningModal({
|
||||
returnHttps,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
returnHttps: boolean;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title='HTTPS Configuration' size='lg'>
|
||||
<Text>
|
||||
{returnHttps ? (
|
||||
<>
|
||||
It appears that you are accessing this instance through an insecure context (HTTP), but the server
|
||||
is configured to use HTTPS. This can lead to issues when logging in, as secure cookies may not be
|
||||
sent by the browser.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
It appears that you are accessing this instance through a secure context (HTTPS), but the server
|
||||
is not configured to use HTTPS. This can lead issues when logging in.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text mt='md'>
|
||||
{returnHttps ? (
|
||||
<>
|
||||
To resolve this issue, please access this instance through HTTPS. If that is currently not
|
||||
possible, you can temporarily set the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to{' '}
|
||||
<Code>false</Code>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
To resolve this issue, it is recommended to have your server configured to use HTTPS. This can be
|
||||
done by setting the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to <Code>true</Code>{' '}
|
||||
and ensuring that your server has a valid SSL setup through a reverse proxy like Nginx or Caddy.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text mt='md'>
|
||||
After making these changes, restart the server for the changes to take effect. If you continue to
|
||||
experience issues, please consult the{' '}
|
||||
<Anchor
|
||||
underline='always'
|
||||
href='https://zipline.diced.sh/docs/config/settings#more-about-return-https-urls'
|
||||
>
|
||||
documentation
|
||||
</Anchor>{' '}
|
||||
or seek support.
|
||||
</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Modal, Center, PinInput, Text, Group, Button } from '@mantine/core';
|
||||
import { IconX, IconShieldQuestion } from '@tabler/icons-react';
|
||||
|
||||
export default function TotpModal({
|
||||
state,
|
||||
onPinChange,
|
||||
onVerify,
|
||||
onCancel,
|
||||
}: {
|
||||
state: { open: boolean; disabled: boolean; error: string; pin: string };
|
||||
onPinChange: (val: string) => void;
|
||||
onVerify: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
|
||||
<Center>
|
||||
<PinInput
|
||||
length={6}
|
||||
oneTimeCode
|
||||
type='number'
|
||||
onChange={onPinChange}
|
||||
error={!!state.error}
|
||||
disabled={state.disabled}
|
||||
size='xl'
|
||||
autoFocus
|
||||
/>
|
||||
</Center>
|
||||
{state.error && (
|
||||
<Text ta='center' size='sm' c='red' mt='xs'>
|
||||
{state.error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group mt='sm' grow>
|
||||
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+8
-7
@@ -1,12 +1,12 @@
|
||||
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { useApiStats } from './useStats';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import dayjs from 'dayjs';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
@@ -35,9 +35,10 @@ export default function DashboardMetrics() {
|
||||
setDateRange(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (allTime) setDateRange([null, null]);
|
||||
}, [allTime]);
|
||||
const showAllTime = () => {
|
||||
setAllTime(true);
|
||||
setDateRange([null, null]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -118,7 +119,7 @@ export default function DashboardMetrics() {
|
||||
size='compact-sm'
|
||||
variant='outline'
|
||||
leftSection={<IconCalendarTime size='1rem' />}
|
||||
onClick={() => setAllTime(true)}
|
||||
onClick={() => showAllTime()}
|
||||
disabled={allTime}
|
||||
>
|
||||
Show All Time
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user