Compare commits

..

15 Commits

Author SHA1 Message Date
dicedtomato 5e6d28deac Merge branch 'trunk' into drizzle 2025-09-19 20:29:44 -07:00
dicedtomato 590e46f18e Merge branch 'trunk' into drizzle 2025-09-18 12:42:29 -07:00
dicedtomato 2eaee1a92e Merge branch 'trunk' into drizzle 2025-09-18 12:35:59 -07:00
diced 4758bd145e fix: accidental force push lmaoo (#875)
PR: #875
2025-09-18 12:35:00 -07:00
dicedtomato 8487e07006 Merge branch 'trunk' into drizzle 2025-09-09 17:06:05 -07:00
TheShadowEevee 83246d6a4b fix: increase TLD length regex (#886)
30 matches the Second Level Domain length limit. This should allow for TLDs longer than 2 or 3 characters, such as .solutions

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-09-09 16:50:46 -07:00
dicedtomato 9df12e141f Merge branch 'trunk' into drizzle 2025-09-08 23:08:50 -07:00
dicedtomato 02e25aa608 Merge branch 'trunk' into drizzle 2025-09-08 23:07:03 -07:00
dicedtomato cf570af0a8 Merge branch 'trunk' into drizzle 2025-09-08 15:59:39 -07:00
dicedtomato 6644eac0ed Merge branch 'trunk' into drizzle 2025-09-08 12:13:09 -07:00
diced a14337bdd4 fix: lock resolution 2025-09-06 12:56:58 -07:00
dicedtomato ab4b9c4ac1 Merge branch 'trunk' into drizzle 2025-09-06 12:55:57 -07:00
dicedtomato 40f7d39426 Merge branch 'trunk' into drizzle 2025-09-05 21:07:54 -07:00
diced 34f27d4da4 feat: add drizzle packages 2025-09-05 19:41:47 -07:00
diced 7b2af8b8c5 feat: initial drizzle setup 2025-09-04 22:05:21 -07:00
345 changed files with 10414 additions and 14718 deletions
Regular → Executable
View File
Regular → Executable
View File
+3 -2
View File
@@ -17,7 +17,7 @@ body:
id: runtime-type
attributes:
label: How is Zipline being run?
description:
description:
options:
- On docker (docker, docker compose, etc.)
- Built from source (running it through `pnpm start` or `node`, etc.)
@@ -34,7 +34,7 @@ body:
- If version checking is enabled (it is by default): paste the response from `http://<domain>/api/version`
- If using docker (and can't do the above): specify the tag you are using (`latest`, `trunk`, or a tag digest)
- A simple version number (e.g. "4.2.1") may also suffice
placeholder: '4.2.1'
placeholder: "4.2.1"
validations:
required: true
@@ -68,3 +68,4 @@ body:
description: |
Please list the exact steps required to reproduce the issue.
Include any relevant configuration options, settings, or external services that may affect Ziplines functionality.
Regular → Executable
View File
Regular → Executable
View File
-99
View File
@@ -1,99 +0,0 @@
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
Regular → Executable
+1 -3
View File
@@ -48,6 +48,4 @@ yarn-error.log*
uploads*/
*.crt
*.key
src/prisma
.memory.log*
openapi.json
src/prisma
Regular → Executable
View File
Regular → Executable
+3 -8
View File
@@ -33,6 +33,8 @@ 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
@@ -50,15 +52,8 @@ RUN pnpm prisma generate
RUN rm -rf /tmp/* /root/*
ENV NODE_ENV=production
ENV ZIPLINE_ROOT=/zipline
ARG ZIPLINE_GIT_SHA
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
# 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"]
CMD ["node", "--enable-source-maps", "build/server"]
Regular → Executable
View File
Regular → Executable
+4
View File
@@ -260,6 +260,10 @@ DATASOURCE_LOCAL_DIRECTORY="/path/to/your/local/files"
# DATASOURCE_S3_BUCKET="your-bucket"
# DATASOURCE_S3_ENDPOINT="your-endpoint"
# ^ if using a custom endpoint other than aws s3
# optional but both are required if using ssl
# SSL_KEY="/path/to/your/ssl/key"
# SSL_CERT="/path/to/your/ssl/cert"
```
Install dependencies:
+1 -1
View File
@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 4.4.x | :white_check_mark: |
| 4.2.x | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
Regular → Executable
View File
Regular → Executable
+1 -1
View File
@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16
image: postgres:15
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
Regular → Executable
+1 -1
View File
@@ -6,7 +6,7 @@ services:
- .env
environment:
POSTGRES_USER: ${POSTGRESQL_USER:-zipline}
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESQL_PASSWORD is required}
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESSQL_PASSWORD is required}
POSTGRES_DB: ${POSTGRESQL_DB:-zipline}
volumes:
- pgdata:/var/lib/postgresql/data
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/server
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env sh
set -e
cd ${ZIPLINE_ROOT:-/zipline}
exec node --enable-source-maps build/ctl "$@"
+13
View File
@@ -0,0 +1,13 @@
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,
});
+51 -44
View File
@@ -1,17 +1,15 @@
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);
@@ -22,53 +20,63 @@ const gitignorePatterns = gitignoreContent
.filter((line) => line.trim() && !line.startsWith('#'))
.map((pattern) => pattern.trim());
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,
export default tseslint.config(
{ ignores: gitignorePatterns },
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
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: {
react: reactPlugin,
'react-hooks': reactHooksPlugin,
prettier,
'unused-imports': unusedImports,
prettier: prettier,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
rules: {
...prettierConfig.rules,
...reactPlugin.configs.recommended.rules,
'prettier/prettier': ['error', {}, { fileInfoOptions: { withNodeModules: false } }],
...prettierConfig.rules,
'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',
@@ -79,29 +87,28 @@ export default defineConfig(
'react/react-in-jsx-scope': 'off',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'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',
'react/display-name': '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: 'detect',
},
},
},
);
Regular → Executable
View File
Regular → Executable
+60 -67
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.4.2",
"version": "4.3.1",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
@@ -12,119 +12,112 @@
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "tsx scripts/validate.ts",
"openapi": "tsx scripts/openapi.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
"db:migrate": "prisma migrate dev --create-only",
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
"docker:compose:dev:build": "docker compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"@fastify/swagger": "^9.6.1",
"@mantine/charts": "^8.3.9",
"@mantine/code-highlight": "^8.3.9",
"@mantine/core": "^8.3.9",
"@mantine/dates": "^8.3.9",
"@mantine/dropzone": "^8.3.9",
"@mantine/form": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantine/modals": "^8.3.9",
"@mantine/notifications": "^8.3.9",
"@fastify/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",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@smithy/node-http-handler": "^4.1.1",
"@tabler/icons-react": "^3.35.0",
"archiver": "^7.0.1",
"@tabler/icons-react": "^3.34.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.12.1",
"asciinema-player": "^3.10.0",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.2",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.19",
"detect-browser": "^5.3.0",
"dotenv": "^17.2.3",
"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",
"fast-glob": "^3.3.3",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fastify": "^5.5.0",
"fastify-plugin": "^5.0.1",
"fflate": "^0.8.2",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.33.0",
"katex": "^0.16.27",
"mantine-datatable": "^8.3.9",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^8.2.0",
"ms": "^2.1.3",
"multer": "2.0.2",
"otplib": "^12.0.1",
"pg": "^8.16.3",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"react-router-dom": "^7.8.2",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
"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"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"@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",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-unused-imports": "^4.3.0",
"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",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"prettier": "^3.6.2",
"sass": "^1.92.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"tsup": "^8.5.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
"packageManager": "pnpm@10.12.1"
}
Generated Regular → Executable
+2159 -2332
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
View File
Regular → Executable
View File
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;
@@ -1,11 +0,0 @@
/*
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;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';
@@ -1,6 +0,0 @@
-- 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;
@@ -1,23 +0,0 @@
/*
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;
Regular → Executable
+20 -42
View File
@@ -1,7 +1,7 @@
generator client {
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
@@ -20,7 +20,6 @@ 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")
@@ -31,21 +30,19 @@ 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?
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")
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")
urlsRoute String @default("/go")
urlsLength Int @default(6)
@@ -66,7 +63,7 @@ model Zipline {
featuresMetricsShowUserSpecific Boolean @default(true)
featuresVersionChecking Boolean @default(true)
featuresVersionAPI String @default("https://zipline-version.diced.sh")
featuresVersionAPI String @default("https://zipline-version.diced.sh")
invitesEnabled Boolean @default(true)
invitesLength Int @default(6)
@@ -109,10 +106,7 @@ model Zipline {
mfaTotpEnabled Boolean @default(false)
mfaTotpIssuer String @default("Zipline")
mfaPasskeysEnabled Boolean @default(false)
mfaPasskeysRpID String?
mfaPasskeysOrigin String?
mfaPasskeys Boolean @default(false)
ratelimitEnabled Boolean @default(true)
ratelimitMax Int @default(10)
@@ -146,7 +140,7 @@ model Zipline {
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
domains String[] @default([])
}
model User {
@@ -163,7 +157,7 @@ model User {
totpSecret String?
passkeys UserPasskey[]
sessions UserSession[]
sessions String[]
quota UserQuota?
@@ -191,18 +185,6 @@ 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())
@@ -312,16 +294,12 @@ 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
}
+2 -8
View File
@@ -1,6 +1,4 @@
type StepCommand = string | (() => void | Promise<void>);
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
export function step(name: string, command: string, condition: () => boolean = () => true) {
return {
name,
command,
@@ -37,11 +35,7 @@ export async function run(name: string, ...steps: Step[]) {
try {
log(`> Running step "${name}/${step.name}"...`);
if (typeof step.command === 'string') {
execSync(step.command, { stdio: 'inherit' });
} else {
await step.command();
}
execSync(step.command, { stdio: 'inherit' });
} catch {
console.error(`x Step "${name}/${step.name}" failed.`);
process.exit(1);
-110
View File
@@ -1,110 +0,0 @@
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),
);
+2 -24
View File
@@ -1,31 +1,10 @@
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
import { 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';
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,
@@ -58,9 +37,8 @@ export default function Root({
},
centered: true,
}}
modals={contextModals}
>
<Notifications position='top-center' zIndex={10000000} />
<Notifications zIndex={10000000} />
<Outlet />
</ModalsProvider>
</ThemeProvider>
-2
View File
@@ -3,8 +3,6 @@
<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>
-3
View File
@@ -1,11 +1,8 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
export default function FourOhFour() {
useTitle('404');
return (
<Center h='100vh'>
<Stack>
+277 -122
View File
@@ -1,53 +1,61 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
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 { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/hooks/useLogin';
import useObjectState from '@/lib/hooks/useObjectState';
import { useTitle } from '@/lib/hooks/useTitle';
import { authenticateWeb } from '@/lib/passkey';
import {
Anchor,
Box,
Button,
Center,
Divider,
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
PinInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { notifications, showNotification } from '@mantine/notifications';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogleFilled,
IconCheck,
IconCircleKeyFilled,
IconKey,
IconShieldQuestion,
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login() {
useTitle('Login');
const location = useLocation();
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin();
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
const navigate = useNavigate();
const { data: config, error: configError, isLoading: configLoading } = useSWR('/api/server/public');
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const showLocalLogin =
query.get('local') === 'true' ||
@@ -61,122 +69,202 @@ 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]);
const [totp, setTotp] = useObjectState({
open: false,
disabled: false,
error: '',
pin: '',
});
useEffect(() => {
if (passkeyErrored) {
setTimeout(() => {
setPasskeyErrored(false);
}, 3000);
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'),
},
});
showNotification({
title: 'Error while authenticating with passkey',
message: 'Please try again',
color: 'red',
icon: <IconX size='1rem' />,
});
}
}, [passkeyErrored]);
useEffect(() => {
if (user) navigate('/dashboard');
if (config?.firstSetup) navigate('/auth/setup');
}, [user, config, navigate]);
}, [config]);
const handleLoginSubmit = async (values: any, code?: string) => {
setTotp({ disabled: true, error: '' });
if (configLoading) return <LoadingOverlay visible />;
const { data, error } = await fetchApi(
'/api/auth/login',
'POST',
{ ...values, code },
{ 'x-zipline-client': webClient },
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
if (error) {
if (ApiError.check(error, 1044)) {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else {
setTotp('error', error.error || 'Login failed');
}
setTotp('disabled', false);
} else if (data?.totp) {
setTotp({ open: true, disabled: false });
} else {
showNotification({
message: 'Logging in...',
icon: <IconCheck size='1rem' />,
autoClose: 700,
});
mutate(data);
}
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
const hasBg = !!config.website.loginBackground;
if (!config) return <LoadingOverlay visible />;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
<TotpModal
state={totp}
onPinChange={(val) => setTotp('pin', val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
form.reset();
}}
/>
<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.
<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>
</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>
)}
<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>
<Center h='100vh'>
{hasBg && (
{config.website.loginBackground && (
<Image
src={config.website.loginBackground}
pos='absolute'
inset={0}
w='100%'
h='100%'
fit='cover'
style={{ filter: config.website.loginBackgroundBlur ? 'blur(10px)' : undefined }}
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)' }),
}}
/>
)}
@@ -185,29 +273,96 @@ export default function Login() {
p='xl'
shadow='xl'
withBorder
pos='relative'
style={{
backgroundColor: hasBg ? 'transparent' : undefined,
backdropFilter: hasBg ? 'blur(35px)' : undefined,
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
}}
>
<Title order={1} ta='center' mb='md'>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
<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>
<Stack>
{showLocalLogin && (
<LocalLogin
form={form}
onSubmit={handleLoginSubmit}
loading={totp.disabled}
hasBackground={hasBg}
/>
{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' />
)}
<Divider label='or' />
{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>
)}
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
{config.features.userRegistration && (
<Button
component={Link}
to='/auth/register'
size='md'
fullWidth
variant='outline'
leftSection={<IconUserPlus size='1rem' />}
>
Sign up
</Button>
)}
<Group grow>
{config.oauthEnabled.discord && (
+35
View File
@@ -0,0 +1,35 @@
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 />;
}
+8 -22
View File
@@ -22,8 +22,6 @@ import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
export function Component() {
useTitle('Register');
@@ -66,12 +64,9 @@ export function Component() {
tos: false,
},
validate: {
username: (value) => (value.length >= 1 ? null : 'Username is required'),
password: (value) => (value.length >= 1 ? null : 'Password is required'),
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
useEffect(() => {
@@ -101,21 +96,14 @@ export function Component() {
return;
}
const { data, error } = await fetchApi(
'/api/auth/register',
'POST',
{
username,
password,
code,
},
{
'x-zipline-client': JSON.stringify(getWebClient()),
},
);
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
if (error) {
if (ApiError.check(error, 1039)) {
if (error.error === 'Username is taken') {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
@@ -226,7 +214,6 @@ export function Component() {
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
@@ -238,7 +225,6 @@ export function Component() {
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='new-password'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
+2 -7
View File
@@ -62,12 +62,9 @@ export function Component() {
password: '',
},
validate: {
username: (value) => (value.length >= 1 ? null : 'Username is required'),
password: (value) => (value.length >= 1 ? null : 'Password is required'),
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -183,14 +180,12 @@ export function Component() {
<TextInput
label='Username'
placeholder='Enter a username...'
autoComplete='username'
{...form.getInputProps('username')}
/>
<PasswordInput
label='Password'
placeholder='Enter a password...'
autoComplete='new-password'
{...form.getInputProps('password')}
/>
</Stack>
@@ -1,10 +0,0 @@
import DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
return <DashboardServerActions />;
}
Component.displayName = 'Dashboard/Admin/Actions';
+18 -125
View File
@@ -1,22 +1,8 @@
import { type Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
ActionIcon,
Anchor,
Breadcrumbs,
Card,
Container,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconFolder, IconUpload } from '@tabler/icons-react';
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { IconUpload } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
import { Link, Params, useLoaderData } from 'react-router-dom';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
@@ -30,72 +16,12 @@ export async function loader({ params }: { params: Params<string> }) {
};
}
function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
return (
<Link to={`/folder/${folder.id}`} style={{ textDecoration: 'none' }}>
<Card withBorder shadow='sm' radius='sm' style={{ cursor: 'pointer' }}>
<Card.Section withBorder inheritPadding py='xs'>
<Group gap='xs'>
<IconFolder size='1.2rem' />
<Text fw={500}>{folder.name}</Text>
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs'>
<Stack gap={2}>
<Text size='xs' c='dimmed'>
{folder._count?.files ?? 0} files
</Text>
{(folder._count?.children ?? 0) > 0 && (
<Text size='xs' c='dimmed'>
{folder._count?.children} subfolders
</Text>
)}
</Stack>
</Card.Section>
</Card>
</Link>
);
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [];
let current = folder.parent as Partial<Folder> | undefined;
while (current && current.public) {
items.unshift({ id: current.id!, name: current.name!, public: true });
current = current.parent as Partial<Folder> | undefined;
}
items.push({ id: folder.id!, name: folder.name!, public: true });
return items;
};
const breadcrumbs = buildBreadcrumbs();
const children = (folder.children ?? []) as Partial<Folder>[];
return (
<>
<Container my='lg'>
{breadcrumbs.length > 1 && (
<Breadcrumbs mb='md'>
{breadcrumbs.map((item, index) => (
<Anchor
key={item.id}
onClick={() => navigate(`/folder/${item.id}`)}
style={{ cursor: 'pointer' }}
fw={index === breadcrumbs.length - 1 ? 600 : 400}
>
{item.name}
</Anchor>
))}
</Breadcrumbs>
)}
<Group>
<Title order={1}>{folder.name}</Title>
@@ -108,54 +34,21 @@ export function Component() {
)}
</Group>
{children.length > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Subfolders
</Title>
<SimpleGrid
cols={{
base: 1,
lg: 4,
md: 3,
sm: 2,
}}
spacing='md'
>
{children.map((child) => (
<PublicFolderCard key={child.id} folder={child} />
))}
</SimpleGrid>
</>
)}
{(folder.files?.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
</Title>
<SimpleGrid
cols={{
base: 1,
lg: 3,
md: 2,
}}
spacing='md'
>
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
</>
)}
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
<Text c='dimmed' mt='md'>
This folder is empty.
</Text>
)}
<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>
</Container>
</>
);
-3
View File
@@ -2,7 +2,6 @@ import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { type Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { useTitle } from '@/lib/hooks/useTitle';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { data, Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
@@ -28,8 +27,6 @@ export function Component() {
revalidateIfStale: false,
});
useTitle(`Upload to ${folder.name ?? 'folder'}`);
return (
<>
<Container my='lg'>
+2 -5
View File
@@ -26,7 +26,6 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
import { useTitle } from '@/lib/hooks/useTitle';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
@@ -56,8 +55,6 @@ export default function ViewFileId() {
const [passwordError, setPasswordError] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
useTitle(file.originalName ?? file.name ?? 'View File');
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
@@ -101,7 +98,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.originalName ?? file.name}</Text>
<Text c='dimmed'>{file.name}</Text>
<Group>
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
@@ -167,7 +164,7 @@ export default function ViewFileId() {
<Group justify='space-between' mb='sm'>
<Group>
<Text size='lg' fw={700} display='flex'>
{file.originalName ?? file.name}{' '}
{file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>
+3 -2
View File
@@ -6,6 +6,7 @@ import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
@@ -37,6 +38,7 @@ export const router = createBrowserRouter([
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
@@ -57,7 +59,7 @@ export const router = createBrowserRouter([
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{
path: 'upload',
@@ -80,7 +82,6 @@ export const router = createBrowserRouter([
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
{
path: 'users',
children: [
+2 -2
View File
@@ -265,11 +265,11 @@ export async function render(
: ''
}
<title>${file.originalName ?? file.name}</title>
<title>${file.name}</title>
`;
return {
html,
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
meta: `${meta}\n${createZiplineSsr(data)}`,
};
}
View File
-56
View File
@@ -1,56 +0,0 @@
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}
/>
);
}
View File
Regular → Executable
+6 -10
View File
@@ -41,7 +41,6 @@ import {
IconRefreshDot,
IconSettingsFilled,
IconShieldLockFilled,
IconStopwatch,
IconTags,
IconUpload,
IconUsersGroup,
@@ -51,7 +50,6 @@ import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import { useLogout } from '@/lib/hooks/useLogout';
type NavLinks = {
label: string;
@@ -128,12 +126,6 @@ const navLinks: NavLinks[] = [
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
},
{
label: 'Actions',
icon: <IconStopwatch size='1rem' />,
active: (path: string) => path === '/dashboard/admin/actions',
href: '/dashboard/admin/actions',
},
{
label: 'Users',
icon: <IconUsersGroup size='1rem' />,
@@ -159,7 +151,6 @@ export default function Layout() {
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const logout = useLogout();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
@@ -306,7 +297,12 @@ export default function Layout() {
)}
<Menu.Divider />
<Menu.Item color='red' leftSection={<IconLogout size='1rem' />} onClick={logout}>
<Menu.Item
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
to='/auth/logout'
>
Logout
</Menu.Item>
</Menu.Dropdown>
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+33 -61
View File
@@ -1,10 +1,9 @@
import { File } from '@/lib/db/models/file';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/hooks/useObjectState';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { mutateFiles } from '../actions';
export default function EditFileDetailsModal({
@@ -16,42 +15,14 @@ export default function EditFileDetailsModal({
file: File | null;
onClose: () => void;
}) {
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,
});
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 [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);
const handleRemovePassword = async () => {
if (!file.password) return;
@@ -87,12 +58,12 @@ export default function EditFileDetailsModal({
name?: string;
} = {};
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();
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();
const passwordTrimmed = formData.password?.trim();
const passwordTrimmed = password?.trim();
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
@@ -114,19 +85,29 @@ export default function EditFileDetailsModal({
onClose();
setFormData('password', null);
setPassword(null);
mutateFiles();
}
};
useEffect(() => {
if (open) {
setName(file.name ?? '');
setMaxViews(file.maxViews ?? null);
setPassword(file.password ? '' : null);
setOriginalName(file.originalName ?? null);
setType(file.type ?? null);
}
}, [open, file]);
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
description='Rename the file.'
value={formData.name}
onChange={(event) => setFormData('name', event.currentTarget.value.trim())}
value={name}
onChange={(event) => setName(event.currentTarget.value.trim())}
/>
<NumberInput
@@ -134,20 +115,17 @@ 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={formData.maxViews || ''}
onChange={(value) => setFormData('maxViews', value === '' ? null : Number(value))}
value={maxViews || ''}
onChange={(value) => setMaxViews(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={formData.originalName ?? ''}
value={originalName ?? ''}
onChange={(event) =>
setFormData(
'originalName',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
}
/>
@@ -159,12 +137,9 @@ export default function EditFileDetailsModal({
doing, this can mess with how Zipline renders specific file types.
</>
}
value={formData.type ?? ''}
value={type ?? ''}
onChange={(event) =>
setFormData(
'type',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
setType(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
}
c='red'
/>
@@ -184,13 +159,10 @@ export default function EditFileDetailsModal({
<PasswordInput
label='Password'
description='Set a password for this file. Leave blank to disable password protection.'
value={formData.password ?? ''}
value={password ?? ''}
autoComplete='off'
onChange={(event) =>
setFormData(
'password',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
}
leftSection={<IconKey size='1rem' />}
/>
+42 -55
View File
@@ -1,12 +1,10 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -31,7 +29,6 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
@@ -49,9 +46,8 @@ import {
IconTrashFilled,
IconUpload,
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import useSWR, { mutate } from 'swr';
import DashboardFileType from '../DashboardFileType';
import {
addToFolder,
@@ -92,45 +88,36 @@ export default function FileModal({
setOpen,
file,
reduce,
user,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const [editFileOpen, setEditFileOpen] = useState(false);
const { data: folders } = useFolders(user);
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const folderCombobox = useCombobox();
const [search, setSearch] = useState('');
const handleAdd = async (value: string) => {
if (value === '$create') {
await createFolderAndAdd(file!, search.trim());
createFolderAndAdd(file!, search.trim());
} else {
await addToFolder(file!, value);
addToFolder(file!, value);
}
};
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
user ? `/api/users/${user}/tags` : '/api/user/tags',
);
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
const tagsCombobox = useCombobox();
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []);
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
@@ -180,6 +167,14 @@ export default function FileModal({
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
useEffect(() => {
if (file) {
setValue(file.tags?.map((x) => x.id) ?? []);
} else {
setValue([]);
}
}, [file]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
@@ -239,15 +234,15 @@ export default function FileModal({
</Title>
<Combobox
zIndex={90000}
withinPortal={false}
store={tagsCombobox}
onOptionSubmit={handleValueSelect}
withinPortal={false}
>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
pointer
onClick={() => tagsCombobox.openDropdown()}
onClick={() => tagsCombobox.toggleDropdown()}
>
<Pill.Group>
{values.length > 0 ? (
@@ -259,14 +254,9 @@ export default function FileModal({
<Combobox.EventsTarget>
<PillsInput.Field
type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => {
if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
if (event.key === 'Backspace') {
event.preventDefault();
handleValueRemove(value[value.length - 1]);
}
@@ -295,7 +285,9 @@ export default function FileModal({
</Combobox.Option>
))
) : (
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
<Combobox.Option value='no-tags' disabled>
No tags found, create one outside of this menu.
</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
@@ -318,8 +310,8 @@ export default function FileModal({
</Button>
) : (
<Combobox
withinPortal={false}
store={folderCombobox}
withinPortal={false}
onOptionSubmit={(value) => handleAdd(value)}
>
<Combobox.Target>
@@ -331,17 +323,11 @@ export default function FileModal({
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onClick={() => folderCombobox.openDropdown()}
onFocus={() => folderCombobox.openDropdown()}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch('');
setSearch(search || '');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
@@ -349,18 +335,24 @@ export default function FileModal({
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<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?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
) : null
}
/>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
)}
@@ -406,11 +398,6 @@ 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)}
View File
View File
+2 -2
View File
@@ -6,12 +6,12 @@ import FileModal from './FileModal';
import styles from './index.module.css';
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
const [open, setOpen] = useState(false);
return (
<>
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
<DashboardFileType key={file.id} file={file} />
</Card>
View File
Regular → Executable
+34 -35
View File
@@ -1,4 +1,3 @@
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';
@@ -28,14 +27,10 @@ export function downloadFile(file: File) {
window.open(`/raw/${file.name}?download=true`, '_blank');
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = raw
? `${domain}/raw/${file.name}`
: file.url
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
clipboard.copy(url);
@@ -111,33 +106,32 @@ export async function favoriteFile(file: File) {
mutateFiles();
}
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' />,
});
}
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' />,
});
}
});
mutateFolder();
mutateFolders();
mutateFiles();
return undefined;
}
export async function removeFromFolder(file: File) {
@@ -166,7 +160,7 @@ export async function removeFromFolder(file: File) {
});
}
mutateFolder();
mutateFolders();
mutateFiles();
}
@@ -197,7 +191,7 @@ export async function addToFolder(file: File, folderId: string | null) {
});
}
mutateFolder();
mutateFolders();
mutateFiles();
}
@@ -229,7 +223,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
});
}
mutateFolder();
mutateFolders();
mutateFiles();
}
@@ -237,3 +231,8 @@ 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');
}
@@ -1,28 +0,0 @@
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>
);
}
+8 -42
View File
@@ -1,20 +1,10 @@
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 { 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 { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import { IconDeviceSdCard, IconEyeFilled, IconFiles, 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'));
@@ -23,9 +13,6 @@ 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>
@@ -60,18 +47,9 @@ export default function DashboardHome() {
</Text>
) : null}
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
<Title order={2}>Recent files</Title>
<Button
variant='outline'
size='compact-xs'
component={Link}
to='/dashboard/files'
leftSection={<IconFiles size='1rem' />}
>
View all files
</Button>
</Group>
<Title order={2} mt='md' mb='xs'>
Recent files
</Title>
{recentLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
@@ -93,21 +71,9 @@ export default function DashboardHome() {
</Text>
)}
<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>
<Title order={2} mt='md'>
Stats
</Title>
<Text size='sm' c='dimmed' mb='xs'>
These statistics are based on your uploads only.
</Text>
+124
View File
@@ -0,0 +1,124 @@
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>
</>
);
}
@@ -1,122 +0,0 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { IncompleteFileStatus } from '@/prisma/client';
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { ReactNode } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '.';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
<Badge variant='light' color='gray'>
Pending
</Badge>
),
PROCESSING: (
<Badge variant='light' color='yellow'>
Processing
</Badge>
),
COMPLETE: (
<Badge variant='light' color='green'>
Complete
</Badge>
),
FAILED: (
<Badge variant='light' color='red'>
Failed
</Badge>
),
};
export default function PendingFilesModal({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
>('/api/user/files/incomplete');
const handleDelete = async (incompleteFile: IncompleteFile) => {
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
'/api/user/files/incomplete',
'DELETE',
{
id: [incompleteFile.id],
},
);
if (error) {
showNotification({
title: 'Error',
message: `Failed to delete pending file: ${error.error}`,
color: 'red',
icon: <IconFileDots size='1rem' />,
});
} else {
showNotification({
message: 'Cleared Pending File!',
color: 'green',
icon: <IconTrashFilled size='1rem' />,
});
}
mutate();
};
return (
<Modal opened={modals.pending} onClose={() => setModals('pending', false)}>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
<Group justify='space-between'>
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
{badgeMap[incompleteFile.status]}
</Group>
<Group justify='space-between'>
<Text size='xs' c='dimmed' fw='bold'>
{incompleteFile.metadata.file.type}
</Text>
<Text size='xs' c='dimmed'>
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
</Text>
</Group>
<Text size='xs' c='dimmed'>
{incompleteFile.id}
</Text>
<Group justify='space-between'>
<Button
fullWidth
size='compact-sm'
mt='xs'
color='red'
variant='light'
onClick={() => handleDelete(incompleteFile)}
leftSection={<IconTrashFilled size='1rem' />}
>
Clear
</Button>
</Group>
</Card>
))}
{incompleteFiles?.length === 0 && (
<Paper withBorder px='sm' py='xs'>
No pending files
</Paper>
)}
</Stack>
</Modal>
);
}
@@ -1,99 +0,0 @@
import { FieldSettings, useFileTableSettingsStore } from '@/lib/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';
export const NAMES = {
name: 'Name',
originalName: 'Original Name',
tags: 'Tags',
type: 'Type',
size: 'Size',
createdAt: 'Created At',
favorite: 'Favorite',
views: 'Views',
};
function SortableTableField({ item }: { item: FieldSettings }) {
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
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>
);
}
+10 -13
View File
@@ -69,23 +69,20 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
});
}
export async function bulkFavorite(ids: string[], favorite: boolean) {
const text = favorite ? 'favorite' : 'unfavorite';
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
export async function bulkFavorite(ids: string[]) {
modals.openConfirmModal({
centered: true,
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
labels: {
cancel: 'Cancel',
confirm: `${textcaps}`,
confirm: 'Favorite',
},
confirmProps: { color: 'yellow' },
onConfirm: async () => {
notifications.show({
title: `${textcaps}ing files`,
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
title: 'Favoriting files',
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
loading: true,
id: 'bulk-favorite',
@@ -99,13 +96,13 @@ export async function bulkFavorite(ids: string[], favorite: boolean) {
{
files: ids,
favorite,
favorite: true,
},
);
if (error) {
notifications.update({
title: 'Error while modifying files',
title: 'Error while favoriting files',
message: error.error,
color: 'red',
icon: <IconStarsOff size='1rem' />,
@@ -115,8 +112,8 @@ export async function bulkFavorite(ids: string[], favorite: boolean) {
});
} else if (data) {
notifications.update({
title: `${textcaps}d files`,
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
title: 'Favorited files',
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
icon: <IconStarsFilled size='1rem' />,
id: 'bulk-favorite',
+10 -70
View File
@@ -1,44 +1,19 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import useObjectState from '@/lib/hooks/useObjectState';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
import {
IconDots,
IconFileDots,
IconFileUpload,
IconGridPatternFilled,
IconTableOptions,
IconTags,
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from './tags/TagsModal';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
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;
};
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 { Link } from 'react-router-dom';
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [modals, setModals] = useObjectState<DashboardFilesModals>({
table: false,
idSearch: false,
tags: false,
pending: false,
});
return (
<>
<TagsModal modals={modals} setModals={setModals} />
<PendingFilesModal modals={modals} setModals={setModals} />
<Group>
<Title>Files</Title>
@@ -50,43 +25,8 @@ export default function DashboardFiles() {
</Link>
</Tooltip>
<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>
<TagsButton />
<PendingFilesButton />
<GridTableSwitcher type='files' />
</Group>
@@ -98,7 +38,7 @@ export default function DashboardFiles() {
<Files />
</>
) : (
<FileTable modals={modals} setModals={setModals} />
<FileTable />
)}
</>
);
View File
View File
View File
@@ -2,24 +2,17 @@ import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '..';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
export default function TagsModals({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
export default function TagsButton() {
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -54,8 +47,8 @@ export default function TagsModals({
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
<Modal
opened={modals.tags}
onClose={() => setModals('tags', false)}
opened={open}
onClose={() => setOpen(false)}
title={
<Group>
<Title>Tags</Title>
@@ -101,6 +94,12 @@ export default function TagsModals({
)}
</Stack>
</Modal>
<Tooltip label='View tags'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconTags size='1rem' />
</ActionIcon>
</Tooltip>
</>
);
}
-2
View File
@@ -19,7 +19,6 @@ type ApiPaginationOptions = {
| 'favorite';
order?: 'asc' | 'desc';
id?: string;
folderId?: string;
search?: {
field?: string;
query: string;
@@ -46,7 +45,6 @@ const fetcher = async (
if (options.search.field) searchParams.append('searchField', options.search.field);
searchParams.append('searchQuery', options.search.query);
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
View File
@@ -1,14 +1,10 @@
import RelativeDate from '@/components/RelativeDate';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -36,20 +32,18 @@ import {
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
IconStar,
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { lazy, useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { DashboardFilesModals } from '..';
import TableEditModal, { NAMES } from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
import { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
@@ -60,6 +54,13 @@ type ReducerQuery = {
const PER_PAGE_OPTIONS = [10, 20, 50];
const NAMES = {
name: 'Name',
originalName: 'Original name',
type: 'Type',
id: 'ID',
};
function SearchFilter({
setSearchField,
searchQuery,
@@ -87,8 +88,8 @@ function SearchFilter({
return (
<TextInput
label={NAMES[field as keyof typeof NAMES]}
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
label={NAMES[field]}
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
value={searchQuery[field]}
onChange={onChange}
size='sm'
@@ -113,7 +114,7 @@ function TagsFilter({
const combobox = useCombobox();
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
const [value, setValue] = useState(() => searchQuery.tags.split(','));
const [value, setValue] = useState(searchQuery.tags.split(','));
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
@@ -178,28 +179,13 @@ function TagsFilter({
);
}
export default function FileTable({
id,
folderId,
modals,
setModals,
}: {
id?: string;
folderId?: string;
modals?: Partial<DashboardFilesModals>;
setModals?: UpdateFn<DashboardFilesModals>;
}) {
export default function FileTable({ id }: { id?: string }) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fields = useFileTableSettingsStore((state) => state.fields);
const { data: folders } = useFolders();
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
@@ -216,23 +202,36 @@ export default function FileTable({
| 'favorite'
>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(
_state: { name: string; originalName: string; type: string; tags: string; id: string },
action: { field: keyof ReducerQuery['state']; query: string },
) => ({
name: action.field === 'name' ? action.query : '',
originalName: action.field === 'originalName' ? action.query : '',
type: action.field === 'type' ? action.query : '',
tags: action.field === 'tags' ? action.query : '',
id: action.field === 'id' ? action.query : '',
}),
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
return {
...state,
[action.field]: action.query,
};
},
{ name: '', originalName: '', type: '', tags: '', id: '' },
);
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
useEffect(() => {
if (idSearchOpen) return;
setSearchQuery({
field: 'id',
query: '',
});
}, [idSearchOpen]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const combobox = useCombobox();
@@ -257,7 +256,6 @@ export default function FileTable({
sort,
order,
id,
folderId,
...(searchQuery[searchField].trim() !== '' && {
search: {
field: searchField,
@@ -266,112 +264,26 @@ export default function FileTable({
}),
});
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
const selectedFile = selectedFileId
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
: null;
useEffect(() => {
if (data && selectedFile) {
const file = data.page.find((x) => x.id === selectedFile.id);
const FIELDS = [
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
accessor: 'originalName',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='originalName'
/>
),
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
},
{
accessor: 'tags',
sortable: false,
width: 200,
render: (file: File) => (
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
<Flex gap='sm'>
{file.tags!.map((tag) => (
<TagPill tag={tag} key={tag.id} />
))}
</Flex>
</ScrollArea>
),
filter: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file: File) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
},
{
accessor: 'views',
sortable: true,
render: (file: File) => file.views,
},
{
accessor: 'id',
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
},
];
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any));
columns.sort((a, b) => {
const aIndex = fields.findIndex((f) => f.field === a.accessor);
const bIndex = fields.findIndex((f) => f.field === b.accessor);
return aIndex - bIndex;
});
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
if (file) {
setSelectedFile(file);
}
}
}, [data]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) {
if (field !== searchField) {
setSearchQuery({
field,
query: '',
});
}
}
}, [searchField]);
return (
<>
@@ -381,14 +293,22 @@ export default function FileTable({
if (!open) setSelectedFile(null);
}}
file={selectedFile}
user={id}
/>
{modals && setModals && modals.table && (
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
)}
<Box>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
<Collapse in={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
<Text size='sm' c='dimmed' mb='xs'>
@@ -415,54 +335,48 @@ export default function FileTable({
variant='outline'
color='yellow'
leftSection={<IconStar size='1rem' />}
onClick={() =>
bulkFavorite(
selectedFiles.map((x) => x.id),
!unfavoriteAll,
)
}
onClick={() => bulkFavorite(selectedFiles.map((x) => x.id))}
>
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
{selectedFiles.length > 1 ? 's' : ''}
Favorite {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
</Button>
{!id && (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => handleAddFolder(value)}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={folderSearch}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setFolderSearch(event.currentTarget.value);
}}
onClick={() => {
combobox.openDropdown();
setFolderSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setFolderSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setFolderSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => handleAddFolder(value)}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={folderSearch}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setFolderSearch(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
setFolderSearch(folderSearch || '');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions folderOptions={folderOptions} searchValue={folderSearch} />
</Combobox.Dropdown>
</Combobox>
)}
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
.map((f) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Group>
<Button
@@ -479,35 +393,99 @@ export default function FileTable({
</Paper>
</Collapse>
{modals && setModals && modals.idSearch && (
<Collapse in={modals.idSearch}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
)}
<Collapse in={idSearchOpen}>
<Paper withBorder p='sm' my='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
{/*@ts-ignore*/}
{/* @ts-ignore */}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
noRecordsText='No files'
columns={[
...columns,
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
accessor: 'tags',
sortable: false,
width: 200,
render: (file) => (
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
<Flex gap='sm'>
{file.tags!.map((tag) => (
<TagPill tag={tag} key={tag.id} />
))}
</Flex>
</ScrollArea>
),
filter: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
},
{
accessor: 'views',
sortable: true,
render: (file) => file.views,
},
{
accessor: 'id',
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
},
{
accessor: 'actions',
textAlign: 'right',
@@ -580,7 +558,7 @@ export default function FileTable({
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record.id)}
onCellClick={({ record }) => setSelectedFile(record)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
@@ -1,4 +1,3 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Button,
Center,
@@ -12,30 +11,36 @@ import {
Text,
Title,
} from '@mantine/core';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useState } from 'react';
import { Link } from 'react-router-dom';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const { data, isLoading } = useApiPagination({
page,
perpage,
id,
folderId,
});
useEffect(() => {
if (data?.pages) {
setCachedPages(data.pages);
}
}, [data?.pages]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, data?.total ?? 0);
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 1;
return (
<>
@@ -54,7 +59,7 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} id={id} />
<DashboardFile file={file} />
</Suspense>
))
) : (
@@ -1,3 +1,4 @@
import { mutateFolders } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import type { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
@@ -6,8 +7,6 @@ import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { IconPencil } from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutateFolder } from '../actions';
export default function EditFolderNameModal({
folder,
onClose,
@@ -29,7 +28,7 @@ export default function EditFolderNameModal({
const onSubmit = async (values: typeof form.values) => {
if (!folder) return;
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder?.id}`,
'PATCH',
{
@@ -43,10 +42,10 @@ export default function EditFolderNameModal({
message: error.error,
});
} else {
mutateFolder();
mutateFolders();
showNotification({
title: 'Folder name updated',
message: 'Folder name has been updated successfully to ' + data?.name,
message: 'Folder name has been updated successfully to ' + name,
});
onClose();
}
View File
+26 -76
View File
@@ -5,10 +5,7 @@ import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconDots,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
IconFiles,
IconLock,
IconLockOpen,
IconPencil,
@@ -17,115 +14,73 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { useState } from 'react';
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from './actions';
import DeleteFolderModal from './modals/DeleteFolderModal';
import EditFolderNameModal from './modals/EditFolderNameModal';
import MoveFolderModal from './modals/MoveFolderModal';
import ViewFilesModal from './modals/ViewFilesModal';
import { withoutPropagation } from './views/FolderTableView';
import ViewFilesModal from './ViewFilesModal';
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from './actions';
import EditFolderNameModal from './EditFolderNameModal';
export default function FolderCard({
folder,
onNavigate,
}: {
folder: Folder;
onNavigate?: (folderId: string | null) => void;
}) {
export default function FolderCard({ folder }: { folder: Folder }) {
const clipboard = useClipboard();
const [viewOpen, setViewOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const childrenCount = folder._count?.children ?? 0;
const filesCount = folder._count?.files ?? folder.files?.length ?? 0;
return (
<>
<ViewFilesModal opened={viewOpen} onClose={() => setViewOpen(false)} folder={folder} />
<EditFolderNameModal folder={folder} opened={editOpen} onClose={() => setEditOpen(false)} />
<MoveFolderModal folder={folder} opened={moveOpen} onClose={() => setMoveOpen(false)} />
<DeleteFolderModal opened={deleteOpen} folder={folder} onClose={() => setDeleteOpen(false)} />
<Card withBorder shadow='sm' radius='sm' style={{ cursor: onNavigate ? 'pointer' : 'default' }}>
<Card.Section withBorder inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
<Card withBorder shadow='sm' radius='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group justify='space-between'>
<Group gap='xs'>
<IconFolder size='1rem' />
<Text fw={400}>
{folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank' onClick={(e) => e.stopPropagation()}>
{folder.name}
</Anchor>
) : (
folder.name
)}
</Text>
</Group>
<Text fw={400}>
{folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank'>
{folder.name}
</Anchor>
) : (
folder.name
)}
</Text>
<Menu withinPortal position='bottom-end' shadow='sm'>
<Group gap={2}>
<Menu.Target>
<ActionIcon variant='transparent' onClick={(e) => e.stopPropagation()}>
<ActionIcon variant='transparent'>
<IconDots size='1rem' />
</ActionIcon>
</Menu.Target>
</Group>
<Menu.Dropdown>
{onNavigate && (
<Menu.Item
leftSection={<IconFolderOpen size='1rem' />}
onClick={() => onNavigate(folder.id)}
>
Open Folder
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(true))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
<Menu.Item leftSection={<IconFiles size='1rem' />} onClick={() => setViewOpen(true)}>
View Files
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
onClick={() => editFolderVisibility(folder, !folder.public)}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
onClick={() => editFolderUploads(folder, !folder.allowUploads)}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditOpen(true))}
>
<Menu.Item leftSection={<IconPencil size='1rem' />} onClick={() => setEditOpen(true)}>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size='1rem' />}
disabled={!folder.public}
onClick={withoutPropagation(() => copyFolderUrl(folder, clipboard))}
onClick={() => copyFolderUrl(folder, clipboard)}
>
Copy URL
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={withoutPropagation(() => setDeleteOpen(true))}
onClick={() => deleteFolder(folder)}
>
Delete
</Menu.Item>
@@ -134,7 +89,7 @@ export default function FolderCard({
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs' onClick={() => onNavigate?.(folder.id)}>
<Card.Section inheritPadding py='xs'>
<Stack gap={1}>
<Text size='xs' c='dimmed'>
<b>Created:</b> <RelativeDate date={folder.createdAt} />
@@ -146,13 +101,8 @@ export default function FolderCard({
<b>Public:</b> {folder.public ? 'Yes' : 'No'}
</Text>
<Text size='xs' c='dimmed'>
<b>Files:</b> {filesCount}
<b>Files:</b> {folder.files!.length}
</Text>
{childrenCount > 0 && (
<Text size='xs' c='dimmed'>
<b>Subfolders:</b> {childrenCount}
</Text>
)}
</Stack>
</Card.Section>
</Card>
+43 -5
View File
@@ -3,11 +3,27 @@ import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteFolder(folder: Folder) {
modals.openConfirmModal({
centered: true,
title: `Delete ${folder.name}?`,
children: `Are you sure you want to delete ${folder.name}? This action cannot be undone.`,
labels: {
cancel: 'Cancel',
confirm: 'Delete',
},
confirmProps: { color: 'red' },
onConfirm: () => handleDeleteFolder(folder),
onCancel: modals.closeAll,
});
}
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
@@ -48,7 +64,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
});
}
mutateFolder(folder.id);
mutate('/api/user/folders');
}
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
@@ -76,11 +92,33 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
});
}
mutateFolder(folder.id);
mutate('/api/user/folders');
}
export async function mutateFolder(folderId?: string) {
if (folderId) return mutate(`/api/user/folders/${folderId}`);
async function handleDeleteFolder(folder: Folder) {
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'DELETE',
{
delete: 'folder',
},
);
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
if (error) {
notifications.show({
title: 'Failed to delete folder',
message: error.error,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder deleted',
message: `${data?.name} has been deleted`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
}
mutate('/api/user/folders');
}
+12 -178
View File
@@ -2,60 +2,20 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
import { useViewStore } from '@/lib/store/view';
import {
Alert,
Anchor,
Box,
Breadcrumbs,
Button,
Collapse,
CopyButton,
Divider,
Group,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tooltip } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconFolderPlus, IconHome, IconPlus, IconShare } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import FilesGridView from '../files/views/FilesGridView';
import FilesTableView from '../files/views/FilesTableView';
import { mutateFolder } from './actions';
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { mutate } from 'swr';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
export default function DashboardFolders() {
const view = useViewStore((state) => state.folders);
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(true);
const folderPath = useMemo(() => {
const pathname = location.pathname.replace('/dashboard/folders', '');
if (!pathname || pathname === '/') return [];
return pathname.split('/').filter(Boolean);
}, [location.pathname]);
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
const {
data: currentFolder,
error: currentFolderError,
isLoading,
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
const form = useForm({
initialValues: {
@@ -74,7 +34,6 @@ export default function DashboardFolders() {
{
name: values.name,
isPublic: values.isPublic,
parentId: currentFolderId ?? undefined,
},
);
@@ -84,71 +43,15 @@ export default function DashboardFolders() {
color: 'red',
});
} else {
mutateFolder();
mutate('/api/user/folders');
setOpen(false);
form.reset();
}
};
const navigateToFolder = useCallback(
(folderId: string | null) => {
if (folderId === null) {
navigate('/dashboard/folders');
} else {
const newPath = [...folderPath, folderId];
navigate(`/dashboard/folders/${newPath.join('/')}`);
}
},
[navigate, folderPath],
);
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [{ id: null, name: 'Root', path: '/dashboard/folders' }];
if (currentFolder) {
const path: Partial<Folder>[] = [];
let folder: Partial<Folder> | undefined | null = currentFolder;
while (folder) {
path.unshift(folder);
folder = folder.parent;
}
const folderIds: string[] = [];
for (const f of path) {
folderIds.push(f.id!);
items.push({
id: f.id!,
name: f.name!,
path: `/dashboard/folders/${folderIds.join('/')}`,
});
}
}
return items;
};
const breadcrumbs = buildBreadcrumbs();
useTitle(currentFolder ? `Folders ${SEPARATOR} ${currentFolder.name}` : 'Folders');
useEffect(() => {
if (!currentFolderId) return;
if (isLoading) return;
if (currentFolderError || !currentFolder) {
navigate('/dashboard/folders', { replace: true });
}
}, [currentFolderId, currentFolder, currentFolderError, isLoading]);
return (
<>
<Modal
centered
opened={open}
onClose={() => setOpen(false)}
title={currentFolderId ? 'Create a subfolder' : 'Create a folder'}
>
<Modal centered opened={open} onClose={() => setOpen(false)} title='Create a folder'>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='sm'>
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
@@ -168,85 +71,16 @@ export default function DashboardFolders() {
<Group>
<Title>Folders</Title>
<Button
variant='outline'
size='compact-sm'
leftSection={<IconPlus size='1rem' />}
onClick={() => setOpen(true)}
>
Create{currentFolderId ? ' Subfolder' : ' Folder'}
</Button>
<Tooltip label='Create a new folder'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconPlus size='1rem' />
</ActionIcon>
</Tooltip>
<GridTableSwitcher type='folders' />
</Group>
{breadcrumbs.length > 1 && (
<Breadcrumbs my='sm'>
{breadcrumbs.map((item, index) => (
<Anchor
key={item.id ?? 'root'}
onClick={() => navigate(item.path!)}
style={{ cursor: 'pointer' }}
fw={index === breadcrumbs.length - 1 ? 600 : 400}
>
{index === 0 ? <IconHome size='1rem' /> : item.name}
</Anchor>
))}
</Breadcrumbs>
)}
{view === 'grid' ? (
<FolderGridView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
) : (
<FolderTableView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
)}
{currentFolderId && currentFolder && (
<Box>
<Divider mx='-xs' my='xs' />
{currentFolder?.allowUploads && (
<Alert
icon={<IconShare size='1rem' />}
variant='outline'
mb='sm'
styles={{ message: { marginTop: 0 } }}
>
This folder allows anonymous uploads. Share the link below to allow others to let others upload
files to this folder.
<br />
<Anchor href={`/folder/${currentFolder.id}/upload`} target='_blank'>
{`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}
</Anchor>
<CopyButton value={`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}>
{({ copied, copy }) => (
<Button mx='sm' size='compact-xs' color={copied ? 'teal' : 'blue'} onClick={copy}>
{copied ? 'Copied url' : 'Copy url'}
</Button>
)}
</CopyButton>
</Alert>
)}
<Text
mt='sm'
c='dimmed'
size='sm'
onClick={() => setFilesOpen((o) => !o)}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse in={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
</Paper>
) : (
<FilesTableView folderId={currentFolderId} />
)}
</Collapse>
</Box>
)}
{view === 'grid' ? <FolderGridView /> : <FolderTableView />}
</>
);
}
@@ -1,192 +0,0 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutateFolder } from '../actions';
type ChildrenAction = 'root' | 'folder' | 'cascade';
export default function DeleteFolderModal({
folder,
opened,
onClose,
}: {
folder: Folder | null;
opened: boolean;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('root');
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const combobox = useCombobox();
const { data: allFolders } = useFolders(undefined, opened);
const folderOptions = useMemo(() => {
if (!allFolders || !folder) return [];
// Exclude the folder being deleted
const excludeIds = new Set([folder.id]);
return buildFolderHierarchy(allFolders, excludeIds);
}, [allFolders, folder]);
if (!folder) return null;
const hasChildren = (folder._count?.children ?? 0) > 0;
const hasFiles = (folder._count?.files ?? 0) > 0;
const hasContent = hasChildren || hasFiles;
const getDisplayValue = () => {
const selected = folderOptions.find((f) => f.id === targetFolderId);
return selected?.path || '';
};
const handleDelete = async () => {
setLoading(true);
const body: any = {
delete: 'folder',
};
if (hasContent) {
body.childrenAction = childrenAction;
if (childrenAction === 'folder') {
if (!targetFolderId) {
notifications.show({
title: 'No folder selected',
message: 'Please select a folder to move contents to',
color: 'red',
});
setLoading(false);
return;
}
body.targetFolderId = targetFolderId;
}
}
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'DELETE',
body,
);
setLoading(false);
if (error) {
notifications.show({
title: 'Failed to delete folder',
message: error.error,
color: 'red',
});
} else {
notifications.show({
title: 'Folder deleted',
message: `${folder.name} has been deleted`,
color: 'green',
});
mutateFolder();
onClose();
}
};
return (
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
<Stack gap='sm'>
<Text size='sm' c='red' fw={500}>
This action cannot be undone.
</Text>
{hasContent && (
<>
<Text size='sm'>
This folder contains {hasFiles && `${folder._count?.files} file(s)`}
{hasChildren && hasFiles && ' and '}
{hasChildren && `${folder._count?.children} subfolder(s)`}. What would you like to do with them?
</Text>
<Radio.Group value={childrenAction} onChange={(v) => setChildrenAction(v as ChildrenAction)}>
<Stack gap='xs'>
<Radio value='root' label='Move contents to root folder' />
<Radio value='folder' label='Move contents to another folder' />
<Radio
value='cascade'
label={
<Text size='sm' c='red'>
Delete everything (cascade delete)
</Text>
}
/>
</Stack>
</Radio.Group>
{childrenAction === 'folder' && (
<Combobox
store={combobox}
withinPortal={true}
onOptionSubmit={(value) => {
setTargetFolderId(value);
setSearch(folderOptions.find((f) => f.id === value)?.path || '');
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label='Target Folder'
placeholder='Select a folder'
rightSection={<Combobox.Chevron />}
value={search || getDisplayValue()}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
combobox.openDropdown();
setSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setSearch('');
}}
rightSectionPointerEvents='none'
required
/>
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions folderOptions={folderOptions} searchValue={search} />
</Combobox.Dropdown>
</Combobox>
)}
{childrenAction === 'cascade' && (
<Text size='sm' c='red' fw={500}>
Warning: This will permanently delete all contents within this folder (subfolders will be
deleted, and files will be unlinked from their folders).
</Text>
)}
</>
)}
<Button
onClick={handleDelete}
loading={loading}
leftSection={<IconTrashFilled size='1rem' />}
color='red'
>
Delete Folder
</Button>
</Stack>
</Modal>
);
}
@@ -1,146 +0,0 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy, getDescendantIds } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/hooks/useFolders';
import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconFolderSymlink } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutateFolder } from '../actions';
export default function MoveFolderModal({
folder,
opened,
onClose,
}: {
folder: Folder | null;
opened: boolean;
onClose: () => void;
}) {
const [selectedParentId, setSelectedParentId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const combobox = useCombobox();
const { data: allFolders } = useFolders(undefined, opened);
const folderOptions = useMemo(() => {
if (!allFolders || !folder) return [];
const descendantIds = getDescendantIds(folder.id, allFolders);
// Exclude the folder being moved and its descendants
const excludeIds = new Set([folder.id, ...descendantIds]);
return buildFolderHierarchy(allFolders, excludeIds);
}, [allFolders, folder]);
const getDisplayValue = () => {
if (selectedParentId === '__root__' || selectedParentId === null) {
return '/ (Root)';
}
const selected = folderOptions.find((f) => f.id === selectedParentId);
return selected?.path || '';
};
if (!folder) {
return null;
}
const handleMove = async () => {
setLoading(true);
const newParentId = selectedParentId === '__root__' ? null : selectedParentId;
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'PATCH',
{ parentId: newParentId },
);
setLoading(false);
if (error) {
notifications.show({
title: 'Failed to move folder',
message: error.error,
color: 'red',
});
} else {
notifications.show({
title: 'Folder moved',
message: `${folder.name} has been moved`,
color: 'green',
});
mutateFolder();
onClose();
}
};
return (
<Modal key={folder.id} centered opened={opened} onClose={onClose} title={`Move "${folder.name}"`}>
<Stack gap='sm'>
<Text size='sm' c='dimmed'>
Select a destination folder for this folder.
</Text>
<Combobox
store={combobox}
withinPortal={true}
onOptionSubmit={(value) => {
setSelectedParentId(value);
setSearch(
value === '__root__' ? '/ (Root)' : folderOptions.find((f) => f.id === value)?.path || '',
);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label='Destination'
placeholder='Select a folder'
rightSection={<Combobox.Chevron />}
value={search || getDisplayValue()}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
combobox.openDropdown();
setSearch('');
}}
onFocus={() => {
combobox.openDropdown();
setSearch('');
}}
onBlur={() => {
combobox.closeDropdown();
setSearch('');
}}
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={<Combobox.Option value='__root__'>/ (Root)</Combobox.Option>}
/>
</Combobox.Dropdown>
</Combobox>
<Button
onClick={handleMove}
loading={loading}
leftSection={<IconFolderSymlink size='1rem' />}
variant='outline'
>
Move Folder
</Button>
</Stack>
</Modal>
);
}
+8 -16
View File
@@ -1,21 +1,13 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
import { IconFolder } from '@tabler/icons-react';
import { IconLink } from '@tabler/icons-react';
import useSWR from 'swr';
import FolderCard from '../FolderCard';
export default function FolderGridView({
currentFolderId,
onNavigate,
}: {
currentFolderId: string | null;
onNavigate: (folderId: string | null) => void;
}) {
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
`/api/user/folders${queryParam}`,
);
export default function FolderGridView() {
const { data: folders, isLoading } =
useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
return (
<>
@@ -34,7 +26,7 @@ export default function FolderGridView({
<Skeleton key={i} height={120} animate />
))}
</SimpleGrid>
) : (folders?.length ?? 0) !== 0 ? (
) : (folders?.length ?? 0 !== 0) ? (
<SimpleGrid
my='sm'
spacing='md'
@@ -46,7 +38,7 @@ export default function FolderGridView({
pos='relative'
>
{folders?.map((folder) => (
<FolderCard key={folder.id} folder={folder} onNavigate={onNavigate} />
<FolderCard key={folder.id} folder={folder} />
))}
</SimpleGrid>
) : (
@@ -54,11 +46,11 @@ export default function FolderGridView({
<Center>
<Stack>
<Group>
<IconFolder size='2rem' />
<IconLink size='2rem' />
<Title order={2}>No Folders found</Title>
</Group>
<Text size='sm' c='dimmed'>
{currentFolderId ? 'This folder is empty' : 'Create a folder to see it here'}
Create a folder to see it here
</Text>
</Stack>
</Center>
+82 -151
View File
@@ -1,15 +1,15 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { ActionIcon, Badge, Box, Checkbox, Group, Menu, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions';
import {
IconCopy,
IconDots,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
IconFiles,
IconLock,
IconLockOpen,
IconPencil,
@@ -17,136 +17,40 @@ import {
IconShareOff,
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, editFolderUploads, editFolderVisibility } from '../actions';
import DeleteFolderModal from '../modals/DeleteFolderModal';
import EditFolderNameModal from '../modals/EditFolderNameModal';
import MoveFolderModal from '../modals/MoveFolderModal';
import ViewFilesModal from '../modals/ViewFilesModal';
import ViewFilesModal from '../ViewFilesModal';
import EditFolderNameModal from '../EditFolderNameModal';
export const withoutPropagation = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
function FolderDotsMenu({
folder,
onNavigate,
setDeleteOpen,
setMoveOpen,
setEditNameOpen,
}: {
folder: Folder;
onNavigate: (folderId: string) => void;
setDeleteOpen: (folder: Folder) => void;
setMoveOpen: (folder: Folder) => void;
setEditNameOpen: (folder: Folder) => void;
}) {
const [opened, setOpened] = useState(false);
return (
<Menu shadow='md' width={200} opened={opened} onChange={setOpened}>
<Menu.Target>
<Tooltip label='More actions'>
<ActionIcon onClick={withoutPropagation(() => setOpened((o) => !o))}>
<IconDots size='1rem' />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{onNavigate && (
<Menu.Item
leftSection={<IconFolderOpen size='1rem' />}
onClick={withoutPropagation(() => onNavigate(folder.id!))}
>
Open Folder
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(folder))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditNameOpen(folder))}
>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={withoutPropagation(() => setDeleteOpen(folder))}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
export default function FolderTableView({
currentFolderId,
onNavigate,
}: {
currentFolderId: string | null;
onNavigate: (folderId: string | null) => void;
}) {
export default function FolderTableView() {
const clipboard = useClipboard();
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
`/api/user/folders${queryParam}`,
);
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
const [moveOpen, setMoveOpen] = useState<Folder | null>(null);
const [deleteOpen, setDeleteOpen] = useState<Folder | null>(null);
const sorted = useMemo<Folder[]>(() => {
if (!data) return [];
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Folder;
const { columnAccessor, direction } = sortStatus;
const key = columnAccessor as keyof Folder;
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
return [...data].sort((a, b) => {
const av = a[key]!;
const bv = b[key]!;
setSorted(sorted);
}
}, [sortStatus]);
if (av === bv) return 0;
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
});
}, [data, sortStatus]);
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
return (
<>
@@ -162,45 +66,35 @@ export default function FolderTableView({
onClose={() => setEditNameOpen(null)}
/>
<MoveFolderModal opened={!!moveOpen} folder={moveOpen} onClose={() => setMoveOpen(null)} />
<DeleteFolderModal opened={!!deleteOpen} folder={deleteOpen} onClose={() => setDeleteOpen(null)} />
<Box my='sm'>
<DataTable
borderRadius='sm'
withTableBorder
minHeight={200}
records={sorted ?? []}
onRowClick={({ record }) => onNavigate(record.id)}
rowStyle={() => ({ cursor: 'pointer' })}
noRecordsText='No subfolders'
columns={[
{
accessor: 'name',
sortable: true,
render: (folder) => (
<Group gap='xs'>
<IconFolder size='1rem' />
<Text>{folder.name}</Text>
{(folder._count?.children ?? 0) > 0 && (
<Badge size='xs' variant='light'>
{folder._count?.children} subfolder{(folder._count?.children ?? 0) > 1 ? 's' : ''}
</Badge>
)}
</Group>
),
render: (folder) =>
folder.public ? (
<Anchor href={`/folder/${folder.id}`} target='_blank'>
{folder.name}
</Anchor>
) : (
folder.name
),
},
{
accessor: 'public',
sortable: true,
render: (folder) => <Checkbox checked={folder.public} readOnly />,
render: (folder) => <Checkbox checked={folder.public} />,
},
{
accessor: 'allowUploads',
title: 'Uploads?',
sortable: true,
render: (folder) => <Checkbox checked={folder.allowUploads} readOnly />,
render: (folder) => <Checkbox checked={folder.allowUploads} />,
},
{
accessor: 'createdAt',
@@ -219,14 +113,16 @@ export default function FolderTableView({
textAlign: 'right',
render: (folder) => (
<Group gap='sm' justify='right' wrap='nowrap'>
<FolderDotsMenu
folder={folder}
onNavigate={onNavigate}
setDeleteOpen={setDeleteOpen}
setMoveOpen={setMoveOpen}
setEditNameOpen={setEditNameOpen}
/>
<Tooltip label='View files'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setSelectedFolder(folder);
}}
>
<IconFiles size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy folder link'>
<ActionIcon
onClick={(e) => {
@@ -238,12 +134,47 @@ export default function FolderTableView({
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
<ActionIcon
color={folder.public ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderVisibility(folder, !folder.public);
}}
>
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip
label={folder.allowUploads ? 'Disable anonymous uploads' : 'Allow anonymous uploads'}
>
<ActionIcon
color={folder.allowUploads ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderUploads(folder, !folder.allowUploads);
}}
>
{folder.allowUploads ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Edit Folder Name'>
<ActionIcon
color='blue'
onClick={(e) => {
e.stopPropagation();
setEditNameOpen(folder);
}}
>
<IconPencil size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
color='red'
onClick={(e) => {
e.stopPropagation();
setDeleteOpen(folder);
deleteFolder(folder);
}}
>
<IconTrashFilled size='1rem' />
View File
View File
+6 -9
View File
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import { Button, Group, Modal, NumberInput, Select, Stack, Title } from '@mantine/core';
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconTagOff } from '@tabler/icons-react';
@@ -112,14 +112,11 @@ export default function DashboardInvites() {
<Group>
<Title>Invites</Title>
<Button
variant='outline'
size='compact-sm'
leftSection={<IconPlus size='1rem' />}
onClick={() => setOpen(true)}
>
Create
</Button>
<Tooltip label='Create a new invite'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconPlus size='1rem' />
</ActionIcon>
</Tooltip>
<GridTableSwitcher type='invites' />
</Group>
View File
+17 -13
View File
@@ -1,14 +1,14 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { useSettingsStore } from '@/lib/store/settings';
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { copyInviteUrl, deleteInvite } from '../actions';
import { useSettingsStore } from '@/lib/store/settings';
export default function InviteTableView() {
const clipboard = useClipboard();
@@ -20,21 +20,25 @@ export default function InviteTableView() {
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Invite[]>(data ?? []);
const sorted = useMemo<Invite[]>(() => {
if (!data) return [];
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Invite;
const { columnAccessor, direction } = sortStatus;
const key = columnAccessor as keyof Invite;
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
return [...data].sort((a, b) => {
const av = a[key]!;
const bv = b[key]!;
setSorted(sorted);
}
}, [sortStatus]);
if (av === bv) return 0;
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
});
}, [data, sortStatus]);
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
return (
<>
-50
View File
@@ -1,50 +0,0 @@
import { Stack, TextInput, PasswordInput, Button } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
export default function LocalLogin({
form,
onSubmit,
loading,
hasBackground,
}: {
form: UseFormReturnType<any>;
onSubmit: (values: any) => void;
loading: boolean;
hasBackground: boolean;
}) {
return (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
}}
{...form.getInputProps('username')}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='current-password'
styles={{
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
}}
{...form.getInputProps('password')}
/>
<Button
size='md'
fullWidth
type='submit'
loading={loading}
variant={hasBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
);
}
@@ -1,50 +0,0 @@
import { Button } from '@mantine/core';
import { IconKey } from '@tabler/icons-react';
import { useState } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { fetchApi } from '@/lib/fetchApi';
import { notifications } from '@mantine/notifications';
import { getWebClient } from '@/lib/api/detect';
export default function PasskeyAuthButton({ onAuthSuccess }: { onAuthSuccess: (data: any) => void }) {
const [loading, setLoading] = useState(false);
const [errored, setErrored] = useState(false);
const handleLogin = async () => {
setLoading(true);
try {
const { data: options } = await fetchApi<any>('/api/auth/webauthn/options', 'GET');
const res = await startAuthentication({ optionsJSON: options.options });
const { data, error } = await fetchApi<any>(
'/api/auth/webauthn',
'POST',
{ response: res },
{ 'x-zipline-client': JSON.stringify(getWebClient()) },
);
if (error) throw new Error(error.error);
onAuthSuccess(data);
} catch (e: any) {
setErrored(true);
setTimeout(() => setErrored(false), 3000);
notifications.show({ title: 'Auth Failed', message: e.message, color: 'red' });
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleLogin}
size='md'
fullWidth
variant='outline'
leftSection={<IconKey size='1rem' />}
color={errored ? 'red' : undefined}
loading={loading}
>
Login with passkey
</Button>
);
}
@@ -1,57 +0,0 @@
import { Anchor, Code, Modal, Text } from '@mantine/core';
export default function SecureWarningModal({
returnHttps,
opened,
onClose,
}: {
returnHttps: boolean;
opened: boolean;
onClose: () => void;
}) {
return (
<Modal opened={opened} onClose={onClose} title='HTTPS Configuration' size='lg'>
<Text>
{returnHttps ? (
<>
It appears that you are accessing this instance through an insecure context (HTTP), but the server
is configured to use HTTPS. This can lead to issues when logging in, as secure cookies may not be
sent by the browser.
</>
) : (
<>
It appears that you are accessing this instance through a secure context (HTTPS), but the server
is not configured to use HTTPS. This can lead issues when logging in.
</>
)}
</Text>
<Text mt='md'>
{returnHttps ? (
<>
To resolve this issue, please access this instance through HTTPS. If that is currently not
possible, you can temporarily set the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to{' '}
<Code>false</Code>.
</>
) : (
<>
To resolve this issue, it is recommended to have your server configured to use HTTPS. This can be
done by setting the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to <Code>true</Code>{' '}
and ensuring that your server has a valid SSL setup through a reverse proxy like Nginx or Caddy.
</>
)}
</Text>
<Text mt='md'>
After making these changes, restart the server for the changes to take effect. If you continue to
experience issues, please consult the{' '}
<Anchor
underline='always'
href='https://zipline.diced.sh/docs/config/settings#more-about-return-https-urls'
>
documentation
</Anchor>{' '}
or seek support.
</Text>
</Modal>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { Modal, Center, PinInput, Text, Group, Button } from '@mantine/core';
import { IconX, IconShieldQuestion } from '@tabler/icons-react';
export default function TotpModal({
state,
onPinChange,
onVerify,
onCancel,
}: {
state: { open: boolean; disabled: boolean; error: string; pin: string };
onPinChange: (val: string) => void;
onVerify: () => void;
onCancel: () => void;
}) {
return (
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
Verify
</Button>
</Group>
</Modal>
);
}
+7 -8
View File
@@ -1,12 +1,12 @@
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { lazy, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
import { useApiStats } from './useStats';
import { StatsCardsSkeleton } from './parts/StatsCards';
import { StatsTablesSkeleton } from './parts/StatsTables';
import { useApiStats } from './useStats';
import dayjs from 'dayjs';
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
@@ -35,10 +35,9 @@ export default function DashboardMetrics() {
setDateRange(value);
};
const showAllTime = () => {
setAllTime(true);
setDateRange([null, null]);
};
useEffect(() => {
if (allTime) setDateRange([null, null]);
}, [allTime]);
return (
<>
@@ -119,7 +118,7 @@ export default function DashboardMetrics() {
size='compact-sm'
variant='outline'
leftSection={<IconCalendarTime size='1rem' />}
onClick={() => showAllTime()}
onClick={() => setAllTime(true)}
disabled={allTime}
>
Show All Time
View File
View File
+4 -3
View File
@@ -99,7 +99,8 @@ export default function StatsTables({ data }: { data: Metric[] }) {
const recent = data[0]; // it is sorted by desc so 0 is the first one.
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
if (recent.data.filesUsers.length === 0) return null;
if (recent.data.urlsUsers.length === 0) return null;
return (
<>
@@ -120,7 +121,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{bytes(count.storage)}</Table.Td>
<Table.Td>{count.views}</Table.Td>
@@ -146,7 +147,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{count.views}</Table.Td>
</Table.Tr>
View File
View File

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