mirror of
https://github.com/diced/zipline.git
synced 2026-06-25 07:33:52 -07:00
Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb21988a7 | |||
| 72fc8116d4 | |||
| 177febf305 | |||
| a8c65c19b4 | |||
| 3fc3dcd1ed | |||
| 0c52b48c05 | |||
| 5c386a792e | |||
| c4f8aa52a4 | |||
| 5c0097fed5 | |||
| 6ebd8f68f9 | |||
| f6188cf15b | |||
| c9cbc2322f | |||
| ae11a29057 | |||
| 4776d9e85f | |||
| 41b63e6f25 | |||
| 24b332c23e | |||
| 3fd9154e57 | |||
| 3f71769ec6 | |||
| a99b0f4f1d | |||
| 15f5279ddb | |||
| 87a2dfbda6 | |||
| c7d2b3010f | |||
| 5119806147 | |||
| 33104ce1be | |||
| eeb1c51fb2 | |||
| 756dee6bba | |||
| a0907e8791 | |||
| 5a58abeb51 | |||
| 72d8c693c7 | |||
| 7caf314ce1 | |||
| 677927b4a6 | |||
| ac0b718f77 | |||
| db3a1b88ad | |||
| a97cf32682 | |||
| 7e2b4ed1bb | |||
| a7fdf5afed | |||
| db8adcc768 | |||
| 135cf1982a | |||
| 9925300e9d | |||
| 3bf125b4b4 | |||
| dc9abe4383 | |||
| 1ccbc878f8 | |||
| aa43f66570 | |||
| 7e3bba5e55 | |||
| 82e1fe4824 | |||
| 818d3f5518 | |||
| 23c131f45a | |||
| 3c5fd8effe | |||
| 377e3dc73d | |||
| f75457da1c | |||
| d6b0ba3b16 | |||
| 1a1bc46667 | |||
| eb1c39933a | |||
| b070dbf432 | |||
| 8af5ad05d6 | |||
| f0bcb4a019 | |||
| 4c86b7fc38 | |||
| 9b7759520c | |||
| e3e77c7916 | |||
| 13282988e8 | |||
| 00f4254227 | |||
| 669c61eae0 | |||
| 1ee1aca589 | |||
| d49fd6a1f0 | |||
| 8128e3deb0 | |||
| 67a9fe34b4 | |||
| 7a3c4223ec | |||
| cb2590aae5 | |||
| 93ff18a120 | |||
| 4343f130fb | |||
| 5e9778d18a | |||
| 9bcccbc8aa | |||
| 00ddf86ea8 | |||
| cc582f6d20 | |||
| 318b09feae | |||
| d55e36375d | |||
| 40b917df30 | |||
| 053a50d1bc | |||
| 78b554cbe8 | |||
| bf7a4e92e3 | |||
| 3b56e7f1ce | |||
| 1d91a008e1 | |||
| ff1fc0eb75 | |||
| 430774082c | |||
| ef67fdd553 | |||
| 73b5528586 | |||
| 3f65029464 | |||
| 6da1719fda | |||
| 4d85b41ec3 | |||
| ac5c0a1cb3 | |||
| eb22598f20 | |||
| 7a4c29d9d4 | |||
| 255336d74f | |||
| dc625fc682 | |||
| d457cb8693 | |||
| 331c4b4a4e | |||
| d8ca9dc9b5 | |||
| 0eee082035 | |||
| 3e287e8ad7 | |||
| 5ec471050e | |||
| 842dac2660 | |||
| dee86aaa86 | |||
| 13e3a58035 | |||
| f4382d5bd9 | |||
| 8990801268 | |||
| 01b9c06513 | |||
| fc180de616 | |||
| f907133d3a | |||
| 9ae9734a3d | |||
| 770b5cf706 | |||
| 56625c664d | |||
| 056a19b946 | |||
| 281ab666c1 | |||
| 31df5341b5 | |||
| ec7024242f | |||
| ef6e0e00a0 | |||
| 3c757374e1 | |||
| c0e1aa9ac6 | |||
| 40fd0b19eb | |||
| 41240b7aff | |||
| 01f177fbc3 | |||
| ab1d394a46 | |||
| d08f1ba5da | |||
| 641a7c9b7b | |||
| a467ffe861 | |||
| 33ff667990 | |||
| e96015f5e0 | |||
| d4d1cdc885 | |||
| a7d831934d | |||
| e9ef6a2d40 | |||
| 7520efa835 | |||
| cff8454ac7 | |||
| 847779601a | |||
| 49c2088ea3 | |||
| 78600103af | |||
| ce8b3ed36d | |||
| 67641c2116 | |||
| acbbb7d40a | |||
| 1f672cda3a | |||
| 2332d529e0 | |||
| e910fe9da5 | |||
| 4656599bb0 | |||
| d6c33b6123 | |||
| defcc7950d | |||
| 3d55ce0def | |||
| 8c9df5af5d | |||
| 5c33ae134a | |||
| b628489330 | |||
| e9a6e31d4f | |||
| ebe37cf7c1 | |||
| 529708110b | |||
| 9066dd37fb | |||
| 45848925f4 | |||
| 2ba1da1671 | |||
| 35c7d6b70c | |||
| f45d1b770f | |||
| 3650178ab3 | |||
| 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 | |||
| 1585287b63 | |||
| 1d4c3f26b4 | |||
| 589f06b460 | |||
| ca09b1319d | |||
| 5d27c14b77 | |||
| 9da74054ff | |||
| 7572f7f3da | |||
| ef979d8853 | |||
| d090ed2cc1 | |||
| 3fc8b044bb | |||
| 61af46f136 | |||
| 771aa67673 | |||
| b2db0c15a3 | |||
| d49afe60c8 | |||
| 3370d4b663 | |||
| 1f1bcd3a47 | |||
| d9df04bac5 | |||
| 2bf2809269 | |||
| 9bb9e7e399 | |||
| 89d6b2908d | |||
| 63c268cd1e | |||
| 6e2da52f77 | |||
| 04b27a2dee | |||
| 6f4c3271c1 | |||
| b014f10240 | |||
| d3a417aff0 | |||
| 63596d983e | |||
| ffbad41994 | |||
| 2a6f1f418a | |||
| 2402c6f0ef | |||
| 317e97e3a6 | |||
| f7753ccf2e | |||
| 2ad10e9a52 | |||
| b4be96c7a8 | |||
| 69dfad201b | |||
| ee1681497e | |||
| 2f19140085 | |||
| c9d492f9d2 |
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
+1
-1
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [22.x, 24.x]
|
||||
node: [24.x, 26.x]
|
||||
arch: [amd64, arm64]
|
||||
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
||||
|
||||
|
||||
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
+3
-1
@@ -48,4 +48,6 @@ yarn-error.log*
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
src/prisma
|
||||
src/prisma
|
||||
.memory.log*
|
||||
openapi.json
|
||||
|
||||
Executable → Regular
Executable → Regular
+18
-13
@@ -1,23 +1,25 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:24-alpine3.22 AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache ffmpeg tzdata
|
||||
RUN corepack enable \
|
||||
&& apk add --no-cache ffmpeg=6.1.2-r2 tzdata=2026b-r0
|
||||
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY prisma ./prisma
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY pnpm-workspace.yaml .
|
||||
|
||||
FROM base AS deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY .gitignore ./.gitignore
|
||||
@@ -33,8 +35,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
|
||||
@@ -46,14 +46,19 @@ COPY --from=builder /zipline/build ./build
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/code.json ./code.json
|
||||
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# clean
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
RUN pnpm prisma generate \
|
||||
&& rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ZIPLINE_ROOT=/zipline
|
||||
|
||||
ARG ZIPLINE_GIT_SHA
|
||||
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
|
||||
|
||||
CMD ["node", "--enable-source-maps", "build/server"]
|
||||
# add scripts
|
||||
COPY docker/entrypoint.sh /zipline/entrypoint
|
||||
COPY docker/ziplinectl.sh /zipline/ziplinectl
|
||||
|
||||
RUN ln -s /zipline/ziplinectl /usr/local/bin/ziplinectl
|
||||
|
||||
ENTRYPOINT ["/zipline/entrypoint"]
|
||||
|
||||
@@ -47,7 +47,7 @@ Visit [the docs](https://zipline.diced.sh/docs/get-started/docker) for a more in
|
||||
|
||||
This is the recommended way to run Zipline:
|
||||
|
||||
```yml
|
||||
```yaml
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -91,6 +91,9 @@ volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Zipline requires a cpu with AVX support. We don't provide binaries or images that have support for non-AVX cpus
|
||||
|
||||
### Volumes
|
||||
|
||||
- `./uploads` - The folder where all the user uploads are stored (the default is `./uploads`)
|
||||
@@ -260,10 +263,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
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
|
||||
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 "$@"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
out: './src/drizzle',
|
||||
schema: './src/drizzle/schema.ts',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL as string,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
+48
-54
@@ -1,15 +1,17 @@
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -20,63 +22,53 @@ const gitignorePatterns = gitignoreContent
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
export default tseslint.config(
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default defineConfig(
|
||||
tseslint.configs.recommended,
|
||||
|
||||
jsxA11yPlugin.flatConfigs.recommended,
|
||||
reactPlugin.configs.flat.recommended,
|
||||
reactHooksPlugin.configs.flat.recommended,
|
||||
reactRefreshPlugin.configs.vite,
|
||||
|
||||
{ ignores: gitignorePatterns },
|
||||
|
||||
{
|
||||
extends: [
|
||||
tseslint.configs.recommended,
|
||||
reactHooksPlugin.configs['recommended-latest'],
|
||||
reactRefreshPlugin.configs.vite,
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
prettier: prettier,
|
||||
react: reactPlugin,
|
||||
'jsx-a11y': jsxA11yPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
prettier,
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
|
||||
rules: {
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{},
|
||||
{
|
||||
fileInfoOptions: {
|
||||
withNodeModules: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'prettier/prettier': ['error', {}, { fileInfoOptions: { withNodeModules: false } }],
|
||||
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: [
|
||||
'error',
|
||||
'single',
|
||||
{
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
quotes: ['error', 'single', { avoidEscape: true }],
|
||||
semi: ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
indent: 'off',
|
||||
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
@@ -87,28 +79,30 @@ export default tseslint.config(
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
'react/display-name': 'off',
|
||||
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
react: { version: '19' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+645
-39
@@ -6,7 +6,8 @@
|
||||
"devenv"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv"
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"git-hooks": [
|
||||
"devenv",
|
||||
@@ -18,11 +19,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1748883665,
|
||||
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -32,22 +33,142 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_2": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_3": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"git-hooks": "git-hooks_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_2",
|
||||
"crate2nix_stable": "crate2nix_stable",
|
||||
"devshell": "devshell_2",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nix-test-runner": "nix-test-runner_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": "pre-commit-hooks_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773440526,
|
||||
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix_stable": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_3",
|
||||
"crate2nix_stable": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"devshell": "devshell",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-test-runner": "nix-test-runner",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769627083,
|
||||
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "0.15.0",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat",
|
||||
"git-hooks": "git-hooks",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"git-hooks": "git-hooks_3",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753888869,
|
||||
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
|
||||
"lastModified": 1774134162,
|
||||
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
|
||||
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -68,14 +189,87 @@
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_3": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,16 +282,17 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nix",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733312601,
|
||||
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,15 +302,58 @@
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_3": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_4": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1753121425,
|
||||
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -128,20 +366,82 @@
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_3": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_5",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750779888,
|
||||
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||
"lastModified": 1772893680,
|
||||
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -151,6 +451,102 @@
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_5": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
@@ -178,7 +574,10 @@
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-parts": "flake-parts",
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"git-hooks-nix": [
|
||||
"devenv",
|
||||
"git-hooks"
|
||||
@@ -195,43 +594,101 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1752773918,
|
||||
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
|
||||
"lastModified": 1774103430,
|
||||
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
|
||||
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.30",
|
||||
"ref": "devenv-2.32",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"devenv",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773634079,
|
||||
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1752827260,
|
||||
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1751159883,
|
||||
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -240,12 +697,161 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1769433173,
|
||||
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1773964973,
|
||||
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_3",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_4",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"flake-parts": "flake-parts_4",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773630837,
|
||||
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixd",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772660329,
|
||||
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# node 24.4.1, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||
# node 24.14, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/812b3986fd1568f7a858f97fcf425ad996ba7d25";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
|
||||
devenv.url = "github:cachix/devenv";
|
||||
@@ -58,7 +58,6 @@
|
||||
ffmpeg
|
||||
|
||||
# for testing docker
|
||||
colima
|
||||
docker
|
||||
docker-compose
|
||||
];
|
||||
@@ -75,11 +74,6 @@
|
||||
downall.exec = ''
|
||||
process-compose down
|
||||
'';
|
||||
|
||||
# ensure that volumes are mounted with write access for docker containers
|
||||
start_colima.exec = ''
|
||||
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
|
||||
'';
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
|
||||
Executable → Regular
Executable → Regular
+86
-78
@@ -2,122 +2,130 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.3.1",
|
||||
"version": "4.6.1",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
|
||||
"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",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@aws-sdk/client-s3": "3.1046.0",
|
||||
"@aws-sdk/lib-storage": "3.1046.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@mantine/charts": "^8.2.8",
|
||||
"@mantine/code-highlight": "^8.2.8",
|
||||
"@mantine/core": "^8.2.8",
|
||||
"@mantine/dates": "^8.2.8",
|
||||
"@mantine/dropzone": "^8.2.8",
|
||||
"@mantine/form": "^8.2.8",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@mantine/modals": "^8.2.8",
|
||||
"@mantine/notifications": "^8.2.8",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^9.2.0",
|
||||
"@mantine/code-highlight": "^9.2.0",
|
||||
"@mantine/core": "^9.2.0",
|
||||
"@mantine/dates": "^9.2.0",
|
||||
"@mantine/dropzone": "^9.2.0",
|
||||
"@mantine/form": "^9.2.0",
|
||||
"@mantine/hooks": "^9.2.0",
|
||||
"@mantine/modals": "^9.2.0",
|
||||
"@mantine/notifications": "^9.2.0",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.7.2",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.10.0",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cross-env": "^10.0.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"commander": "^14.0.3",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"devalue": "^5.8.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fastify": "^5.8.5",
|
||||
"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.26.0",
|
||||
"katex": "^0.16.22",
|
||||
"mantine-datatable": "^8.2.0",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"katex": "^0.16.46",
|
||||
"mantine-datatable": "^9.2.0",
|
||||
"marked-react": "^4.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"multer": "2.1.1",
|
||||
"nuqs": "^2.8.9",
|
||||
"otplib": "^13.4.0",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.6",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^7.1.4",
|
||||
"zod": "^4.1.5",
|
||||
"zustand": "^5.0.8"
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.4.1",
|
||||
"vite": "^8.0.12",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9.34.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.92.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.98.0",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
|
||||
}
|
||||
|
||||
+3703
-5052
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
onlyBuiltDependencies:
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
|
||||
Executable → Regular
+48
-23
@@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ model Zipline {
|
||||
coreReturnHttpsUrls Boolean @default(false)
|
||||
coreDefaultDomain String?
|
||||
coreTempDirectory String // default join(tmpdir(), 'zipline')
|
||||
coreTrustProxy Boolean @default(false)
|
||||
|
||||
chunksEnabled Boolean @default(true)
|
||||
chunksMax String @default("95mb")
|
||||
@@ -30,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)
|
||||
@@ -54,16 +58,17 @@ model Zipline {
|
||||
featuresOauthRegistration Boolean @default(false)
|
||||
featuresDeleteOnMaxViews Boolean @default(true)
|
||||
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsInstantaneous Boolean @default(false)
|
||||
|
||||
featuresMetricsEnabled Boolean @default(true)
|
||||
featuresMetricsAdminOnly Boolean @default(false)
|
||||
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)
|
||||
@@ -106,7 +111,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)
|
||||
@@ -140,7 +148,7 @@ model Zipline {
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -157,7 +165,7 @@ model User {
|
||||
|
||||
totpSecret String?
|
||||
passkeys UserPasskey[]
|
||||
sessions String[]
|
||||
sessions UserSession[]
|
||||
|
||||
quota UserQuota?
|
||||
|
||||
@@ -185,6 +193,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())
|
||||
@@ -264,6 +284,7 @@ model File {
|
||||
maxViews Int?
|
||||
favorite Boolean @default(false)
|
||||
password String?
|
||||
anonymous Boolean @default(false)
|
||||
|
||||
tags Tag[]
|
||||
|
||||
@@ -294,12 +315,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),
|
||||
);
|
||||
+29
-3
@@ -1,10 +1,32 @@
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
import ThemeProvider from '@/components/ThemeProvider';
|
||||
import { type ZiplineTheme } from '@/lib/theme';
|
||||
import { type Config } from '@/lib/config/validate';
|
||||
import { Button, Text } from '@mantine/core';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
|
||||
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
|
||||
<>
|
||||
<Text size='sm'>{innerProps.modalBody}</Text>
|
||||
|
||||
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
|
||||
OK
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const contextModals = {
|
||||
alert: AlertModal,
|
||||
};
|
||||
|
||||
declare module '@mantine/modals' {
|
||||
export interface MantineModalsOverride {
|
||||
modals: typeof contextModals;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Root({
|
||||
themes,
|
||||
@@ -37,9 +59,13 @@ export default function Root({
|
||||
},
|
||||
centered: true,
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Outlet />
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
|
||||
<NuqsAdapter>
|
||||
<Outlet />
|
||||
</NuqsAdapter>
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
|
||||
@@ -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 expanded={view}>
|
||||
<GenericError
|
||||
title='Failed to fetch dynamically imported module'
|
||||
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
|
||||
details={{}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+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/client/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>
|
||||
|
||||
+181
-300
@@ -1,62 +1,59 @@
|
||||
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 useLogin from '@/lib/client/hooks/useLogin';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { 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 { Link, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { eitherTrue } from '@/lib/primitive';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
|
||||
const location = useLocation();
|
||||
const query = new URLSearchParams(location.search);
|
||||
const { user, mutate } = useLogin();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: config,
|
||||
error: configError,
|
||||
isLoading: configLoading,
|
||||
} = useSWR<Response['/api/server/public']>('/api/server/public', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
const { user, mutate } = useLogin({
|
||||
swrConfig: {
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
const webClient = JSON.stringify(getWebClient());
|
||||
|
||||
const { data: config, error: configError, isLoading: configLoading } = useSWR('/api/server/public');
|
||||
|
||||
const showLocalLogin =
|
||||
query.get('local') === 'true' ||
|
||||
!(
|
||||
@@ -69,202 +66,128 @@ 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(() => {
|
||||
console.log({ willRedirect, config });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotpChange = async (val: string) => {
|
||||
setTotp('pin', val);
|
||||
|
||||
if (val.length === 6) await handleLoginSubmit(form.values, val);
|
||||
};
|
||||
|
||||
if (configLoading || !config) return <LoadingOverlay visible />;
|
||||
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
|
||||
|
||||
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) => handleTotpChange(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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -273,117 +196,75 @@ 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>
|
||||
)}
|
||||
{eitherTrue(
|
||||
config.mfa.passkeys && browserSupportsWebAuthn(),
|
||||
config.oauthEnabled.discord,
|
||||
config.oauthEnabled.github,
|
||||
config.oauthEnabled.google,
|
||||
config.oauthEnabled.oidc,
|
||||
config.features.userRegistration,
|
||||
) && (
|
||||
<>
|
||||
<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 && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton provider='GitHub' leftSection={<IconBrandGithubFilled size='1.1rem' />} />
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton
|
||||
provider='GitHub'
|
||||
leftSection={<IconBrandGithubFilled size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{config.features.userRegistration && (
|
||||
<Text ta='center' mt='md'>
|
||||
Don't have an account?{' '}
|
||||
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
|
||||
Register
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function Logout() {
|
||||
useTitle('Log out');
|
||||
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
|
||||
if (res.ok) {
|
||||
setUser(null);
|
||||
mutate('/api/user', null);
|
||||
navigate('/auth/login');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <LoadingOverlay visible />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import useUser from '@/lib/client/hooks/useUser';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -18,10 +19,12 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, Navigate, 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');
|
||||
@@ -29,8 +32,6 @@ export function Component() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const {
|
||||
data: config,
|
||||
error: configError,
|
||||
@@ -57,6 +58,8 @@ export function Component() {
|
||||
},
|
||||
);
|
||||
|
||||
const { user, loading: userLoading } = useUser();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
@@ -64,22 +67,14 @@ 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(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/api/user');
|
||||
if (res.ok) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
@@ -96,14 +91,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({
|
||||
@@ -126,7 +128,11 @@ export function Component() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || configLoading) return <LoadingOverlay visible />;
|
||||
if (userLoading || configLoading) return <LoadingOverlay visible />;
|
||||
|
||||
if (user) {
|
||||
return <Navigate to='/dashboard' replace />;
|
||||
}
|
||||
|
||||
if (!config || configError) {
|
||||
return (
|
||||
@@ -214,6 +220,7 @@ export function Component() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -225,6 +232,7 @@ export function Component() {
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='new-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
@@ -62,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>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Container, LoadingOverlay } from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Terms of Service');
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import DashboardServerActions from '@/components/pages/serverActions';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Actions');
|
||||
|
||||
return <DashboardServerActions />;
|
||||
}
|
||||
|
||||
Component.displayName = 'Dashboard/Admin/Actions';
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardInvites from '@/components/pages/invites';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Invites');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardServerSettings from '@/components/pages/serverSettings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Server Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Params, redirect, useLoaderData } from 'react-router-dom';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardUsers from '@/components/pages/users';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Users');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFiles from '@/components/pages/files';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Files');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardFolders from '@/components/pages/folders';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Folders');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardHome from '@/components/pages/dashboard';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardMetrics from '@/components/pages/metrics';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { redirect } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardSettings from '@/components/pages/settings';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Settings');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload File');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UploadText from '@/components/pages/upload/Text';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Upload Text');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DashboardURLs from '@/components/pages/urls';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('URLs');
|
||||
|
||||
@@ -1,32 +1,164 @@
|
||||
import { useApiPagination } from '@/components/pages/files/useApiPagination';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFolder, IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useQueryState, parseAsInteger } from 'nuqs';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
const res = await fetch(`/api/server/folder/${params.id}`);
|
||||
export async function loader({ params, request }: { params: Params<string>; request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const perpage = url.searchParams.get('perpage') ?? '15';
|
||||
|
||||
const res = await fetch(
|
||||
`/api/server/folder/${params.id}?page=${encodeURIComponent(page)}&perpage=${encodeURIComponent(perpage)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response('Folder not found', { status: 404 });
|
||||
}
|
||||
return {
|
||||
folder: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
initial: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
};
|
||||
}
|
||||
|
||||
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
return (
|
||||
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card withBorder shadow='sm' 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>
|
||||
);
|
||||
}
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
const { initial } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(15));
|
||||
|
||||
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
|
||||
{
|
||||
route: `/api/server/folder/${initial.folder.id}`,
|
||||
page,
|
||||
perpage,
|
||||
sort: 'createdAt',
|
||||
order: 'desc',
|
||||
},
|
||||
{ fallbackData: initial, keepPreviousData: true, revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const folder = data?.folder ?? initial.folder;
|
||||
const files = data?.page ?? [];
|
||||
const totalRecords = data?.total ?? 0;
|
||||
const cachedPages = data?.pages ?? 0;
|
||||
|
||||
useTitle(folder.name ?? 'Folder');
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const items: FolderBreadcrumb[] = [];
|
||||
|
||||
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>[];
|
||||
const from = totalRecords === 0 ? 0 : (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, totalRecords);
|
||||
|
||||
const [current, setCurrent, setFiles] = useFileNavStore(
|
||||
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
|
||||
);
|
||||
const currentFile = current ? (files.find((file) => file.id === current) ?? null) : null;
|
||||
const ids = useMemo(() => files.map((file) => file.id), [files]);
|
||||
|
||||
useEffect(() => {
|
||||
setFiles(ids);
|
||||
}, [ids]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
<DashboardFileModal
|
||||
open={!!currentFile}
|
||||
setOpen={(open) => setCurrent(open ? (currentFile?.id ?? null) : null)}
|
||||
file={currentFile}
|
||||
reduce
|
||||
sequenced
|
||||
/>
|
||||
|
||||
{breadcrumbs.length > 1 && (
|
||||
<Breadcrumbs mb='md'>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<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 +166,86 @@ 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(files.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
lg: 3,
|
||||
md: 2,
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{files.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce onOpen={(fileId) => setCurrent(fileId)} />
|
||||
</Suspense>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children.length === 0 && totalRecords === 0 && (
|
||||
<Text c='dimmed' mt='md'>
|
||||
This folder is empty.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify='space-between' align='center' mt='md'>
|
||||
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
|
||||
|
||||
<Group gap='sm'>
|
||||
<Select
|
||||
value={perpage.toString()}
|
||||
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
|
||||
onChange={(value) => {
|
||||
setSearchParams((prev) => {
|
||||
prev.set('perpage', value ?? '15');
|
||||
prev.set('page', '1');
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
w={80}
|
||||
size='xs'
|
||||
variant='filled'
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={cachedPages}
|
||||
size='sm'
|
||||
withControls
|
||||
withEdges
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,20 @@ import ConfigProvider from '@/components/ConfigProvider';
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { 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 });
|
||||
|
||||
const d = (await res.json()) as Response['/api/server/folder/[id]'];
|
||||
if (!d.folder) throw data('Folder not found', { status: 404 });
|
||||
|
||||
return {
|
||||
folder: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
folder: d.folder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +29,8 @@ export function Component() {
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
useTitle(`Upload to ${folder.name ?? 'folder'}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
@@ -37,7 +41,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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import DashboardFileType from '@/components/file/DashboardFileType';
|
||||
import TagPill from '@/components/pages/files/tags/TagPill';
|
||||
import { useSsrData } from '@/components/ZiplineSSRProvider';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { parseString } from '@/lib/parser';
|
||||
@@ -8,7 +10,6 @@ import { formatRootUrl } from '@/lib/url';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Collapse,
|
||||
@@ -24,7 +25,6 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
|
||||
import * as sanitize from 'isomorphic-dompurify';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSsrData } from '../../../components/ZiplineSSRProvider';
|
||||
import { getFile } from '../../ssr-view/server';
|
||||
|
||||
type SsrData = {
|
||||
@@ -33,7 +33,7 @@ type SsrData = {
|
||||
code: boolean;
|
||||
user?: Partial<User>;
|
||||
host: string;
|
||||
pw?: string | null;
|
||||
token?: string | null;
|
||||
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
|
||||
filesRoute?: string;
|
||||
};
|
||||
@@ -42,20 +42,15 @@ export default function ViewFileId() {
|
||||
const data = useSsrData<SsrData>();
|
||||
if (!data) return null;
|
||||
|
||||
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
|
||||
|
||||
// Fix dates that were stringified during SSR
|
||||
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
|
||||
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
|
||||
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
|
||||
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
|
||||
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
|
||||
const { file, password, code, user, host, metrics, filesRoute, token } = data;
|
||||
|
||||
const [passwordValue, setPassword] = useState<string>('');
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
|
||||
return password && !pw ? (
|
||||
useTitle(file.originalName ?? file.name ?? 'View File');
|
||||
|
||||
return password && !token ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
@@ -68,7 +63,8 @@ export default function ViewFileId() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
const json = (await res.json()) as { token: string };
|
||||
window.location.replace(`/view/${file.name}?token=${encodeURIComponent(json.token)}`);
|
||||
} else {
|
||||
setPasswordError('Invalid password');
|
||||
}
|
||||
@@ -98,7 +94,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)}>
|
||||
@@ -109,7 +105,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
@@ -118,7 +114,7 @@ export default function ViewFileId() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Collapse in={detailsOpen}>
|
||||
<Collapse expanded={detailsOpen}>
|
||||
<Paper m='md' p='md' withBorder>
|
||||
{user?.view!.content && (
|
||||
<Typography>
|
||||
@@ -147,15 +143,9 @@ export default function ViewFileId() {
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
|
||||
<Paper m='md' p='md' withBorder>
|
||||
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
|
||||
</Paper>
|
||||
) : (
|
||||
<Box m='sm'>
|
||||
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
|
||||
</Box>
|
||||
)}
|
||||
<Center m='sm'>
|
||||
<DashboardFileType file={file as unknown as File} token={token} show code={code} fullscreen />
|
||||
</Center>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -164,7 +154,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 +167,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>
|
||||
@@ -199,7 +195,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}${token ? `?token=${encodeURIComponent(token)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size='1rem' />
|
||||
@@ -210,7 +206,7 @@ export default function ViewFileId() {
|
||||
size='md'
|
||||
variant='outline'
|
||||
component={Link}
|
||||
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
|
||||
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
|
||||
target='_blank'
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
@@ -219,7 +215,7 @@ export default function ViewFileId() {
|
||||
</ActionIcon.Group>
|
||||
</Group>
|
||||
|
||||
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
|
||||
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
|
||||
|
||||
{user?.view!.content && (
|
||||
<Typography>
|
||||
|
||||
@@ -6,10 +6,11 @@ export default function ViewUrlId() {
|
||||
const data = useSsrData<{
|
||||
url: { id: string; destination?: string };
|
||||
password?: boolean;
|
||||
token?: string | null;
|
||||
}>();
|
||||
if (!data) return null;
|
||||
|
||||
const { url, password } = data;
|
||||
const { url, password, token } = data;
|
||||
|
||||
const [passwordValue, setPassword] = useState<string>('');
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
@@ -18,7 +19,7 @@ export default function ViewUrlId() {
|
||||
if (!password && url.destination) window.location.href = url.destination;
|
||||
}, []);
|
||||
|
||||
return password ? (
|
||||
return password && !token ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
@@ -31,7 +32,8 @@ export default function ViewUrlId() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
const json = (await res.json()) as { token: string };
|
||||
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
|
||||
} else {
|
||||
setPasswordError('Invalid password');
|
||||
}
|
||||
|
||||
+23
-30
@@ -6,9 +6,13 @@ import DashboardErrorBoundary from './error/DashboardErrorBoundary';
|
||||
import RootErrorBoundary from './error/RootErrorBoundary';
|
||||
import FourOhFour from './pages/404';
|
||||
import Login from './pages/auth/login';
|
||||
import Logout from './pages/auth/logout';
|
||||
import Root from './Root';
|
||||
|
||||
const fourOhFourCatchall = {
|
||||
path: '*',
|
||||
Component: FourOhFour,
|
||||
};
|
||||
|
||||
export async function dashboardLoader() {
|
||||
try {
|
||||
const res = await fetch('/api/server/settings/web');
|
||||
@@ -29,22 +33,21 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
Component: Root,
|
||||
path: '/',
|
||||
HydrateFallback: () => null,
|
||||
children: [
|
||||
{
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{ path: '*', Component: FourOhFour },
|
||||
fourOhFourCatchall,
|
||||
{
|
||||
path: '/auth',
|
||||
children: [
|
||||
{ path: 'login', Component: Login },
|
||||
{ path: 'logout', Component: Logout },
|
||||
{ path: 'register', lazy: () => import('./pages/auth/register') },
|
||||
{ path: 'auth/login', Component: Login },
|
||||
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
|
||||
{
|
||||
path: 'setup',
|
||||
path: 'auth/setup',
|
||||
lazy: () => import('./pages/auth/setup'),
|
||||
},
|
||||
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
|
||||
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,38 +62,28 @@ export const router = createBrowserRouter([
|
||||
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
|
||||
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
|
||||
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
|
||||
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
|
||||
// admin routes
|
||||
{
|
||||
path: 'upload',
|
||||
children: [
|
||||
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loader: async () => {
|
||||
const res = await fetch('/api/user');
|
||||
if (!res.ok) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
if (!res.ok) return redirect('/auth/login');
|
||||
|
||||
const { user } = await res.json();
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: 'users',
|
||||
children: [
|
||||
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: ':id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
path: 'admin/users/:id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as cookie from 'cookie';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
import { verifyAccessToken } from '@/lib/accessToken';
|
||||
import { config as zConfig } from '@/lib/config';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { renderHtml } from '@/lib/ssr/renderHtml';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import { createRoutes } from './routes'; // This should include the `/url/:id` route
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { createRoutes } from './routes';
|
||||
|
||||
export async function render(
|
||||
{
|
||||
@@ -17,13 +15,11 @@ export async function render(
|
||||
}: {
|
||||
themes: ZiplineTheme[];
|
||||
defaultTheme: Config['website']['theme'];
|
||||
req: FastifyRequest;
|
||||
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
|
||||
},
|
||||
url: string,
|
||||
) {
|
||||
const routes = createRoutes(themes, defaultTheme);
|
||||
|
||||
const id = url.split('/').pop();
|
||||
const id = req.params?.id ?? null;
|
||||
if (!id) return { html: 'Not Found', meta: '', status: 404 };
|
||||
|
||||
const { config: libConfig, reloadSettings } = await import('@/lib/config');
|
||||
@@ -52,31 +48,27 @@ export async function render(
|
||||
return { html: 'Gone', meta: '', status: 410 };
|
||||
}
|
||||
|
||||
const cookies = cookie.parse(req.headers.cookie || '');
|
||||
const pw = cookies[`url_pw_${urlEntry.id}`];
|
||||
const token = req.query.token;
|
||||
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
|
||||
const hasPassword = !!urlEntry.password;
|
||||
|
||||
const data = {
|
||||
url: { ...urlEntry },
|
||||
password: hasPassword,
|
||||
token: valid ? token : null,
|
||||
};
|
||||
|
||||
delete (data.url as any).password;
|
||||
|
||||
const routes = createRoutes(themes, defaultTheme);
|
||||
|
||||
if (hasPassword) {
|
||||
delete (data.url as any).password;
|
||||
if (pw) {
|
||||
const verified = await verifyPassword(pw, urlEntry.password!);
|
||||
if (!verified) {
|
||||
delete (data.url as any).destination;
|
||||
return renderHtml(routes, { url, data, status: 403 });
|
||||
}
|
||||
} else {
|
||||
if (!valid) {
|
||||
delete (data.url as any).destination;
|
||||
return renderHtml(routes, { url, data, status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
delete (data.url as any).password;
|
||||
|
||||
await prisma.url.update({
|
||||
where: { id: urlEntry.id },
|
||||
data: { views: { increment: 1 } },
|
||||
|
||||
@@ -5,19 +5,19 @@ import '@mantine/dropzone/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import 'mantine-datatable/styles.css';
|
||||
|
||||
import { verifyAccessToken } from '@/lib/accessToken';
|
||||
import { isCode } from '@/lib/code';
|
||||
import { config as zConfig } from '@/lib/config';
|
||||
import type { Config } from '@/lib/config/validate';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { parseString } from '@/lib/parser';
|
||||
import { parserMetrics } from '@/lib/parser/metrics';
|
||||
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
|
||||
import { stripHtml } from '@/lib/stripHtml';
|
||||
import type { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import * as cookie from 'cookie';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
|
||||
@@ -43,11 +43,11 @@ export async function render(
|
||||
}: {
|
||||
themes: ZiplineTheme[];
|
||||
defaultTheme: Config['website']['theme'];
|
||||
req: FastifyRequest;
|
||||
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
|
||||
},
|
||||
url: string,
|
||||
) {
|
||||
const id = url.split('/').pop();
|
||||
const id = req.params?.id ?? null;
|
||||
if (!id) return { html: 'Not Found', meta: '', status: 404 };
|
||||
|
||||
const { config: libConfig, reloadSettings } = await import('@/lib/config');
|
||||
@@ -93,17 +93,15 @@ export async function render(
|
||||
const metrics = await parserMetrics(user.id);
|
||||
const config = { website: { theme: zConfig.website.theme } };
|
||||
|
||||
const cookies = cookie.parse(req.headers.cookie || '');
|
||||
const pw = cookies[`file_pw_${file.id}`];
|
||||
const token = req.query.token;
|
||||
const valid = token && file.password ? verifyAccessToken(token, 'file', file.id) : false;
|
||||
const hasPassword = !!file.password;
|
||||
|
||||
delete (file as any).password;
|
||||
|
||||
if (hasPassword) {
|
||||
if (pw) {
|
||||
const verified = await verifyPassword(pw, file.password!);
|
||||
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
|
||||
delete (file as any).password;
|
||||
} else {
|
||||
delete (file as any).password;
|
||||
console.log('File is password protected');
|
||||
if (!valid) {
|
||||
const data = {
|
||||
file: { id: file.id, name: file.name, type: file.type },
|
||||
password: true,
|
||||
@@ -140,7 +138,7 @@ export async function render(
|
||||
const data = {
|
||||
file,
|
||||
password: hasPassword,
|
||||
pw: pw || null,
|
||||
token: valid ? token : null,
|
||||
code,
|
||||
user,
|
||||
host,
|
||||
@@ -166,110 +164,115 @@ export async function render(
|
||||
const router = createStaticRouter(routes, context);
|
||||
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
|
||||
|
||||
const meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
? `<meta property="og:title" content="${
|
||||
const safeFilename = stripHtml(file.name);
|
||||
const safeOriginalName = stripHtml(file.originalName || '');
|
||||
const safeType = stripHtml(file.type || '');
|
||||
|
||||
const viewEnabled = !!user.view?.enabled;
|
||||
const showRichOg = viewEnabled && !!user.view.embed;
|
||||
const showMediaOg = viewEnabled && (!!user.view.embed || !!user.view.embedMediaOnly);
|
||||
const pageUrl = `${host}${url.split('?')[0]}`;
|
||||
|
||||
const richMeta = [
|
||||
showRichOg && user?.view?.embedTitle
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
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="${
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: '',
|
||||
showRichOg && user?.view?.embedDescription
|
||||
? `<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="${
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: '',
|
||||
showRichOg && user?.view?.embedSiteName
|
||||
? `<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="${
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: '',
|
||||
showRichOg && user?.view?.embedColor
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
: ''
|
||||
}
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
${
|
||||
file.type?.startsWith('image')
|
||||
const imageOg =
|
||||
showMediaOg && file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${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="${pageUrl}" />
|
||||
<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}" />
|
||||
${showRichOg ? `<meta property="twitter:title" content="${safeFilename}" />` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('video')
|
||||
const videoOg =
|
||||
showMediaOg && file.type?.startsWith('video')
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:video:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
file.type?.startsWith('audio')
|
||||
const audioOg =
|
||||
showMediaOg && file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<meta name="twitter:player" content="${host}/raw/${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}" />
|
||||
${showRichOg ? `<meta name="twitter:title" content="${safeFilename}" />` : ''}
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${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="${pageUrl}" />
|
||||
<meta property="og:audio" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:type" content="${safeType}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
const otherOg =
|
||||
showRichOg && !file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${pageUrl}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: '';
|
||||
|
||||
<title>${file.name}</title>
|
||||
`;
|
||||
const docTitle = `<title>${file.originalName ? safeOriginalName : safeFilename}</title>`;
|
||||
|
||||
const includeHead = showRichOg || showMediaOg;
|
||||
const headMeta = includeHead
|
||||
? [richMeta, imageOg, videoOg, audioOg, otherOg, docTitle].filter(Boolean).join('\n')
|
||||
: '';
|
||||
|
||||
return {
|
||||
html,
|
||||
meta: `${meta}\n${createZiplineSsr(data)}`,
|
||||
meta: `${headMeta ? `${headMeta}\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
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/store/view';
|
||||
import { ViewStore, ViewType, useViewStore } from '@/lib/client/store/view';
|
||||
import { Center, SegmentedControl } from '@mantine/core';
|
||||
import { IconLayoutGrid, IconLayoutList } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
Executable → Regular
+95
-62
@@ -1,11 +1,11 @@
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import useAvatar from '@/lib/client/hooks/useAvatar';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { useLogout } from '@/lib/client/hooks/useLogout';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import type { SafeConfig } from '@/lib/config/safe';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
@@ -41,15 +41,17 @@ import {
|
||||
IconRefreshDot,
|
||||
IconSettingsFilled,
|
||||
IconShieldLockFilled,
|
||||
IconStopwatch,
|
||||
IconTags,
|
||||
IconUpload,
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, NavigateFunction, Outlet, useLoaderData, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import { SETTINGS_EXTERNAL_LINKS } from './pages/serverSettings';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -122,9 +124,21 @@ const navLinks: NavLinks[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <IconAdjustments size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/settings',
|
||||
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
|
||||
if: (user) => user?.role === 'SUPERADMIN',
|
||||
href: '/dashboard/admin/settings',
|
||||
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
|
||||
label: name,
|
||||
icon,
|
||||
active: (path: string) => path === url,
|
||||
href: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
icon: <IconStopwatch size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/actions',
|
||||
href: '/dashboard/admin/actions',
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
@@ -143,6 +157,66 @@ const navLinks: NavLinks[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderLinks = (
|
||||
links: NavLinks[],
|
||||
pathname: string,
|
||||
user: Response['/api/user']['user'],
|
||||
config: SafeConfig,
|
||||
navigate: NavigateFunction,
|
||||
) => {
|
||||
const visible = (link: NavLinks) => !link.if || link.if(user as Response['/api/user']['user'], config);
|
||||
|
||||
const active = (link: NavLinks): boolean => {
|
||||
if (!visible(link)) return false;
|
||||
if (link.active(pathname)) return true;
|
||||
|
||||
return (link.links || []).some((child) => active(child));
|
||||
};
|
||||
|
||||
return links.map((link) => {
|
||||
if (visible(link)) {
|
||||
const sublinks = link.links;
|
||||
const isActive = link.active(pathname);
|
||||
|
||||
if (!sublinks) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive && !sublinks.some((child) => active(child))}
|
||||
defaultOpened={isActive || sublinks.some((child) => active(child))}
|
||||
onClick={(event) => {
|
||||
if (!link.href) return;
|
||||
event.preventDefault();
|
||||
navigate(link.href);
|
||||
}}
|
||||
>
|
||||
{renderLinks(sublinks, pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -151,6 +225,8 @@ export default function Layout() {
|
||||
const clipboard = useClipboard();
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogout();
|
||||
|
||||
const loaderData = useLoaderData<typeof dashboardLoader>();
|
||||
const config = loaderData.config;
|
||||
@@ -158,6 +234,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?',
|
||||
@@ -232,6 +314,7 @@ export default function Layout() {
|
||||
color={theme.colors.gray[6]}
|
||||
mr='xl'
|
||||
hiddenFrom='sm'
|
||||
bdrs='md'
|
||||
/>
|
||||
|
||||
{config.website.titleLogo && (
|
||||
@@ -297,12 +380,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>
|
||||
@@ -317,54 +395,9 @@ export default function Layout() {
|
||||
</Title>
|
||||
<Divider hiddenFrom='sm' />
|
||||
|
||||
{navLinks
|
||||
.filter((link) => !link.if || link.if(user as Response['/api/user']['user'], config))
|
||||
.map((link) => {
|
||||
if (!link.links) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={location.pathname === link.href}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
defaultOpened={link.active(location.pathname)}
|
||||
>
|
||||
{link.links
|
||||
.filter(
|
||||
(sublink) => !sublink.if || sublink.if(user as Response['/api/user']['user'], config),
|
||||
)
|
||||
.map((sublink) => (
|
||||
<NavLink
|
||||
key={sublink.label}
|
||||
label={sublink.label}
|
||||
leftSection={sublink.icon}
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
variant='light'
|
||||
active={location.pathname === sublink.href}
|
||||
component={Link}
|
||||
to={sublink.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<ScrollArea mah='calc(100vh - 200px)'>
|
||||
{renderLinks(navLinks, location.pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</ScrollArea>
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<VersionBadge />
|
||||
@@ -391,7 +424,7 @@ export default function Layout() {
|
||||
|
||||
<AppShell.Main>
|
||||
<ConfigProvider data={loaderData}>
|
||||
<Paper m='lg' withBorder p='xs'>
|
||||
<Paper withBorder m='md' p='xs' radius='md'>
|
||||
<Outlet />
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Button, Group, Image, Modal, Select, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconClipboardCheck, IconClipboardX, IconCopy, IconDownload } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Type = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
||||
const UNSUPPORTED_COPY = ['image/jpeg', 'image/webp'];
|
||||
|
||||
export default function QRCodeModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
}) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [type, setType] = useState<Type>('image/png');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
import('qrcode')
|
||||
.then((QRCode) => QRCode.toDataURL(getDomain(url), { width: 500, type }))
|
||||
.then(setDataUrl)
|
||||
.catch(() => setDataUrl(null));
|
||||
}, [opened, url, type]);
|
||||
|
||||
const copyImageToClipboard = async () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
showNotification({
|
||||
message: 'QR code image copied to clipboard',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCheck size='1rem' />,
|
||||
});
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
title: 'Failed to copy QR code image',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
color: 'red',
|
||||
icon: <IconClipboardX size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.style.display = 'none';
|
||||
link.download = `qr-code.${type.split('/')[1]}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title='QR Code' opened={opened} onClose={onClose} size='sm' centered>
|
||||
{dataUrl ? (
|
||||
<Image src={dataUrl} alt='QR Code' />
|
||||
) : (
|
||||
<Text c='red' ta='center'>
|
||||
Failed to generate QR code.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Select
|
||||
mt='md'
|
||||
label='Format'
|
||||
value={type}
|
||||
onChange={(value) => setType(value as Type)}
|
||||
data={[
|
||||
{ value: 'image/png', label: 'png' },
|
||||
{ value: 'image/jpeg', label: 'jpeg' },
|
||||
{ value: 'image/webp', label: 'webp' },
|
||||
]}
|
||||
size='xs'
|
||||
/>
|
||||
|
||||
{dataUrl && (
|
||||
<Group gap='xs' mt='md' grow>
|
||||
<Tooltip
|
||||
label={
|
||||
UNSUPPORTED_COPY.includes(type)
|
||||
? 'Copying this format is not supported in some browsers. You can copy the image normally via right-click or holding it.'
|
||||
: ''
|
||||
}
|
||||
hidden={!UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
<Button
|
||||
onClick={copyImageToClipboard}
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
disabled={UNSUPPORTED_COPY.includes(type)}
|
||||
>
|
||||
Copy Image
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={downloadImage} leftSection={<IconDownload size='1rem' />}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { ZiplineTheme, findTheme, themeComponents } from '@/lib/theme';
|
||||
import dark_blue from '@/lib/theme/builtins/dark_blue';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useVersion from '@/lib/hooks/useVersion';
|
||||
import useVersion from '@/lib/client/hooks/useVersion';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
|
||||
function DataDisplay({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string; href?: string; color?: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Stack gap='xs'>
|
||||
@@ -29,7 +33,7 @@ function DataDisplay({ items }: { items: { label: string; value: string; href?:
|
||||
{item.value}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{item.value}</Text>
|
||||
<Text c={item.color ?? undefined}>{item.value}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
@@ -105,10 +109,14 @@ export default function VersionBadge() {
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!,
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -131,6 +139,7 @@ export default function VersionBadge() {
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
|
||||
import FileModal from './FileModal';
|
||||
import FileViewer from './FileViewer';
|
||||
|
||||
export default function DashboardFileModal(props: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file?: File | null;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
sequenced?: boolean;
|
||||
}) {
|
||||
const fileModal = useSettingsStore((state) => state.settings.fileViewer);
|
||||
|
||||
if (fileModal === 'default') {
|
||||
return <FileModal {...props} />;
|
||||
}
|
||||
|
||||
return <FileViewer {...props} />;
|
||||
}
|
||||
Executable → Regular
+62
-34
@@ -1,9 +1,10 @@
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useObjectState from '@/lib/client/hooks/useObjectState';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutateFiles } from '../actions';
|
||||
|
||||
export default function EditFileDetailsModal({
|
||||
@@ -15,13 +16,41 @@ export default function EditFileDetailsModal({
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!file) return null;
|
||||
const [formData, setFormData] = useObjectState<{
|
||||
name: string;
|
||||
maxViews: number | null;
|
||||
password: string | null;
|
||||
originalName: string | null;
|
||||
type: string | null;
|
||||
}>({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
|
||||
const [name, setName] = useState<string>(file.name ?? '');
|
||||
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
||||
const [password, setPassword] = useState<string | null>('');
|
||||
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
||||
const [type, setType] = useState<string | null>(file?.type ?? null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
name: file?.name ?? '',
|
||||
maxViews: file?.maxViews ?? null,
|
||||
password: file?.password ? '' : null,
|
||||
originalName: file?.originalName ?? null,
|
||||
type: file?.type ?? null,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
maxViews: null,
|
||||
password: null,
|
||||
originalName: null,
|
||||
type: null,
|
||||
});
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const handleRemovePassword = async () => {
|
||||
if (!file.password) return;
|
||||
@@ -58,12 +87,12 @@ export default function EditFileDetailsModal({
|
||||
name?: string;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (originalName !== null) data['originalName'] = originalName?.trim();
|
||||
if (type !== null) data['type'] = type?.trim();
|
||||
if (name !== file.name) data['name'] = name.trim();
|
||||
if (formData.maxViews !== null) data['maxViews'] = formData.maxViews;
|
||||
if (formData.originalName !== null) data['originalName'] = formData.originalName?.trim();
|
||||
if (formData.type !== null) data['type'] = formData.type?.trim();
|
||||
if (formData.name !== file.name) data['name'] = formData.name.trim();
|
||||
|
||||
const passwordTrimmed = password?.trim();
|
||||
const passwordTrimmed = formData.password?.trim();
|
||||
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
|
||||
|
||||
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
||||
@@ -85,29 +114,19 @@ export default function EditFileDetailsModal({
|
||||
|
||||
onClose();
|
||||
|
||||
setPassword(null);
|
||||
setFormData('password', null);
|
||||
mutateFiles();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(file.name ?? '');
|
||||
setMaxViews(file.maxViews ?? null);
|
||||
setPassword(file.password ? '' : null);
|
||||
setOriginalName(file.originalName ?? null);
|
||||
setType(file.type ?? null);
|
||||
}
|
||||
}, [open, file]);
|
||||
|
||||
return (
|
||||
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
||||
<Modal zIndex={400} 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
+165
-48
@@ -1,13 +1,17 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import TagPill from '@/components/pages/files/tags/TagPill';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconProps,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -29,6 +33,9 @@ import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
Icon,
|
||||
IconBombFilled,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDeviceSdCard,
|
||||
IconDownload,
|
||||
@@ -45,9 +52,12 @@ import {
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
addToFolder,
|
||||
@@ -68,15 +78,16 @@ function ActionButton({
|
||||
onClick,
|
||||
tooltip,
|
||||
color,
|
||||
...props
|
||||
}: {
|
||||
Icon: Icon;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
color?: string;
|
||||
}) {
|
||||
} & ActionIconProps) {
|
||||
return (
|
||||
<Tooltip label={tooltip}>
|
||||
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick}>
|
||||
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick} {...props}>
|
||||
<Icon size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -88,36 +99,48 @@ export default function FileModal({
|
||||
setOpen,
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
sequenced,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file?: File | null;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
sequenced?: boolean;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
|
||||
|
||||
const [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]));
|
||||
};
|
||||
@@ -167,13 +190,33 @@ export default function FileModal({
|
||||
|
||||
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
|
||||
|
||||
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
|
||||
useShallow((state) => {
|
||||
if (!state.current) {
|
||||
return [state.goPrev, state.goNext, false, false];
|
||||
}
|
||||
|
||||
const idx = state.ids.indexOf(state.current);
|
||||
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
setValue(file.tags?.map((x) => x.id) ?? []);
|
||||
} else {
|
||||
setValue([]);
|
||||
}
|
||||
}, [file]);
|
||||
if (!open || !sequenced) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowLeft' && hasPrev) {
|
||||
goPrev();
|
||||
} else if (event.key === 'ArrowRight' && hasNext) {
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -194,7 +237,7 @@ export default function FileModal({
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<DashboardFileType file={file} show />
|
||||
{open && <DashboardFileType file={file} show />}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing='md' my='xs'>
|
||||
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
|
||||
@@ -224,6 +267,7 @@ export default function FileModal({
|
||||
{file.originalName && (
|
||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||
)}
|
||||
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && (
|
||||
@@ -232,17 +276,12 @@ export default function FileModal({
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
pointer
|
||||
onClick={() => tagsCombobox.toggleDropdown()}
|
||||
onClick={() => tagsCombobox.openDropdown()}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? (
|
||||
@@ -254,9 +293,14 @@ export default function FileModal({
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onFocus={() => tagsCombobox.openDropdown()}
|
||||
onBlur={() => tagsCombobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
value.length > 0 &&
|
||||
event.currentTarget.value === ''
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
@@ -285,9 +329,7 @@ export default function FileModal({
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<Combobox.Option value='no-tags' disabled>
|
||||
No tags found, create one outside of this menu.
|
||||
</Combobox.Option>
|
||||
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
@@ -310,8 +352,8 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
store={folderCombobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
@@ -323,11 +365,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'
|
||||
@@ -335,24 +383,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>
|
||||
)}
|
||||
@@ -398,6 +446,11 @@ export default function FileModal({
|
||||
tooltip='View file in a new tab'
|
||||
color='blue'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconClipboardTypography}
|
||||
onClick={() => copyFile(file, clipboard, true)}
|
||||
tooltip='Copy raw file link'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconCopy}
|
||||
onClick={() => copyFile(file, clipboard)}
|
||||
@@ -415,6 +468,70 @@ export default function FileModal({
|
||||
<></>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{open && sequenced && fileNavButtons && (
|
||||
<>
|
||||
<ActionButton
|
||||
Icon={IconChevronLeft}
|
||||
tooltip='Previous file'
|
||||
onClick={() => goPrev()}
|
||||
disabled={!hasPrev}
|
||||
hiddenFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '0.75rem',
|
||||
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='md'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronRight}
|
||||
tooltip='Next file'
|
||||
onClick={() => goNext()}
|
||||
disabled={!hasNext}
|
||||
hiddenFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '0.75rem',
|
||||
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='md'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronLeft}
|
||||
tooltip='Previous file'
|
||||
onClick={() => goPrev()}
|
||||
disabled={!hasPrev}
|
||||
visibleFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '1rem',
|
||||
top: '50%',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='lg'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronRight}
|
||||
tooltip='Next file'
|
||||
onClick={() => goNext()}
|
||||
disabled={!hasNext}
|
||||
visibleFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '1rem',
|
||||
top: '50%',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='lg'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Executable → Regular
@@ -0,0 +1,641 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import TagPill from '@/components/pages/files/tags/TagPill';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconProps,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Drawer,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Paper,
|
||||
Pill,
|
||||
PillsInput,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
Icon,
|
||||
IconBombFilled,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDeviceSdCard,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconEyeFilled,
|
||||
IconFileInfo,
|
||||
IconFolderMinus,
|
||||
IconInfoCircle,
|
||||
IconPencil,
|
||||
IconRefresh,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTags,
|
||||
IconTagsOff,
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
addToFolder,
|
||||
copyFile,
|
||||
createFolderAndAdd,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
favoriteFile,
|
||||
mutateFiles,
|
||||
removeFromFolder,
|
||||
viewFile,
|
||||
} from '../actions';
|
||||
import EditFileDetailsModal from './EditFileDetailsModal';
|
||||
import FileStat from './FileStat';
|
||||
|
||||
function ActionButton({
|
||||
Icon,
|
||||
onClick,
|
||||
tooltip,
|
||||
color,
|
||||
...props
|
||||
}: {
|
||||
Icon: Icon;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
color?: string;
|
||||
} & ActionIconProps) {
|
||||
return (
|
||||
<Tooltip label={tooltip} zIndex='200'>
|
||||
<ActionIcon
|
||||
size='xl'
|
||||
variant='subtle'
|
||||
bd='1px solid var(--mantine-color-dark-4)'
|
||||
color={color ?? 'gray'}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
<Icon size='1.15rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileViewer({
|
||||
open,
|
||||
setOpen,
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
sequenced,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file?: File | null;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
sequenced?: boolean;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
|
||||
|
||||
const { data: folders } = useFolders(user);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!folders) return [];
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const folderCombobox = useCombobox();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleAdd = async (value: string) => {
|
||||
if (value === '$create') {
|
||||
await createFolderAndAdd(file!, search.trim());
|
||||
} else {
|
||||
await addToFolder(file!, value);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
|
||||
user ? `/api/users/${user}/tags` : '/api/user/tags',
|
||||
);
|
||||
|
||||
const tagsCombobox = useCombobox();
|
||||
|
||||
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
|
||||
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
|
||||
const handleValueRemove = (val: string) => {
|
||||
setValue((current) => current.filter((v) => v !== val));
|
||||
};
|
||||
|
||||
const handleTagsUpdate = async () => {
|
||||
if (value.length === file?.tags?.length && value.every((v) => file?.tags?.map((x) => x.id).includes(v))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/files/${file!.id}`,
|
||||
'PATCH',
|
||||
{
|
||||
tags: value,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Failed to save tags',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconTagsOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Saved tags',
|
||||
message: `Saved ${data!.tags!.length} tags for file ${data!.name}`,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFiles();
|
||||
mutate('/api/user/tags');
|
||||
};
|
||||
|
||||
const triggerSave = async () => {
|
||||
tagsCombobox.closeDropdown();
|
||||
|
||||
handleTagsUpdate();
|
||||
};
|
||||
|
||||
const values = value.map((id) => <TagPill key={id} tag={tags?.find((t) => t.id === id) || null} />);
|
||||
|
||||
const [editFileOpen, setEditFileOpen] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
|
||||
useShallow((state) => {
|
||||
if (!state.current) return [state.goPrev, state.goNext, false, false];
|
||||
|
||||
const idx = state.ids.indexOf(state.current);
|
||||
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!sequenced) return;
|
||||
if (event.key === 'ArrowLeft' && hasPrev) {
|
||||
event.preventDefault();
|
||||
goPrev();
|
||||
} else if (event.key === 'ArrowRight' && hasNext) {
|
||||
event.preventDefault();
|
||||
goNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext, setOpen]);
|
||||
|
||||
const headerActionGroup = file ? (
|
||||
<ActionIcon.Group>
|
||||
{!reduce && (
|
||||
<>
|
||||
<ActionButton
|
||||
Icon={IconPencil}
|
||||
onClick={() => setEditFileOpen(true)}
|
||||
tooltip='Edit file details'
|
||||
color='orange'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconTrashFilled}
|
||||
onClick={() => deleteFile(warnDeletion, file, setOpen)}
|
||||
tooltip='Delete file'
|
||||
color='red'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={file.favorite ? IconStarFilled : IconStar}
|
||||
onClick={() => favoriteFile(file)}
|
||||
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
|
||||
color={file.favorite ? 'gray' : 'yellow'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionButton
|
||||
Icon={IconInfoCircle}
|
||||
onClick={() => setInfoOpen((v) => !v)}
|
||||
tooltip={infoOpen ? 'Hide details' : 'Show details'}
|
||||
color={infoOpen ? 'cyan' : 'gray'}
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconExternalLink}
|
||||
onClick={() => viewFile(file)}
|
||||
tooltip='Open in new tab'
|
||||
color='blue'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconClipboardTypography}
|
||||
onClick={() => copyFile(file, clipboard, true)}
|
||||
tooltip='Copy raw file link'
|
||||
/>
|
||||
<ActionButton Icon={IconCopy} onClick={() => copyFile(file, clipboard)} tooltip='Copy file link' />
|
||||
<ActionButton Icon={IconDownload} onClick={() => downloadFile(file)} tooltip='Download' />
|
||||
</ActionIcon.Group>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{file && (
|
||||
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file} />
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
opened={infoOpen}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
position='right'
|
||||
title={<Title order={2}>Details</Title>}
|
||||
radius='md'
|
||||
offset={20}
|
||||
overlayProps={{ blur: 6 }}
|
||||
>
|
||||
{file && (
|
||||
<Stack gap='md'>
|
||||
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
|
||||
<FileStat Icon={IconDeviceSdCard} title='Size' value={bytes(file.size)} />
|
||||
<FileStat
|
||||
Icon={IconUpload}
|
||||
title='Created at'
|
||||
value={new Date(file.createdAt).toLocaleString()}
|
||||
/>
|
||||
<FileStat
|
||||
Icon={IconRefresh}
|
||||
title='Updated at'
|
||||
value={new Date(file.updatedAt).toLocaleString()}
|
||||
/>
|
||||
{file.deletesAt && !reduce && (
|
||||
<FileStat
|
||||
Icon={IconBombFilled}
|
||||
title='Deletes at'
|
||||
value={new Date(file.deletesAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileStat
|
||||
Icon={IconEyeFilled}
|
||||
title='Views'
|
||||
value={file.maxViews ? `${file.views} / ${file.maxViews}` : file.views}
|
||||
/>
|
||||
{file.originalName && (
|
||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||
)}
|
||||
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
||||
{!reduce && (
|
||||
<>
|
||||
<Box>
|
||||
<Title order={4} mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
pointer
|
||||
onClick={() => tagsCombobox.openDropdown()}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? (
|
||||
values
|
||||
) : (
|
||||
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
|
||||
)}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onFocus={() => tagsCombobox.openDropdown()}
|
||||
onBlur={() => tagsCombobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
value.length > 0 &&
|
||||
event.currentTarget.value === ''
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{tags?.length ? (
|
||||
tags.map((tag) => (
|
||||
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
|
||||
<Group gap='sm'>
|
||||
<Checkbox
|
||||
checked={value.includes(tag.id)}
|
||||
onChange={() => {}}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
<TagPill tag={tag} />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={4} mb='xs'>
|
||||
Folder
|
||||
</Title>
|
||||
{file.folderId ? (
|
||||
<Button
|
||||
color='red'
|
||||
leftSection={<IconFolderMinus size='1rem' />}
|
||||
onClick={() => removeFromFolder(file)}
|
||||
fullWidth
|
||||
>
|
||||
Remove from folder "
|
||||
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
folderCombobox.openDropdown();
|
||||
folderCombobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
folderCombobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
folderCombobox.openDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
folderCombobox.closeDropdown();
|
||||
setSearch('');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
{folders?.length === 0 && (
|
||||
<Combobox.Empty>
|
||||
You have no folders. Start typing to create a new folder for this file.
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
additionalOptions={
|
||||
!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 ? (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create folder "{search}"
|
||||
</Combobox.Option>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 200,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
|
||||
opacity: open ? 1 : 0,
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
transition: 'opacity 220ms cubic-bezier(0.33, 1, 0.68, 1)',
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<Paper m={0} p={0} withBorder bdrs={0} style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
|
||||
<Stack gap='sm' px='lg' py='sm' onClick={(e) => e.stopPropagation()}>
|
||||
<Group justify='space-between' align='center' gap='sm' wrap='nowrap' visibleFrom='sm'>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size='lg' fw={600} lineClamp={1} c='white'>
|
||||
{file?.name ?? ''}
|
||||
</Text>
|
||||
{file && (
|
||||
<Text size='sm' c='dimmed' lineClamp={1}>
|
||||
{file.type} ({bytes(file.size)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap='sm' wrap='nowrap' style={{ flexShrink: 0 }}>
|
||||
{headerActionGroup}
|
||||
<ActionButton Icon={IconX} tooltip='Close' onClick={() => setOpen(false)} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap='sm' hiddenFrom='sm'>
|
||||
<Group justify='space-between' align='flex-start' gap='sm' wrap='nowrap'>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size='lg' fw={600} lineClamp={1} c='white'>
|
||||
{file?.name ?? ''}
|
||||
</Text>
|
||||
{file && (
|
||||
<Text size='sm' c='dimmed' lineClamp={1}>
|
||||
{file.type} ({bytes(file.size)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<ActionButton
|
||||
Icon={IconX}
|
||||
tooltip='Close'
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={0} wrap='nowrap'>
|
||||
{headerActionGroup}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
ref={setScrollParent}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'flex-start',
|
||||
paddingTop: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
marginLeft: '1rem',
|
||||
marginRight: '1rem',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
>
|
||||
{open && file ? (
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
alignSelf: 'stretch',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
overflow: 'visible',
|
||||
paddingLeft: '4rem',
|
||||
paddingRight: '4rem',
|
||||
}}
|
||||
>
|
||||
<DashboardFileType
|
||||
key={file.id}
|
||||
file={file}
|
||||
show
|
||||
fullscreen
|
||||
allowZoom={false}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
|
||||
{sequenced && fileNavButtons && file && (
|
||||
<>
|
||||
<ActionButton
|
||||
Icon={IconChevronLeft}
|
||||
tooltip='Previous file'
|
||||
onClick={() => goPrev()}
|
||||
disabled={!hasPrev}
|
||||
hiddenFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '0.75rem',
|
||||
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='md'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronRight}
|
||||
tooltip='Next file'
|
||||
onClick={() => goNext()}
|
||||
disabled={!hasNext}
|
||||
hiddenFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '0.75rem',
|
||||
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
size='md'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronLeft}
|
||||
tooltip='Previous file'
|
||||
onClick={() => goPrev()}
|
||||
disabled={!hasPrev}
|
||||
visibleFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '1rem',
|
||||
top: '50%',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
variant='filled'
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
Icon={IconChevronRight}
|
||||
tooltip='Next file'
|
||||
onClick={() => goNext()}
|
||||
disabled={!hasNext}
|
||||
visibleFrom='sm'
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '1rem',
|
||||
top: '50%',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
variant='filled'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
+21
-4
@@ -2,17 +2,34 @@ import type { File } from '@/lib/db/models/file';
|
||||
import { Card } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import FileModal from './FileModal';
|
||||
import DashboardFileModal from './DashboardFileModal';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
|
||||
export default function DashboardFile({
|
||||
file,
|
||||
reduce,
|
||||
id,
|
||||
onOpen,
|
||||
}: {
|
||||
file: File;
|
||||
reduce?: boolean;
|
||||
id?: string;
|
||||
onOpen?: (fileId: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
|
||||
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
|
||||
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
|
||||
|
||||
<Card
|
||||
shadow='md'
|
||||
radius='md'
|
||||
p={0}
|
||||
onClick={() => (onOpen ? onOpen(file.id) : setOpen(true))}
|
||||
className={styles.file}
|
||||
>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Image as MantineImage,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { renderMode } from '../pages/upload/renderMode';
|
||||
import Asciinema from '../render/Asciinema';
|
||||
import Pdf from '../render/Pdf';
|
||||
import Render from '../render/Render';
|
||||
import fileIcon from './fileIcon';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
|
||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
return (
|
||||
<Stack align='center'>
|
||||
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
|
||||
<Text size='md' ta='center'>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
||||
return (
|
||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function FileZoomModal({
|
||||
setOpen,
|
||||
children,
|
||||
}: {
|
||||
setOpen: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(5px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardFileType({
|
||||
file,
|
||||
show,
|
||||
password,
|
||||
code,
|
||||
allowZoom,
|
||||
}: {
|
||||
file: DbFile | File;
|
||||
show?: boolean;
|
||||
password?: string | null;
|
||||
code?: boolean;
|
||||
allowZoom?: boolean;
|
||||
}) {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
const fileRoute = user ? `/api/user/files/${(file as DbFile).id}/raw` : `/raw/${file.name}`;
|
||||
const thumbnailRoute = user
|
||||
? `/api/user/files/${(file as DbFile).thumbnail?.path}/raw`
|
||||
: `/raw/${(file as DbFile).thumbnail?.path}`;
|
||||
const dbFile = 'id' in file;
|
||||
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
|
||||
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [type, setType] = useState(file.type.split('/')[0]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const getText = useCallback(async () => {
|
||||
try {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
reader.result!.slice(0, 1 * 1024 * 1024) +
|
||||
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
} else {
|
||||
setFileContent(reader.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(
|
||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${fileRoute}${password ? `?pw=${password}` : ''}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
const text = await res.text();
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
}, [dbFile, file, password]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setType('text');
|
||||
getText();
|
||||
} else if (type === 'text') {
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (disableMediaPreview && !show)
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (dbFile && file.password === true && !show)
|
||||
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
|
||||
|
||||
if (dbFile && file.password === true && show)
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
text={`Click to view protected ${file.name}`}
|
||||
Icon={IconShieldLockFilled}
|
||||
onClick={() => window.open(`/view/${file.name}${password ? `?pw=${password}` : ''}`)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
|
||||
|
||||
switch (true) {
|
||||
case type === 'video':
|
||||
return show ? (
|
||||
<video
|
||||
width='100%'
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||
/>
|
||||
) : (file as DbFile).thumbnail && dbFile ? (
|
||||
<Box pos='relative'>
|
||||
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
|
||||
|
||||
<Center
|
||||
pos='absolute'
|
||||
h='100%'
|
||||
top='50%'
|
||||
left='50%'
|
||||
style={{
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<IconPlayerPlay
|
||||
size='4rem'
|
||||
stroke={3}
|
||||
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
) : (
|
||||
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case type === 'image':
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vw',
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
{allowZoom && open && (
|
||||
<FileZoomModal setOpen={setOpen}>
|
||||
<MantineImage
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
width: 'auto',
|
||||
}}
|
||||
/>
|
||||
</FileZoomModal>
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<MantineImage
|
||||
fit='contain'
|
||||
mah={400}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name || 'Image'}
|
||||
/>
|
||||
);
|
||||
|
||||
case type === 'audio':
|
||||
return show ? (
|
||||
<audio
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={dbFile ? `${fileRoute}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
/>
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case type === 'text':
|
||||
return show ? (
|
||||
fileContent.trim() === '' ? (
|
||||
<LoadingOverlay
|
||||
visible={fileContent.trim() === ''}
|
||||
loaderProps={{
|
||||
children: (
|
||||
<>
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
<Text ta='center' mt='xs' c='dimmed'>
|
||||
Loading file...
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Render mode={renderIn} language={file.name.split('.').pop() || ''} code={fileContent} />
|
||||
)
|
||||
) : (
|
||||
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
case isAsciicast === true:
|
||||
return show && dbFile ? (
|
||||
<Asciinema src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
Icon={fileIcon('application/x-asciicast')}
|
||||
/>
|
||||
);
|
||||
|
||||
case file.type === 'application/pdf':
|
||||
return show && dbFile ? (
|
||||
<Pdf src={`${fileRoute}${password ? `?pw=${password}` : ''}`} />
|
||||
) : (
|
||||
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
|
||||
default:
|
||||
if (dbFile && !show)
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (dbFile && show)
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
onClick={() => window.open(`${fileRoute}${password ? `?pw=${password}` : ''}`)}
|
||||
text={`Click to view file ${file.name} in a new tab`}
|
||||
Icon={fileIcon(file.type)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
else return <IconFileUnknown size={48} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
export default function FileZoomModal({
|
||||
setOpen,
|
||||
children,
|
||||
}: {
|
||||
setOpen: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
export default function FullscreenFrame({
|
||||
fullscreen,
|
||||
parent,
|
||||
children,
|
||||
}: {
|
||||
fullscreen?: boolean;
|
||||
parent?: HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!fullscreen) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={
|
||||
parent
|
||||
? {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: 'none',
|
||||
overflow: 'visible',
|
||||
}
|
||||
: {
|
||||
width: 'min(96vw, calc(100vw - 3rem))',
|
||||
maxHeight: 'none',
|
||||
overflow: 'visible',
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
|
||||
export default function Placeholder({
|
||||
text,
|
||||
Icon,
|
||||
...props
|
||||
}: {
|
||||
text: string;
|
||||
Icon: Icon;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
|
||||
<Stack align='center'>
|
||||
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
|
||||
<Text size='md' ta='center'>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Asciinema from '@/components/render/Asciinema';
|
||||
import Pdf from '@/components/render/Pdf';
|
||||
import Render from '@/components/render/Render';
|
||||
import { renderMode } from '@/components/render/renderMode';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Image as MantineImage,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import fileIcon from '../fileIcon';
|
||||
import FileZoomModal from './FileZoomModal';
|
||||
import FullscreenFrame from './FullscreenFrame';
|
||||
import useFileContents from './useFileContent';
|
||||
import useFileUrls, { isDbFile } from './useFileUrls';
|
||||
|
||||
export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
||||
return (
|
||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
|
||||
<Stack align='center'>
|
||||
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
|
||||
<Text size='md' ta='center'>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenSizedMedia({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
alignSelf: 'stretch',
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardFileType({
|
||||
file,
|
||||
show,
|
||||
token,
|
||||
code,
|
||||
allowZoom,
|
||||
fullscreen,
|
||||
scrollParent,
|
||||
}: {
|
||||
file: DbFile | File;
|
||||
show?: boolean;
|
||||
token?: string | null;
|
||||
code?: boolean;
|
||||
allowZoom?: boolean;
|
||||
fullscreen?: boolean;
|
||||
scrollParent?: HTMLElement | null;
|
||||
}) {
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
const mediaAutoMuted = useSettingsStore((state) => state.settings.mediaAutoMuted);
|
||||
|
||||
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
|
||||
const db = isDbFile(file) ? file : null;
|
||||
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const renderIn = renderMode(extension);
|
||||
const type = code ? 'text' : file.type.split('/')[0];
|
||||
|
||||
const fileContent = useFileContents({ enabled: type === 'text', file, fileUrl });
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoomOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [zoomOpen]);
|
||||
|
||||
if (disableMediaPreview && !show) {
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
}
|
||||
|
||||
if (db?.password === true && !show) {
|
||||
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
|
||||
}
|
||||
|
||||
if (db?.password === true && show) {
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
text={`Click to view protected ${file.name}`}
|
||||
Icon={IconShieldLockFilled}
|
||||
onClick={() => window.open(viewUrl!)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
|
||||
|
||||
if (type === 'video') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
|
||||
if (!show) {
|
||||
if (thumbnailUrl) {
|
||||
return (
|
||||
<Box pos='relative'>
|
||||
<MantineImage src={thumbnailUrl} alt={file.name || 'Video thumbnail'} />
|
||||
<Center pos='absolute' inset={0}>
|
||||
<IconPlayerPlay
|
||||
size='4rem'
|
||||
stroke={3}
|
||||
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
}
|
||||
|
||||
const video = (
|
||||
<video
|
||||
width={fullscreen ? undefined : '100%'}
|
||||
autoPlay
|
||||
muted={mediaAutoMuted}
|
||||
controls
|
||||
src={fileUrl}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
objectFit: 'contain',
|
||||
...(fullscreen
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '85vw', maxHeight: '85vh', width: '100%' }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return fullscreen ? <FullscreenSizedMedia>{video}</FullscreenSizedMedia> : video;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
|
||||
if (!show) {
|
||||
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
|
||||
}
|
||||
|
||||
const image = (
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
fit='contain'
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
...(fullscreen
|
||||
? { maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto' }
|
||||
: { maxWidth: '70vw', maxHeight: '70vw' }),
|
||||
}}
|
||||
onClick={() => allowZoom && setZoomOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fullscreen ? <FullscreenSizedMedia>{image}</FullscreenSizedMedia> : <Center>{image}</Center>}
|
||||
{allowZoom && zoomOpen && (
|
||||
<FileZoomModal setOpen={setZoomOpen}>
|
||||
<MantineImage
|
||||
src={fileUrl}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
width: 'auto',
|
||||
}}
|
||||
/>
|
||||
</FileZoomModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<audio autoPlay muted={mediaAutoMuted} controls style={{ width: '100%' }} src={fileUrl} />
|
||||
) : (
|
||||
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
if (!show) return <Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
if (fileContent.trim() === '') {
|
||||
return (
|
||||
<LoadingOverlay
|
||||
visible={fileContent.trim() === ''}
|
||||
loaderProps={{
|
||||
children: (
|
||||
<>
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
<Text ta='center' mt='xs' c='dimmed'>
|
||||
Loading file...
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullscreenFrame fullscreen={fullscreen} parent={scrollParent}>
|
||||
<Render
|
||||
mode={renderIn}
|
||||
language={extension}
|
||||
code={fileContent}
|
||||
noClamp={fullscreen}
|
||||
scrollParent={scrollParent}
|
||||
/>
|
||||
</FullscreenFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAsciicast) {
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
<FullscreenFrame fullscreen={fullscreen}>
|
||||
<Asciinema src={fileUrl} />
|
||||
</FullscreenFrame>
|
||||
) : (
|
||||
<Placeholder
|
||||
text={`Click to download asciinema cast ${file.name}`}
|
||||
Icon={fileIcon('application/x-asciicast')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.type === 'application/pdf') {
|
||||
if (!fileUrl) return <Loader />;
|
||||
return show ? (
|
||||
fullscreen ? (
|
||||
<Box style={{ height: 'calc(100vh - 7.5rem)', width: 'min(96vw, calc(100vw - 3rem))' }}>
|
||||
<Pdf src={fileUrl} />
|
||||
</Box>
|
||||
) : (
|
||||
<Pdf src={fileUrl} />
|
||||
)
|
||||
) : (
|
||||
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
|
||||
<Placeholder
|
||||
onClick={() => window.open(fileUrl)}
|
||||
text={`Click to view file ${file.name} in a new tab`}
|
||||
Icon={fileIcon(file.type)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import useSWR from 'swr';
|
||||
import { isDbFile } from './useFileUrls';
|
||||
|
||||
const MAX_BYTES = 1 * 1024 * 1024;
|
||||
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
|
||||
|
||||
async function readBlobText(file: File) {
|
||||
const raw = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.onload = () => resolve((reader.result ?? '') as string);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
return raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) + FILE_BIG : raw;
|
||||
}
|
||||
|
||||
async function readText(fileUrl: string) {
|
||||
const res = await fetch(fileUrl, {
|
||||
headers: {
|
||||
Range: `bytes=0-${MAX_BYTES}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to fetch file');
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export default function useFileContent({
|
||||
enabled,
|
||||
file,
|
||||
fileUrl,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
file: DbFile | File;
|
||||
fileUrl: string;
|
||||
}) {
|
||||
const { data, error } = useSWR<string>(
|
||||
() => {
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isDbFile(file)) return ['dbfile', file.id] as const;
|
||||
|
||||
const f = file as File;
|
||||
return ['blobfile', f.name] as const;
|
||||
},
|
||||
async () => {
|
||||
if (!isDbFile(file)) return readBlobText(file as File);
|
||||
|
||||
if (file.size > MAX_BYTES) {
|
||||
const text = await readText(fileUrl);
|
||||
return text + FILE_BIG;
|
||||
}
|
||||
|
||||
return readText(fileUrl);
|
||||
},
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) return 'Error loading file.';
|
||||
|
||||
return data ?? '';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import type { File as DbFile } from '@/lib/db/models/file';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
function appendToken(url: string, token?: string | null) {
|
||||
if (!token) return url;
|
||||
|
||||
return `${url}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
}
|
||||
|
||||
export function isDbFile(file: DbFile | File): file is DbFile {
|
||||
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
|
||||
}
|
||||
|
||||
export default function useFileUrls({ file, token }: { file: DbFile | File; token?: string | null }): {
|
||||
fileUrl: string;
|
||||
thumbnailUrl: string | null;
|
||||
viewUrl: string | null;
|
||||
} {
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
const blobUrl = useMemo(() => (isDbFile(file) ? null : URL.createObjectURL(file as File)), [file]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isDbFile(file)) return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
|
||||
|
||||
const thumb = file.thumbnail?.path;
|
||||
const thumbnailUrl = thumb ? (user ? `/api/user/files/${thumb}/raw` : `/raw/${thumb}`) : null;
|
||||
|
||||
return {
|
||||
fileUrl: appendToken(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, token),
|
||||
viewUrl: appendToken(`/view/${file.name}`, token),
|
||||
thumbnailUrl,
|
||||
};
|
||||
}, [token, blobUrl, file, user]);
|
||||
}
|
||||
Executable → Regular
+42
-46
@@ -1,8 +1,10 @@
|
||||
import { mutateFolder } from '@/components/pages/folders/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { conditionalWarning } from '@/lib/warningModal';
|
||||
import { conditionalWarning } from '@/lib/client/warningModal';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -27,10 +29,12 @@ export function downloadFile(file: File) {
|
||||
window.open(`/raw/${file.name}?download=true`, '_blank');
|
||||
}
|
||||
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
|
||||
const domain = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
|
||||
const url = raw
|
||||
? getDomain(`/raw/${file.name}`)
|
||||
: file.url
|
||||
? getDomain(`${file.url}`)
|
||||
: getDomain(`/view/${file.name}`);
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
@@ -106,43 +110,40 @@ export async function favoriteFile(file: File) {
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
export function createFolderAndAdd(file: File, folderName: string | null) {
|
||||
fetchApi<Extract<Response['/api/user/folders'], Folder>>('/api/user/folders', 'POST', {
|
||||
name: folderName,
|
||||
files: [file.id],
|
||||
}).then(({ data, error }) => {
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Error while creating folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder created',
|
||||
message: `${data!.name} has been created with ${file.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
}
|
||||
});
|
||||
export async function createFolderAndAdd(file: File, folderName: string | null) {
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/folders'], Folder>>(
|
||||
'/api/user/folders',
|
||||
'POST',
|
||||
{
|
||||
name: folderName,
|
||||
files: [file.id],
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Error while creating folder',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Folder created',
|
||||
message: `${data!.name} has been created with ${file.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function removeFromFolder(file: File) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/folders/${file.folderId}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
},
|
||||
);
|
||||
const { data, error } = await fetchApi<{ folder: Folder }>(`/api/user/folders/${file.folderId}`, 'DELETE', {
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
@@ -154,13 +155,13 @@ export async function removeFromFolder(file: File) {
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'File removed from folder',
|
||||
message: `${file.name} has been removed from ${data!.name}`,
|
||||
message: `${file.name} has been removed from ${data?.folder.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ export async function addToFolder(file: File, folderId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
|
||||
});
|
||||
}
|
||||
|
||||
mutateFolders();
|
||||
mutateFolder();
|
||||
mutateFiles();
|
||||
}
|
||||
|
||||
@@ -231,8 +232,3 @@ export function mutateFiles() {
|
||||
mutate('/api/user/recent');
|
||||
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
|
||||
}
|
||||
|
||||
export function mutateFolders() {
|
||||
mutate('/api/user/folders');
|
||||
mutate('/api/user/folders?noincl=true');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FolderHierarchyItem } from '@/lib/folderHierarchy';
|
||||
import { Combobox, Text } from '@mantine/core';
|
||||
|
||||
export default function FolderComboboxOptions({
|
||||
folderOptions,
|
||||
searchValue,
|
||||
additionalOptions,
|
||||
}: {
|
||||
folderOptions: FolderHierarchyItem[];
|
||||
searchValue: string;
|
||||
additionalOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Combobox.Options>
|
||||
{additionalOptions}
|
||||
{folderOptions
|
||||
.filter((f) => f.path.toLowerCase().includes(searchValue.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
<Text size='sm' style={{ paddingLeft: f.depth * 12 }}>
|
||||
{f.depth > 0 ? '└ ' : ''}
|
||||
{f.name}
|
||||
</Text>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+43
-9
@@ -1,10 +1,20 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import Stat from '@/components/Stat';
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
IconDeviceSdCard,
|
||||
IconEyeFilled,
|
||||
IconFiles,
|
||||
IconGraphFilled,
|
||||
IconLink,
|
||||
IconStarFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
@@ -13,6 +23,9 @@ export default function DashboardHome() {
|
||||
const { user } = useLogin();
|
||||
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
@@ -47,9 +60,18 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Title order={2} mt='md' mb='xs'>
|
||||
Recent files
|
||||
</Title>
|
||||
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Recent files</Title>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/files'
|
||||
leftSection={<IconFiles size='1rem' />}
|
||||
>
|
||||
View all files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{recentLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
|
||||
@@ -71,9 +93,21 @@ export default function DashboardHome() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Title order={2} mt='md'>
|
||||
Stats
|
||||
</Title>
|
||||
<Group mt='md' style={{ alignItems: 'center' }}>
|
||||
<Title order={2}>Stats</Title>
|
||||
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='compact-xs'
|
||||
component={Link}
|
||||
to='/dashboard/metrics'
|
||||
leftSection={<IconGraphFilled size='1rem' />}
|
||||
>
|
||||
View instance metrics
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
These statistics are based on your uploads only.
|
||||
</Text>
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
PENDING: (
|
||||
<Badge variant='light' color='gray'>
|
||||
Pending
|
||||
</Badge>
|
||||
),
|
||||
PROCESSING: (
|
||||
<Badge variant='light' color='yellow'>
|
||||
Processing
|
||||
</Badge>
|
||||
),
|
||||
COMPLETE: (
|
||||
<Badge variant='light' color='green'>
|
||||
Complete
|
||||
</Badge>
|
||||
),
|
||||
FAILED: (
|
||||
<Badge variant='light' color='red'>
|
||||
Failed
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
|
||||
export default function PendingFilesButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
>('/api/user/files/incomplete');
|
||||
|
||||
const handleDelete = async (incompleteFile: IncompleteFile) => {
|
||||
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
|
||||
'/api/user/files/incomplete',
|
||||
'DELETE',
|
||||
{
|
||||
id: [incompleteFile.id],
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: `Failed to delete pending file: ${error.error}`,
|
||||
color: 'red',
|
||||
icon: <IconFileDots size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Cleared Pending File!',
|
||||
color: 'green',
|
||||
icon: <IconTrashFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
<Group justify='space-between'>
|
||||
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
|
||||
{badgeMap[incompleteFile.status]}
|
||||
</Group>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Text size='xs' c='dimmed' fw='bold'>
|
||||
{incompleteFile.metadata.file.type}
|
||||
</Text>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size='xs' c='dimmed'>
|
||||
{incompleteFile.id}
|
||||
</Text>
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Button
|
||||
fullWidth
|
||||
size='compact-sm'
|
||||
mt='xs'
|
||||
color='red'
|
||||
variant='light'
|
||||
onClick={() => handleDelete(incompleteFile)}
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{incompleteFiles?.length === 0 && (
|
||||
<Paper withBorder px='sm' py='xs'>
|
||||
No pending files
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='View pending files'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconFileDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '.';
|
||||
|
||||
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: DashboardFilesModalsUpdate;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.field,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Group gap='xs'>
|
||||
<IconGripVertical size='1rem' />
|
||||
|
||||
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
|
||||
|
||||
<Text>{NAMES[item.field]}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const newIndex = fields.findIndex((item) => item.field === over?.id);
|
||||
|
||||
setIndex(active.id as FieldSettings['field'], newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
<SortableTableField item={item} />
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+13
-10
@@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkFavorite(ids: string[]) {
|
||||
export async function bulkFavorite(ids: string[], favorite: boolean) {
|
||||
const text = favorite ? 'favorite' : 'unfavorite';
|
||||
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
|
||||
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
labels: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Favorite',
|
||||
confirm: `${textcaps}`,
|
||||
},
|
||||
confirmProps: { color: 'yellow' },
|
||||
onConfirm: async () => {
|
||||
notifications.show({
|
||||
title: 'Favoriting files',
|
||||
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}ing files`,
|
||||
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
loading: true,
|
||||
id: 'bulk-favorite',
|
||||
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
|
||||
{
|
||||
files: ids,
|
||||
|
||||
favorite: true,
|
||||
favorite,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
notifications.update({
|
||||
title: 'Error while favoriting files',
|
||||
title: 'Error while modifying files',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconStarsOff size='1rem' />,
|
||||
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
title: 'Favorited files',
|
||||
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}d files`,
|
||||
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
icon: <IconStarsFilled size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
|
||||
Executable → Regular
+80
-11
@@ -1,19 +1,50 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
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 } from '@tabler/icons-react';
|
||||
import { useViewStore } from '@/lib/client/store/view';
|
||||
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconDots,
|
||||
IconFileDots,
|
||||
IconFileUpload,
|
||||
IconGridPatternFilled,
|
||||
IconTableOptions,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import { parseAsBoolean, useQueryStates } from 'nuqs';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 function useModals() {
|
||||
return useQueryStates({
|
||||
table: parseAsBoolean.withDefault(false),
|
||||
idSearch: parseAsBoolean.withDefault(false),
|
||||
tags: parseAsBoolean.withDefault(false),
|
||||
pending: parseAsBoolean.withDefault(false),
|
||||
});
|
||||
}
|
||||
|
||||
export type DashboardFilesModalsUpdate = ReturnType<typeof useModals>[1];
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
const [modals, setModals] = useModals();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagsModal modals={modals} setModals={setModals} />
|
||||
<PendingFilesModal modals={modals} setModals={setModals} />
|
||||
|
||||
<Group>
|
||||
<Title>Files</Title>
|
||||
|
||||
@@ -25,8 +56,46 @@ export default function DashboardFiles() {
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
<TagsButton />
|
||||
<PendingFilesButton />
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Tooltip label='More actions'>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconDots 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>
|
||||
@@ -38,7 +107,7 @@ export default function DashboardFiles() {
|
||||
<Files />
|
||||
</>
|
||||
) : (
|
||||
<FileTable />
|
||||
<FileTable modals={modals} setModals={setModals} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user