commit a8fef7d4913d1096beaabc4cd78b9c3a6b7d3c86 Author: Tautvydas Derzinskas Date: Mon Sep 7 09:12:14 2026 +0200 feat: initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6512376 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +PUID=1000 +PGID=1000 +BACKEND_IMAGE=printstash-backend:latest +POSTGRES_USER=printstash +POSTGRES_PASSWORD=printstash +POSTGRES_DB=printstash +API_PORT=8000 +PUBLIC_URL= +CORS_ORIGINS= +AUTH_USERNAME=admin +AUTH_PASSWORD=super-secret +AUTH_SECRET=replace-with-random-secret +AUTH_TOKEN_TTL=43200 +IMPORT_MOUNT_PATH=/imports +IMPORT_MOUNT_PATH_HOST=/path/to/imports +IMPORT_MOUNT_EXTS=stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip +IMPORT_MOUNT_INCLUDE_HIDDEN=false +IMPORT_MOUNT_ON_STARTUP=true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/backend-image.yml b/.github/workflows/backend-image.yml new file mode 100644 index 0000000..56daa02 --- /dev/null +++ b/.github/workflows/backend-image.yml @@ -0,0 +1,41 @@ +name: Backend image + +# Only runs when backend/ (or the build workflow) changes — a frontend-only commit never +# triggers a backend publish, and vice versa (see frontend-image.yml). To build+publish either +# image on demand regardless of what changed, use the "Manual image publish" workflow instead. +on: + push: + branches: [main] + tags: ["v*.*.*"] + paths: + - "backend/**" + - ".github/workflows/backend-image.yml" + - ".github/workflows/build-image.yml" + pull_request: + branches: [main] + paths: + - "backend/**" + - ".github/workflows/backend-image.yml" + - ".github/workflows/build-image.yml" + +concurrency: + group: backend-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + # Build context is the repo root: backend/Dockerfile builds the frontend in its own stage + # and bundles the static output into the backend image, so it needs to see both backend/ + # and frontend/ regardless of which one changed. + project_dir: backend + image_name: ghcr.io/${{ github.repository_owner }}/printstash-backend + context: . + dockerfile: backend/Dockerfile + cache_scope: backend + push: ${{ github.event_name != 'pull_request' }} + secrets: inherit diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..64c0e2f --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,90 @@ +name: Build image (reusable) + +# Shared build+push logic used by backend-image.yml, frontend-image.yml, and +# manual-image.yml — not triggered on its own (workflow_call only). +on: + workflow_call: + inputs: + project_dir: + description: "The npm project directory to lint, e.g. backend or frontend" + required: true + type: string + image_name: + description: "Fully-qualified image name, e.g. ghcr.io/owner/printstash-backend" + required: true + type: string + context: + description: "Docker build context" + required: true + type: string + dockerfile: + description: "Path to the Dockerfile" + required: true + type: string + cache_scope: + description: "GHA cache scope, so backend/frontend caches don't collide" + required: true + type: string + push: + description: "Whether to log in and push (false for PR validation builds)" + required: false + type: boolean + default: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ${{ inputs.project_dir }}/package-lock.json + + - name: Install dependencies + working-directory: ${{ inputs.project_dir }} + run: npm ci --no-audit --no-fund + + # - name: Lint (quality gate — blocks the image build on failure) + # working-directory: ${{ inputs.project_dir }} + # run: npm run lint + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: inputs.push + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.image_name }} + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build (and push) + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: ${{ inputs.push }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ inputs.cache_scope }} + cache-to: type=gha,mode=max,scope=${{ inputs.cache_scope }} diff --git a/.github/workflows/frontend-image.yml b/.github/workflows/frontend-image.yml new file mode 100644 index 0000000..2d810cb --- /dev/null +++ b/.github/workflows/frontend-image.yml @@ -0,0 +1,38 @@ +name: Frontend image + +# Only runs when frontend/ (or the build workflow) changes — a backend-only commit never +# triggers a frontend publish, and vice versa (see backend-image.yml). To build+publish either +# image on demand regardless of what changed, use the "Manual image publish" workflow instead. +on: + push: + branches: [main] + tags: ["v*.*.*"] + paths: + - "frontend/**" + - ".github/workflows/frontend-image.yml" + - ".github/workflows/build-image.yml" + pull_request: + branches: [main] + paths: + - "frontend/**" + - ".github/workflows/frontend-image.yml" + - ".github/workflows/build-image.yml" + +concurrency: + group: frontend-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + project_dir: frontend + image_name: ghcr.io/${{ github.repository_owner }}/printstash-frontend + context: ./frontend + dockerfile: frontend/Dockerfile + cache_scope: frontend + push: ${{ github.event_name != 'pull_request' }} + secrets: inherit diff --git a/.github/workflows/manual-image.yml b/.github/workflows/manual-image.yml new file mode 100644 index 0000000..1873708 --- /dev/null +++ b/.github/workflows/manual-image.yml @@ -0,0 +1,49 @@ +name: Manual image publish + +# One place to build+push either (or both) images on demand, regardless of what actually +# changed — e.g. to re-publish after a registry hiccup, or force a rebuild after a base-image +# security update. The automatic, path-filtered publishes still live in backend-image.yml and +# frontend-image.yml. +on: + workflow_dispatch: + inputs: + target: + description: "Which image(s) to build and publish" + required: true + type: choice + options: + - backend + - frontend + - both + default: both + +jobs: + backend: + if: inputs.target == 'backend' || inputs.target == 'both' + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + project_dir: backend + image_name: ghcr.io/${{ github.repository_owner }}/printstash-backend + context: . + dockerfile: backend/Dockerfile + cache_scope: backend + push: true + secrets: inherit + + frontend: + if: inputs.target == 'frontend' || inputs.target == 'both' + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-image.yml + with: + project_dir: frontend + image_name: ghcr.io/${{ github.repository_owner }}/printstash-frontend + context: ./frontend + dockerfile: frontend/Dockerfile + cache_scope: frontend + push: true + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51b59d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Local secrets and environment overrides +.env +.env.* +!.env.example +!.env.sample +!.env.template + +# Node and Vite +node_modules/ +dist/ +.vite/ +coverage/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Prisma +backend/prisma/dev.db* + +# PrintStash runtime content +data/ +imports/ +backend/storage/ + +# IDE, operating-system, and temporary files +.idea/ +.vscode/ +.DS_Store +Thumbs.db +*.swp +*.swo +*.tmp +*.temp +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ddcf084 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +PUID=1000 +PGID=1000 +DATABASE_URL=postgresql://printstash:printstash@db:5432/printstash?schema=public +FILE_STORAGE=/app/storage +API_PORT=8000 +PUBLIC_URL= +CORS_ORIGINS= +AUTH_USERNAME=admin +AUTH_PASSWORD=super-secret +AUTH_SECRET=replace-with-random-secret +AUTH_TOKEN_TTL=43200 +IMPORT_MOUNT_PATH=/imports +IMPORT_MOUNT_PATH_HOST=/path/to/imports +IMPORT_MOUNT_EXTS=stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip +IMPORT_MOUNT_INCLUDE_HIDDEN=false +IMPORT_MOUNT_ON_STARTUP=true +IMPORT_MOUNT_COPY=true diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..510832a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +storage/ +*.log diff --git a/backend/.oxlintrc.json b/backend/.oxlintrc.json new file mode 100644 index 0000000..81416fd --- /dev/null +++ b/backend/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "node", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "warn" + }, + "rules": { + "no-underscore-dangle": ["error", { "allow": ["_max", "_count", "_sum", "_avg", "_min"] }], + "no-await-in-loop": "off" + }, + "env": { + "builtin": true, + "node": true + }, + "ignorePatterns": ["dist", "node_modules", "coverage", "prisma/migrations", "storage"] +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4be0e13 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +# Build context is the repo root (see docker-compose.yml) so this stage can reach ../frontend. +FROM node:20-alpine AS frontend-build +WORKDIR /frontend +COPY frontend/package*.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend ./ +RUN npm run build + +FROM node:20-alpine AS dependencies +WORKDIR /app +RUN apk add --no-cache openssl +COPY backend/package*.json ./ +COPY backend/prisma ./prisma +RUN npm ci --no-audit --no-fund && npx prisma generate + +FROM dependencies AS test +COPY backend . +ENV DATABASE_URL="postgresql://printstash:printstash@localhost:5432/printstash_test?schema=public" \ + FILE_STORAGE=/tmp/printstash-storage +CMD ["npm", "test"] + +FROM dependencies AS build +COPY backend . +RUN npm run build + +FROM node:20-alpine AS runtime +RUN apk add --no-cache openssl su-exec +WORKDIR /app + +COPY --from=dependencies /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=frontend-build /frontend/dist ./public +COPY backend/package*.json ./ +COPY backend/prisma ./prisma +COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +# node:20-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own +# "appuser"/"appgroup" (which docker-entrypoint.sh re-aligns to PUID/PGID at container start) +# can claim that uid/gid without colliding. +RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ + && (deluser node 2>/dev/null || true) \ + && (delgroup node 2>/dev/null || true) \ + && addgroup -g 1000 appgroup \ + && adduser -D -u 1000 -G appgroup -s /sbin/nologin appuser \ + && mkdir -p /app/storage /app/data + +EXPOSE 8000 +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["node", "dist/server.js"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..7b9c05b --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +PUID=${PUID:-1000} +PGID=${PGID:-1000} +APP_USER=appuser +APP_GROUP=appgroup + +# Align group with requested PGID (create or update in-place). +if getent group "$PGID" >/dev/null 2>&1; then + APP_GROUP="$(getent group "$PGID" | cut -d: -f1)" +elif getent group "$APP_GROUP" >/dev/null 2>&1; then + addgroup -g "$PGID" "$APP_GROUP" 2>/dev/null || true +fi + +# Align user with requested PUID. +if id "$APP_USER" >/dev/null 2>&1; then + deluser "$APP_USER" >/dev/null 2>&1 || true + adduser -D -u "$PUID" -G "$APP_GROUP" -s /sbin/nologin "$APP_USER" +fi + +# Make sure mounted data directories are writable by the runtime user. +CHOWN_MODE=${CHOWN_MODE:-minimal} +mkdir -p /app/storage /app/data +if [ "$CHOWN_MODE" = "recursive" ]; then + chown -R "$PUID":"$PGID" /app/storage /app/data 2>/dev/null || true +else + chown "$PUID":"$PGID" /app/storage /app/data 2>/dev/null || true +fi + +# Managed migrations: apply any pending Prisma migrations before the app starts. +npx prisma migrate deploy + +exec su-exec "$APP_USER" "$@" diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..357a149 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,4621 @@ +{ + "name": "printstash-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "printstash-backend", + "version": "0.1.0", + "dependencies": { + "@prisma/client": "^5.20.0", + "cheerio": "^1.0.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "mime-types": "^2.1.35", + "multer": "^2.0.1", + "prisma": "^5.20.0", + "sharp": "^0.35.4", + "yauzl": "^3.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/mime-types": "^2.1.4", + "@types/multer": "^2.2.0", + "@types/node": "^20.14.0", + "@types/supertest": "^7.2.1", + "@types/yauzl": "^2.10.3", + "oxlint": "^1.81.0", + "supertest": "^7.2.2", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6cb854a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "printstash-backend", + "private": true, + "version": "0.1.0", + "type": "commonjs", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "test": "vitest run", + "lint": "oxlint ." + }, + "dependencies": { + "@prisma/client": "^5.20.0", + "cheerio": "^1.0.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "mime-types": "^2.1.35", + "multer": "^2.0.1", + "prisma": "^5.20.0", + "sharp": "^0.35.4", + "yauzl": "^3.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/mime-types": "^2.1.4", + "@types/multer": "^2.2.0", + "@types/node": "^20.14.0", + "@types/supertest": "^7.2.1", + "@types/yauzl": "^2.10.3", + "oxlint": "^1.81.0", + "supertest": "^7.2.2", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vitest": "^4.1.11" + }, + "overrides": { + "qs": "^6.16.0" + } +} diff --git a/backend/prisma/migrations/20260906173312_init/migration.sql b/backend/prisma/migrations/20260906173312_init/migration.sql new file mode 100644 index 0000000..3e1ff44 --- /dev/null +++ b/backend/prisma/migrations/20260906173312_init/migration.sql @@ -0,0 +1,126 @@ +-- CreateEnum +CREATE TYPE "PrintFileRole" AS ENUM ('SUPPORTING', 'PREPARED'); + +-- CreateTable +CREATE TABLE "Folder" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "parentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Folder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Print" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameNormalized" TEXT NOT NULL, + "title" TEXT, + "notes" TEXT, + "creator" TEXT, + "collection" TEXT, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "folderId" TEXT, + "sourcePath" TEXT, + "preparedFileId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Print_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Plate" ( + "id" TEXT NOT NULL, + "printId" TEXT NOT NULL, + "position" INTEGER NOT NULL, + "filename" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "size" INTEGER NOT NULL DEFAULT 0, + "storagePath" TEXT NOT NULL, + "sourcePath" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Plate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PrintFile" ( + "id" TEXT NOT NULL, + "printId" TEXT NOT NULL, + "role" "PrintFileRole" NOT NULL, + "filename" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "size" INTEGER NOT NULL DEFAULT 0, + "storagePath" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PrintFile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Setting" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + + CONSTRAINT "Setting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "Folder_parentId_idx" ON "Folder"("parentId"); + +-- CreateIndex +CREATE INDEX "Folder_tags_idx" ON "Folder" USING GIN ("tags"); + +-- CreateIndex +CREATE UNIQUE INDEX "Print_preparedFileId_key" ON "Print"("preparedFileId"); + +-- CreateIndex +CREATE INDEX "Print_folderId_name_idx" ON "Print"("folderId", "name"); + +-- CreateIndex +CREATE INDEX "Print_tags_idx" ON "Print" USING GIN ("tags"); + +-- CreateIndex +CREATE UNIQUE INDEX "Print_folderId_nameNormalized_key" ON "Print"("folderId", "nameNormalized"); + +-- CreateIndex +-- Postgres multi-column unique indexes never conflict when any indexed column is NULL, +-- so the composite index above does not stop two root-level (folderId IS NULL) prints +-- from sharing a name. This partial index enforces per-folder name uniqueness at the root too. +CREATE UNIQUE INDEX "Print_root_nameNormalized_key" ON "Print"("nameNormalized") WHERE "folderId" IS NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Plate_storagePath_key" ON "Plate"("storagePath"); + +-- CreateIndex +CREATE INDEX "Plate_printId_idx" ON "Plate"("printId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Plate_printId_position_key" ON "Plate"("printId", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "PrintFile_storagePath_key" ON "PrintFile"("storagePath"); + +-- CreateIndex +CREATE INDEX "PrintFile_printId_role_idx" ON "PrintFile"("printId", "role"); + +-- AddForeignKey +ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Print" ADD CONSTRAINT "Print_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Print" ADD CONSTRAINT "Print_preparedFileId_fkey" FOREIGN KEY ("preparedFileId") REFERENCES "PrintFile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Plate" ADD CONSTRAINT "Plate_printId_fkey" FOREIGN KEY ("printId") REFERENCES "Print"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PrintFile" ADD CONSTRAINT "PrintFile_printId_fkey" FOREIGN KEY ("printId") REFERENCES "Print"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260906174400_add_print_prepared_metadata/migration.sql b/backend/prisma/migrations/20260906174400_add_print_prepared_metadata/migration.sql new file mode 100644 index 0000000..8acd761 --- /dev/null +++ b/backend/prisma/migrations/20260906174400_add_print_prepared_metadata/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Print" ADD COLUMN "preparedMetadata" JSONB; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..3207b38 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,91 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Folder { + id String @id @default(cuid()) + name String + tags String[] @default([]) + parentId String? + parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: SetNull) + children Folder[] @relation("FolderTree") + prints Print[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([parentId]) + @@index([tags], type: Gin) +} + +model Print { + id String @id @default(cuid()) + name String + nameNormalized String + title String? + notes String? + creator String? + collection String? + tags String[] @default([]) + folderId String? + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + sourcePath String? + plates Plate[] + files PrintFile[] @relation("PrintFiles") + preparedFileId String? @unique + preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull) + preparedMetadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([folderId, nameNormalized]) + @@index([folderId, name]) + @@index([tags], type: Gin) +} + +model Plate { + id String @id @default(cuid()) + printId String + print Print @relation(fields: [printId], references: [id], onDelete: Cascade) + position Int + filename String + mime String + size Int @default(0) + storagePath String @unique + sourcePath String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([printId, position]) + @@index([printId]) +} + +enum PrintFileRole { + SUPPORTING + PREPARED +} + +model PrintFile { + id String @id @default(cuid()) + printId String + print Print @relation("PrintFiles", fields: [printId], references: [id], onDelete: Cascade) + role PrintFileRole + filename String + mime String + size Int @default(0) + storagePath String @unique + metadata Json? + createdAt DateTime @default(now()) + preparedFor Print? @relation("PreparedFile") + + @@index([printId, role]) +} + +model Setting { + key String @id + value Json +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..b44522d --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,65 @@ +import express, { type Express, type NextFunction, type Request, type Response } from "express"; +import cors from "cors"; +import fs from "node:fs"; +import { resolveCorsOrigins } from "./cors"; +import { FRONTEND_DIST } from "./config"; +import { HttpError } from "./utils/fileUtils"; + +import healthRoutes from "./routes/health"; +import authRoutes from "./routes/auth"; +import printsRoutes from "./routes/prints"; +import platesRoutes from "./routes/plates"; +import foldersRoutes from "./routes/folders"; +import printFilesRoutes from "./routes/printFiles"; +import settingsRoutes from "./routes/settings"; +import importsRoutes from "./routes/imports"; + +export function createApp(): Express { + const app = express(); + + const origins = resolveCorsOrigins(); + app.use( + cors({ + origin: origins.includes("*") ? true : origins, + credentials: true, + exposedHeaders: ["X-Has-More", "X-Next-Offset"], + }), + ); + + app.use(express.json()); + + // Serves the frontend's built static files (index.html, assets/*) when present — i.e. in the + // production Docker image, which bundles frontend + backend behind one origin/port so the UI + // can call the API with plain relative paths (no /api prefix, no separate proxy). Absent in + // local dev (`npm run dev`), where the Vite dev server is used instead and this is a no-op. + if (fs.existsSync(FRONTEND_DIST)) { + app.use(express.static(FRONTEND_DIST)); + } + + app.use(healthRoutes); + app.use(authRoutes); + app.use(settingsRoutes); + app.use(printsRoutes); + app.use(platesRoutes); + app.use(foldersRoutes); + app.use(printFilesRoutes); + app.use(importsRoutes); + + // 404 fallback for unmatched routes. + app.use((_req: Request, res: Response) => { + res.status(404).json({ detail: "Not found" }); + }); + + // Central error handler: HttpError carries its own status, everything else is a 500. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof HttpError) { + res.status(err.status).json({ detail: err.message }); + return; + } + console.error(err); + res.status(500).json({ detail: "Internal server error" }); + }); + + return app; +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..553b25f --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,39 @@ +import type { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { AUTH_ALGO, AUTH_ENABLED, AUTH_SECRET, AUTH_TOKEN_TTL } from "./config"; + +export function createToken(username: string): string { + return jwt.sign({ sub: username }, AUTH_SECRET, { + algorithm: AUTH_ALGO, + expiresIn: AUTH_TOKEN_TTL, + }); +} + +export function verifyToken(token: string): { sub: string } | null { + try { + return jwt.verify(token, AUTH_SECRET, { algorithms: [AUTH_ALGO] }) as { sub: string }; + } catch { + return null; + } +} + +/** + * Accepts either an `Authorization: Bearer ` header or a `?token=` query + * param (needed for /direct file links that can't set headers). No-op when + * auth is disabled entirely (mirrors MakersVault's require_auth dependency). + */ +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (!AUTH_ENABLED) { + next(); + return; + } + const header = req.header("authorization"); + const headerToken = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined; + const queryToken = typeof req.query.token === "string" ? req.query.token : undefined; + const token = headerToken || queryToken; + if (!token || !verifyToken(token)) { + res.status(401).json({ detail: "Unauthorized" }); + return; + } + next(); +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..a5c4176 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,59 @@ +import path from "node:path"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function envBool(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined) return fallback; + return ["1", "true", "yes", "y", "on"].includes(raw.trim().toLowerCase()); +} + +export const STORAGE = path.resolve(process.env.FILE_STORAGE || "./storage"); +export const THUMBS = path.join(STORAGE, "thumbs"); +export const BUNDLES = path.join(STORAGE, "bundles"); + +export const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; +export const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "super-secret"; +export const AUTH_SECRET = process.env.AUTH_SECRET || "changeme-secret"; +export const AUTH_TOKEN_TTL = envInt("AUTH_TOKEN_TTL", 43200); +export const AUTH_ALGO = "HS256" as const; +export const AUTH_ENABLED = Boolean(AUTH_USERNAME && AUTH_PASSWORD); + +export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]); +export const IMPORT_EXT_PRIORITY = [".3mf", ".stl", ".step", ".stp", ".lbrn2", ".lbrn", ".zip"]; +export const IMPORT_BLOCKED_EXTS = new Set([ + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".jfif", ".tif", ".tiff", + ".css", ".js", ".mjs", ".map", ".json", ".ico", ".woff", ".woff2", ".ttf", ".eot", +]); +export const IMPORT_TIMEOUT_SECONDS = envInt("IMPORT_TIMEOUT_SECONDS", 30); +export const IMPORT_MAX_MB = envInt("IMPORT_MAX_MB", 512); +export const IMPORT_MAX_BYTES = Math.max(1, IMPORT_MAX_MB) * 1024 * 1024; +export const IMPORT_HTML_MAX_KB = envInt("IMPORT_HTML_MAX_KB", 4096); +export const IMPORT_HTML_MAX_BYTES = Math.max(64, IMPORT_HTML_MAX_KB) * 1024; +export const IMPORT_USER_AGENT = "PrintStash/1.0"; +export const IMPORT_BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"; + +export const MOUNT_IMPORT_PATH = (process.env.IMPORT_MOUNT_PATH || "").trim(); +export const MOUNT_IMPORT_EXTS_RAW = (process.env.IMPORT_MOUNT_EXTS || "").trim(); +export const MOUNT_IMPORT_INCLUDE_HIDDEN = envBool("IMPORT_MOUNT_INCLUDE_HIDDEN", false); +export const MOUNT_IMPORT_ENABLED = envBool("IMPORT_MOUNT_ON_STARTUP", true); +export const MOUNT_IMPORT_COPY = envBool("IMPORT_MOUNT_COPY", true); +export const DEFAULT_MOUNT_IMPORT_EXTS = new Set([ + ".stl", ".3mf", ".step", ".stp", ".obj", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".bmp", + ".lbrn", ".lbrn2", ".zip", +]); + +export const API_PORT = envInt("API_PORT", 8000); + +/** + * Where the frontend's built static files (index.html, assets/*) live, if present. Populated by + * the Docker image's build (see Dockerfile); absent in local `npm run dev` — express.static() + * simply serves nothing and every request falls through to the API routes / 404 handler. + */ +export const FRONTEND_DIST = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../public")); diff --git a/backend/src/cors.ts b/backend/src/cors.ts new file mode 100644 index 0000000..be13b2c --- /dev/null +++ b/backend/src/cors.ts @@ -0,0 +1,29 @@ +/** Port of MakersVault main.py's normalize_origin/resolve_cors_origins (lines ~112-149). */ +function normalizeOrigin(raw: string | undefined): string | null { + const value = (raw || "").trim(); + if (!value) return null; + try { + const parsed = new URL(value); + if (!parsed.protocol || !parsed.host) return null; + return `${parsed.protocol}//${parsed.host}`; + } catch { + return null; + } +} + +export function resolveCorsOrigins(): string[] { + const raw = process.env.CORS_ORIGINS; + if (raw) { + return raw + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + } + const candidates: string[] = []; + for (const name of ["PUBLIC_URL", "VITE_API_URL"]) { + const origin = normalizeOrigin(process.env[name]); + if (origin && !candidates.includes(origin)) candidates.push(origin); + } + if (candidates.length) return candidates; + return ["http://localhost:5173"]; +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..0147e48 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,9 @@ +import fs from "node:fs"; +import { PrismaClient } from "@prisma/client"; +import { STORAGE, THUMBS, BUNDLES } from "./config"; + +for (const dir of [STORAGE, THUMBS, BUNDLES]) { + fs.mkdirSync(dir, { recursive: true }); +} + +export const prisma = new PrismaClient(); diff --git a/backend/src/dto.ts b/backend/src/dto.ts new file mode 100644 index 0000000..53a1eeb --- /dev/null +++ b/backend/src/dto.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import type { Folder, Plate, Print, PrintFile } from "@prisma/client"; +import { plateThumbExists, plateThumbPath } from "./services/printService"; +import { preparedFilename } from "./services/preparedPrint"; + +export type PreparedPrintOut = { + printer?: string | null; + material?: string | null; + nozzle_mm?: number | null; + layer_height_mm?: number | null; + estimated_seconds?: number | null; + format?: string | null; + removable: boolean; +}; + +export type PlateOut = { + id: string; + print_id: string; + position: number; + filename: string; + mime: string; + size: number; + url: string; + thumb_url: string | null; +}; + +export type PrintFileOut = { + id: string; + filename: string; + mime: string; + size: number; + url: string; +}; + +export type PrintOut = { + id: string; + name: string; + title: string | null; + notes: string | null; + creator: string | null; + collection: string | null; + tags: string[]; + folder_id: string | null; + storage_path: string | null; + plates: PlateOut[]; + thumb_url: string | null; + supporting_file_count: number; + prepared_print: PreparedPrintOut | null; + slicer_url: string | null; + slicer_filename: string | null; +}; + +export type FolderOut = { + id: string; + name: string; + tags: string[]; + parent_id: string | null; +}; + +function plateThumbUrl(plateId: string): string | null { + if (!plateThumbExists(plateId)) return null; + const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs; + return `/plate/${plateId}/thumb.jpg?v=${mtime}`; +} + +export function toPlateOut(printId: string, plate: Plate): PlateOut { + return { + id: plate.id, + print_id: printId, + position: plate.position, + filename: plate.filename, + mime: plate.mime, + size: plate.size, + url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`, + thumb_url: plateThumbUrl(plate.id), + }; +} + +export function toPrintFileOut(printFile: PrintFile): PrintFileOut { + return { + id: printFile.id, + filename: printFile.filename, + mime: printFile.mime, + size: printFile.size, + url: `/print/${printFile.printId}/files/${printFile.id}`, + }; +} + +function storageParentDir(plates: Plate[]): string | null { + if (!plates.length) return null; + const parts = plates[0].storagePath.split("/"); + parts.pop(); + return parts.join("/") || null; +} + +/** + * Builds the full Print DTO. `plates` must already be sorted by position ascending. + * `files` is every PrintFile (supporting + prepared) belonging to this print. + * `preparedFile` is the print's explicit prepared PrintFile row, if any (preparedFileId). + */ +export function toPrintOut( + print: Print, + plates: Plate[], + files: PrintFile[], + preparedFile: PrintFile | null, +): PrintOut { + const sortedPlates = plates.toSorted((a, b) => a.position - b.position); + const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p)); + const supportingCount = files.filter((f) => f.role === "SUPPORTING").length; + + let prepared: PreparedPrintOut | null = null; + let slicerUrl: string | null = null; + let slicerFilename: string | null = null; + + if (preparedFile) { + const meta = (preparedFile.metadata as Record | null) || {}; + prepared = { + printer: (meta.printer as string | null) ?? null, + material: (meta.material as string | null) ?? null, + nozzle_mm: (meta.nozzle_mm as number | null) ?? null, + layer_height_mm: (meta.layer_height_mm as number | null) ?? null, + estimated_seconds: (meta.estimated_seconds as number | null) ?? null, + format: (meta.format as string | null) ?? null, + removable: true, + }; + slicerUrl = `/print/${print.id}/prepared-print`; + slicerFilename = preparedFile.filename; + } else if (print.preparedMetadata) { + const meta = print.preparedMetadata as Record; + prepared = { + printer: (meta.printer as string | null) ?? null, + material: (meta.material as string | null) ?? null, + nozzle_mm: (meta.nozzle_mm as number | null) ?? null, + layer_height_mm: (meta.layer_height_mm as number | null) ?? null, + estimated_seconds: (meta.estimated_seconds as number | null) ?? null, + format: (meta.format as string | null) ?? null, + removable: false, + }; + slicerUrl = plateOuts[0]?.url ?? null; + slicerFilename = preparedFilename(print.name, { format: meta.format as string }); + } else { + slicerUrl = plateOuts[0]?.url ?? null; + slicerFilename = sortedPlates[0]?.filename ?? null; + } + + return { + id: print.id, + name: print.name, + title: print.title, + notes: print.notes, + creator: print.creator, + collection: print.collection, + tags: print.tags, + folder_id: print.folderId, + storage_path: storageParentDir(sortedPlates), + plates: plateOuts, + thumb_url: plateOuts[0]?.thumb_url ?? null, + supporting_file_count: supportingCount, + prepared_print: prepared, + slicer_url: slicerUrl, + slicer_filename: slicerFilename, + }; +} + +export function toFolderOut(folder: Folder): FolderOut { + return { + id: folder.id, + name: folder.name, + tags: folder.tags, + parent_id: folder.parentId, + }; +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..3574afa --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { z } from "zod"; +import { AUTH_ENABLED, AUTH_PASSWORD, AUTH_TOKEN_TTL, AUTH_USERNAME } from "../config"; +import { createToken, requireAuth, verifyToken } from "../auth"; +import { HttpError } from "../utils/fileUtils"; +import { parseBody } from "../utils/validate"; + +const router = Router(); + +const loginSchema = z.object({ username: z.string(), password: z.string() }); + +router.post("/login", (req, res) => { + if (!AUTH_ENABLED) throw new HttpError(503, "Authentication is not configured on the server"); + const body = parseBody(loginSchema, req.body); + if (body.username !== AUTH_USERNAME || body.password !== AUTH_PASSWORD) { + throw new HttpError(401, "Invalid username or password"); + } + const token = createToken(body.username); + res.json({ token, expires_in: AUTH_TOKEN_TTL }); +}); + +router.post("/refresh", requireAuth, (req, res) => { + if (!AUTH_ENABLED) throw new HttpError(503, "Authentication is not configured on the server"); + const header = req.header("authorization"); + const headerToken = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined; + const queryToken = typeof req.query.token === "string" ? req.query.token : undefined; + const token = headerToken || queryToken; + if (!token) throw new HttpError(401, "Unauthorized"); + const payload = verifyToken(token); + if (!payload?.sub) throw new HttpError(401, "Invalid or expired token"); + const newToken = createToken(payload.sub); + res.json({ token: newToken, expires_in: AUTH_TOKEN_TTL }); +}); + +export default router; diff --git a/backend/src/routes/folders.ts b/backend/src/routes/folders.ts new file mode 100644 index 0000000..d216fa3 --- /dev/null +++ b/backend/src/routes/folders.ts @@ -0,0 +1,99 @@ +import { Router } from "express"; +import { z } from "zod"; +import { prisma } from "../db"; +import { requireAuth } from "../auth"; +import { HttpError } from "../utils/fileUtils"; +import { parseBody } from "../utils/validate"; +import { asyncHandler } from "../utils/asyncHandler"; +import { validateParentFolder } from "../services/folderService"; +import { availableModelName, reorganizeManagedPrints } from "../services/printService"; +import { toFolderOut } from "../dto"; +import { sendPrintsZip } from "../services/downloadZip"; +import type { Prisma } from "@prisma/client"; + +const router = Router(); +router.use(requireAuth); + +const folderSchema = z.object({ + name: z.string().min(1), + tags: z.array(z.string()).default([]), + parent_id: z.string().nullable().optional(), +}); + +router.get( + "/folders", + asyncHandler(async (_req, res) => { + const folders = await prisma.folder.findMany(); + res.json(folders.map(toFolderOut)); + }), +); + +router.post( + "/folders", + asyncHandler(async (req, res) => { + const body = parseBody(folderSchema, req.body); + const parentId = await validateParentFolder(body.parent_id ?? null); + const folder = await prisma.folder.create({ + data: { name: body.name, tags: body.tags.map((t) => t.trim()).filter(Boolean), parentId }, + }); + res.json(toFolderOut(folder)); + }), +); + +router.patch( + "/folder/:id", + asyncHandler(async (req, res) => { + const body = parseBody(folderSchema, req.body); + const folder = await prisma.folder.findUnique({ where: { id: req.params.id } }); + if (!folder) throw new HttpError(404, "Not found"); + const parentId = await validateParentFolder(body.parent_id ?? null, folder.id); + const updated = await prisma.folder.update({ + where: { id: folder.id }, + data: { name: body.name, tags: body.tags.map((t) => t.trim()).filter(Boolean), parentId }, + }); + await reorganizeManagedPrints(); + res.json(toFolderOut(updated)); + }), +); + +router.delete( + "/folder/:id", + asyncHandler(async (req, res) => { + const folder = await prisma.folder.findUnique({ where: { id: req.params.id } }); + if (!folder) throw new HttpError(404, "Not found"); + + await prisma.folder.updateMany({ where: { parentId: folder.id }, data: { parentId: null } }); + + const prints = await prisma.print.findMany({ where: { folderId: folder.id } }); + for (const print of prints) { + const nextName = await availableModelName(print.name, null, print.id); + const data: Prisma.PrintUpdateInput = { folder: { disconnect: true } }; + if (nextName !== print.name) { + data.name = nextName; + data.nameNormalized = nextName.trim().toLowerCase(); + data.title = nextName; + } + await prisma.print.update({ where: { id: print.id }, data }); + } + + await prisma.folder.delete({ where: { id: folder.id } }); + await reorganizeManagedPrints(); + res.json({ ok: true }); + }), +); + +router.get( + "/folder/:id/download", + asyncHandler(async (req, res) => { + const folder = await prisma.folder.findUnique({ where: { id: req.params.id } }); + if (!folder) throw new HttpError(404, "Folder not found"); + const prints = await prisma.print.findMany({ + where: { folderId: folder.id }, + include: { plates: { orderBy: { position: "asc" } }, folder: true }, + }); + const downloadName = `${(folder.name || "folder").replace(/ /g, "_").slice(0, 50) || "folder"}.zip`; + await sendPrintsZip(res, prints, downloadName); + }), +); + +export default router; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..bc258d3 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { AUTH_ENABLED } from "../config"; + +const router = Router(); + +router.get("/health", (_req, res) => { + res.json({ ok: true, auth_required: AUTH_ENABLED }); +}); + +export default router; diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts new file mode 100644 index 0000000..2d11c4f --- /dev/null +++ b/backend/src/routes/imports.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth } from "../auth"; +import { HttpError } from "../utils/fileUtils"; +import { normalizeImportUrl } from "../utils/urlUtils"; +import { parseBody } from "../utils/validate"; +import { asyncHandler } from "../utils/asyncHandler"; +import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../services/importService"; +import { extractZipEntriesToPrints, listZipEntries } from "../services/zipService"; +import { toPrintOut } from "../dto"; + +const router = Router(); +router.use(requireAuth); + +const importRequestSchema = z.object({ + url: z.string(), + title: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + tags: z.array(z.string()).default([]), + folder_id: z.string().nullable().optional(), + filename: z.string().nullable().optional(), + makerworld_cookie: z.string().nullable().optional(), + thingiverse_cookie: z.string().nullable().optional(), +}); + +router.post( + "/import", + asyncHandler(async (req, res) => { + const body = parseBody(importRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const { print, plates } = await importPrintFromUrl(url, body); + res.json(toPrintOut(print, plates, [], null)); + }), +); + +router.post( + "/import/inspect", + asyncHandler(async (req, res) => { + const body = parseBody(importRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const result = await inspectImportLink(url, body); + res.json(result); + }), +); + +router.post( + "/import/zip/entries", + asyncHandler(async (req, res) => { + const body = parseBody(importRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const { tempPath, filename } = await downloadImportToTemp(url, body); + try { + if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip"); + const entries = await listZipEntries(tempPath); + if (!entries.length) throw new HttpError(400, "No files found in zip"); + res.json({ filename, entries }); + } finally { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + }), +); + +const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) }); +router.post( + "/import/zip", + asyncHandler(async (req, res) => { + const body = parseBody(zipExtractRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const { tempPath, filename } = await downloadImportToTemp(url, body); + try { + if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip"); + const { prints, failed } = await extractZipEntriesToPrints(tempPath, body.entries, { + title: body.title, + notes: body.notes, + tags: body.tags, + folderId: body.folder_id, + }); + res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null)), failed }); + } finally { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + }), +); + +export default router; diff --git a/backend/src/routes/plates.ts b/backend/src/routes/plates.ts new file mode 100644 index 0000000..9e76825 --- /dev/null +++ b/backend/src/routes/plates.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Router } from "express"; +import { z } from "zod"; +import { prisma } from "../db"; +import { requireAuth } from "../auth"; +import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils"; +import { parseBody } from "../utils/validate"; +import { asyncHandler } from "../utils/asyncHandler"; +import { modelUpload, thumbnailUpload } from "../uploadMiddleware"; +import { + addPlatesToPrint, + deletePlateFiles, + refreshAutoPreparedMetadata, + type NewPlateInput, +} from "../services/printCreation"; +import { availablePlateFilename, plateThumbPath, relocatePrint, saveThumbFromBytes } from "../services/printService"; +import { printOutById } from "../services/printLoader"; + +const router = Router(); +router.use(requireAuth); + +// ---- POST /print/:id/plates (append) -------------------------------------------------------- + +router.post( + "/print/:id/plates", + modelUpload.array("files"), + asyncHandler(async (req, res) => { + const files = (req.files as Express.Multer.File[]) || []; + try { + if (!files.length) throw new HttpError(400, "No files uploaded"); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + + const plateInputs: NewPlateInput[] = files.map((f) => { + const safeName = sanitizeFilename(f.originalname); + return { filename: safeName, mime: mimeFromContentType(f.mimetype, safeName), tempFilePath: f.path }; + }); + await addPlatesToPrint(print.id, plateInputs); + res.json({ print: await printOutById(print.id) }); + } finally { + for (const f of files) { + if (fs.existsSync(f.path)) fs.rmSync(f.path, { force: true }); + } + } + }), +); + +// ---- DELETE /print/:id/plates/:plateId -------------------------------------------------------- + +router.delete( + "/print/:id/plates/:plateId", + asyncHandler(async (req, res) => { + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }); + const target = plates.find((p) => p.id === req.params.plateId); + if (!target) throw new HttpError(404, "Plate not found"); + if (plates.length <= 1) { + throw new HttpError(409, "Cannot remove the only plate of a print; delete the print instead."); + } + + const remaining = plates.filter((p) => p.id !== target.id); + // Delete the target first to free its position slot, then renumber densely via the same + // two-phase (negative temp position) trick as reorder, to avoid transient collisions with + // the (printId, position) unique index regardless of update ordering. + await prisma.plate.delete({ where: { id: target.id } }); + await prisma.$transaction( + remaining.map((p, idx) => prisma.plate.update({ where: { id: p.id }, data: { position: -(idx + 1) } })), + ); + await prisma.$transaction( + remaining.map((p, idx) => prisma.plate.update({ where: { id: p.id }, data: { position: idx } })), + ); + await deletePlateFiles(target); + await fs.promises.rm(plateThumbPath(target.id), { force: true }).catch(() => undefined); + + if (target.position === 0) { + await refreshAutoPreparedMetadata(print.id); + } + res.json({ print: await printOutById(print.id) }); + }), +); + +// ---- POST /print/:id/plates/reorder ------------------------------------------------------------ + +const reorderSchema = z.object({ plate_ids: z.array(z.string()).min(1) }); +router.post( + "/print/:id/plates/reorder", + asyncHandler(async (req, res) => { + const body = parseBody(reorderSchema, req.body); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const plates = await prisma.plate.findMany({ where: { printId: print.id } }); + const byId = new Map(plates.map((p) => [p.id, p])); + if (body.plate_ids.length !== plates.length || body.plate_ids.some((id) => !byId.has(id))) { + throw new HttpError(400, "plate_ids must contain exactly the print's current plate ids"); + } + const previousFirst = plates.toSorted((a, b) => a.position - b.position)[0]?.id; + + // Two-phase update avoids transient collisions with the (printId, position) unique index. + await prisma.$transaction( + body.plate_ids.map((id, idx) => prisma.plate.update({ where: { id }, data: { position: -(idx + 1) } })), + ); + await prisma.$transaction( + body.plate_ids.map((id, idx) => prisma.plate.update({ where: { id }, data: { position: idx } })), + ); + + if (previousFirst !== body.plate_ids[0]) { + await refreshAutoPreparedMetadata(print.id); + } + res.json({ print: await printOutById(print.id) }); + }), +); + +// ---- POST /print/:id/plate/:plateId/rename ----------------------------------------------------- + +const renameSchema = z.object({ filename: z.string().min(1) }); +router.post( + "/print/:id/plate/:plateId/rename", + asyncHandler(async (req, res) => { + const body = parseBody(renameSchema, req.body); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } }); + if (!plate || plate.printId !== print.id) throw new HttpError(404, "Plate not found"); + + const sanitized = sanitizeFilename(body.filename); + const nextFilename = await availablePlateFilename(print.id, sanitized, plate.id); + if (nextFilename !== plate.filename) { + await prisma.plate.update({ where: { id: plate.id }, data: { filename: nextFilename } }); + await relocatePrint(print, [{ ...plate, filename: nextFilename }]); + if (plate.position === 0) { + await refreshAutoPreparedMetadata(print.id); + } + } + res.json({ print: await printOutById(print.id) }); + }), +); + +// ---- Plate thumbnails -------------------------------------------------------------------------- + +router.get( + "/plate/:plateId/thumb.jpg", + asyncHandler(async (req, res) => { + const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } }); + if (!plate) throw new HttpError(404, "Not found"); + const thumbPath = plateThumbPath(plate.id); + if (!fs.existsSync(thumbPath)) throw new HttpError(404, "Not found"); + res.setHeader("Content-Type", "image/jpeg"); + res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); + res.sendFile(path.resolve(thumbPath)); + }), +); + +router.post( + "/plate/:plateId/thumbnail-generated", + thumbnailUpload.single("file"), + asyncHandler(async (req, res) => { + const file = req.file; + if (!file) throw new HttpError(400, "No file uploaded"); + if (!["image/png", "image/jpeg", "image/webp"].includes((file.mimetype || "").toLowerCase())) { + throw new HttpError(415, "Generated thumbnail must be PNG, JPEG, or WebP"); + } + if (file.size > 8 * 1024 * 1024) throw new HttpError(413, "Generated thumbnail exceeds 8 MB"); + const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } }); + if (!plate) throw new HttpError(404, "Not found"); + const ok = await saveThumbFromBytes(plate.id, file.buffer); + if (!ok) throw new HttpError(400, "Invalid thumbnail image"); + res.json({ print: await printOutById(plate.printId) }); + }), +); + +export default router; diff --git a/backend/src/routes/printFiles.ts b/backend/src/routes/printFiles.ts new file mode 100644 index 0000000..d0e3ad6 --- /dev/null +++ b/backend/src/routes/printFiles.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Router } from "express"; +import { prisma } from "../db"; +import { requireAuth } from "../auth"; +import { HttpError } from "../utils/fileUtils"; +import { asyncHandler } from "../utils/asyncHandler"; +import { modelUpload } from "../uploadMiddleware"; +import { + deletePreparedFile, + deleteSupportingFile, + listSupportingFiles, + managedPrintFilePath, + saveFileFromTemp, +} from "../services/printFileService"; +import { toPrintFileOut } from "../dto"; +import { printOutById } from "../services/printLoader"; + +const router = Router(); +router.use(requireAuth); + +router.get( + "/print/:id/files", + asyncHandler(async (req, res) => { + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const files = await listSupportingFiles(print.id); + res.json(files.map(toPrintFileOut)); + }), +); + +router.post( + "/print/:id/files", + modelUpload.single("file"), + asyncHandler(async (req, res) => { + const file = req.file; + try { + if (!file) throw new HttpError(400, "No file uploaded"); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + await saveFileFromTemp(print.id, file.path, file.originalname || "supporting-file", file.mimetype); + res.json({ print: await printOutById(print.id) }); + } finally { + if (file && fs.existsSync(file.path)) fs.rmSync(file.path, { force: true }); + } + }), +); + +router.get( + "/print/:id/files/:fileId", + asyncHandler(async (req, res) => { + const record = await prisma.printFile.findUnique({ where: { id: req.params.fileId } }); + if (!record || record.printId !== req.params.id || record.role !== "SUPPORTING") { + throw new HttpError(404, "Not found"); + } + const filePath = managedPrintFilePath(record); + if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found"); + res.setHeader("Content-Type", record.mime || "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(record.filename)}"`); + res.sendFile(path.resolve(filePath)); + }), +); + +router.delete( + "/print/:id/files/:fileId", + asyncHandler(async (req, res) => { + const print = await deleteSupportingFile(req.params.id, req.params.fileId); + res.json({ print: await printOutById(print.id) }); + }), +); + +router.get( + "/print/:id/prepared-print", + asyncHandler(async (req, res) => { + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print || !print.preparedFileId) throw new HttpError(404, "Not found"); + const record = await prisma.printFile.findUnique({ where: { id: print.preparedFileId } }); + if (!record || record.printId !== print.id || record.role !== "PREPARED") throw new HttpError(404, "Not found"); + const filePath = managedPrintFilePath(record); + if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found"); + res.setHeader("Content-Type", record.mime || "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(record.filename)}"`); + res.sendFile(path.resolve(filePath)); + }), +); + +router.delete( + "/print/:id/prepared-print", + asyncHandler(async (req, res) => { + const print = await deletePreparedFile(req.params.id); + res.json({ print: await printOutById(print.id) }); + }), +); + +export default router; diff --git a/backend/src/routes/prints.ts b/backend/src/routes/prints.ts new file mode 100644 index 0000000..5705feb --- /dev/null +++ b/backend/src/routes/prints.ts @@ -0,0 +1,340 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Router, type Request } from "express"; +import { z } from "zod"; +import { prisma } from "../db"; +import { requireAuth } from "../auth"; +import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils"; +import { parseBody } from "../utils/validate"; +import { asyncHandler } from "../utils/asyncHandler"; +import { modelUpload } from "../uploadMiddleware"; +import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation"; +import { plateThumbPath, relocatePrint, uniqueModelName } from "../services/printService"; +import { toPrintOut } from "../dto"; +import { loadFullPrint, printOutById } from "../services/printLoader"; +import { deleteAllPrintFiles } from "../services/printFileService"; +import { sendPrintsZip } from "../services/downloadZip"; +import type { Prisma } from "@prisma/client"; + +const router = Router(); +router.use(requireAuth); + +function buildPrintWhere(req: Request): Prisma.PrintWhereInput { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const tagsParam = typeof req.query.tags === "string" ? req.query.tags : ""; + const folderId = typeof req.query.folder_id === "string" ? req.query.folder_id : undefined; + + const where: Prisma.PrintWhereInput = {}; + if (folderId) where.folderId = folderId; + const tagList = tagsParam + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + if (tagList.length) where.tags = { hasEvery: tagList }; + if (q) { + where.OR = [ + { name: { contains: q, mode: "insensitive" } }, + { title: { contains: q, mode: "insensitive" } }, + { notes: { contains: q, mode: "insensitive" } }, + { creator: { contains: q, mode: "insensitive" } }, + { collection: { contains: q, mode: "insensitive" } }, + ]; + } + return where; +} + +// ---- POST /upload --------------------------------------------------------------------------- + +router.post( + "/upload", + modelUpload.array("files"), + asyncHandler(async (req, res) => { + const files = (req.files as Express.Multer.File[]) || []; + const body = req.body as Record; + try { + if (!files.length) throw new HttpError(400, "No files uploaded"); + const mode = body.mode === "multiplate" ? "multiplate" : "separate"; + if (files.length > 1 && body.mode !== "separate" && body.mode !== "multiplate") { + throw new HttpError(400, "mode is required when uploading more than one file"); + } + const tags = (body.tags || "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const meta = { + title: body.title || null, + notes: body.notes || null, + tags, + folderId: body.folder_id || null, + }; + + const printsOut = []; + if (files.length === 1 || mode === "separate") { + for (const file of files) { + const safeName = sanitizeFilename(file.originalname); + const mime = mimeFromContentType(file.mimetype, safeName); + const { print, plates } = await createPrint(meta, path.parse(safeName).name, [ + { filename: safeName, mime, tempFilePath: file.path }, + ]); + printsOut.push(toPrintOut(print, plates, [], null)); + } + } else { + const plateInputs: NewPlateInput[] = files.map((f) => { + const safeName = sanitizeFilename(f.originalname); + return { filename: safeName, mime: mimeFromContentType(f.mimetype, safeName), tempFilePath: f.path }; + }); + const nameHint = path.parse(plateInputs[0].filename).name; + const { print, plates } = await createPrint(meta, nameHint, plateInputs); + printsOut.push(toPrintOut(print, plates, [], null)); + } + res.json({ prints: printsOut }); + } finally { + for (const f of files) { + if (fs.existsSync(f.path)) fs.rmSync(f.path, { force: true }); + } + } + }), +); + +// ---- GET /prints ------------------------------------------------------------------------------ + +router.get( + "/prints", + asyncHandler(async (req, res) => { + const limitRaw = req.query.limit; + const offsetRaw = req.query.offset; + let limit: number | undefined; + let offset: number | undefined; + if (limitRaw !== undefined) { + limit = Number(limitRaw); + if (!Number.isFinite(limit) || limit < 1 || limit > 1000) throw new HttpError(400, "Invalid limit"); + } + if (offsetRaw !== undefined) { + offset = Number(offsetRaw); + if (!Number.isFinite(offset) || offset < 0) throw new HttpError(400, "Invalid offset"); + } + + const where = buildPrintWhere(req); + const prints = await prisma.print.findMany({ where, include: { plates: { orderBy: { position: "asc" } } } }); + const printIds = prints.map((p) => p.id); + const files = printIds.length ? await prisma.printFile.findMany({ where: { printId: { in: printIds } } }) : []; + const filesByPrint = new Map(); + for (const f of files) { + const list = filesByPrint.get(f.printId) ?? []; + list.push(f); + filesByPrint.set(f.printId, list); + } + + const sorted = prints.toSorted((a, b) => { + const nameCmp = a.name.localeCompare(b.name); + if (nameCmp !== 0) return nameCmp; + const af = a.plates[0]?.filename ?? ""; + const bf = b.plates[0]?.filename ?? ""; + const fCmp = af.localeCompare(bf); + if (fCmp !== 0) return fCmp; + return a.id.localeCompare(b.id); + }); + + let paged = sorted; + if (limit !== undefined) { + const start = offset ?? 0; + paged = sorted.slice(start, start + limit); + const hasMore = sorted.length > start + paged.length; + res.setHeader("X-Has-More", hasMore ? "true" : "false"); + res.setHeader("X-Next-Offset", String(start + paged.length)); + } + + const out = paged.map((p) => { + const printFiles = filesByPrint.get(p.id) ?? []; + const preparedFile = p.preparedFileId ? printFiles.find((f) => f.id === p.preparedFileId) ?? null : null; + return toPrintOut(p, p.plates, printFiles, preparedFile); + }); + res.json(out); + }), +); + +// ---- GET /tags ---------------------------------------------------------------------------- + +router.get( + "/tags", + asyncHandler(async (req, res) => { + const where = buildPrintWhere(req); + const rows = await prisma.print.findMany({ where, select: { tags: true } }); + const found = new Set(); + for (const row of rows) { + for (const tag of row.tags) { + const cleaned = tag.trim(); + if (cleaned) found.add(cleaned); + } + } + res.json([...found].toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))); + }), +); + +// ---- POST /download/zip -------------------------------------------------------------------- + +const downloadSchema = z.object({ + print_ids: z.array(z.string()).optional(), + tag: z.string().optional(), + folder_id: z.string().optional(), + filename: z.string().optional(), +}); + +router.post( + "/download/zip", + asyncHandler(async (req, res) => { + const body = parseBody(downloadSchema, req.body); + if (!(body.print_ids?.length || body.tag || body.folder_id)) { + throw new HttpError(400, "Provide print_ids, tag, or folder_id to download."); + } + const where: Prisma.PrintWhereInput = {}; + if (body.print_ids?.length) where.id = { in: body.print_ids }; + if (body.folder_id) where.folderId = body.folder_id; + let prints = await prisma.print.findMany({ + where, + include: { plates: { orderBy: { position: "asc" } }, folder: true }, + }); + if (body.tag) { + const tag = body.tag.trim(); + prints = prints.filter((p) => p.tags.includes(tag)); + } + + let downloadName = body.filename || "printstash.zip"; + if (body.tag) { + const safeTag = body.tag.replace(/ /g, "_").slice(0, 50) || "tag"; + downloadName = `${safeTag}.zip`; + } + if (body.folder_id) { + const folder = prints.find((p) => p.folder)?.folder; + if (folder) { + const safeName = folder.name.replace(/ /g, "_").slice(0, 50) || "folder"; + downloadName = `${safeName}.zip`; + } + } + await sendPrintsZip(res, prints, downloadName); + }), +); + +// ---- Plate file streaming + print thumbnail ----------------------------------------------- + +router.get( + "/print/:id/plate/:plateId/file/:filename", + asyncHandler(async (req, res) => { + const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } }); + if (!plate || plate.printId !== req.params.id) throw new HttpError(404, "Not found"); + const filePath = resolvePlateFilePath(plate); + if (!filePath) throw new HttpError(404, "Not found"); + res.setHeader("Content-Type", plate.mime || "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(plate.filename)}"`); + res.setHeader("Cache-Control", "private, max-age=86400"); + res.sendFile(path.resolve(filePath)); + }), +); + +router.get( + "/print/:id/thumb.jpg", + asyncHandler(async (req, res) => { + const plate0 = await prisma.plate.findFirst({ where: { printId: req.params.id }, orderBy: { position: "asc" } }); + if (!plate0) throw new HttpError(404, "Not found"); + const thumbPath = plateThumbPath(plate0.id); + if (!fs.existsSync(thumbPath)) throw new HttpError(404, "Not found"); + res.setHeader("Content-Type", "image/jpeg"); + res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); + res.sendFile(path.resolve(thumbPath)); + }), +); + +// ---- Print metadata mutation routes --------------------------------------------------------- + +const tagsSchema = z.object({ tags: z.array(z.string()) }); +router.post( + "/print/:id/tags", + asyncHandler(async (req, res) => { + const body = parseBody(tagsSchema, req.body); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const updated = await prisma.print.update({ + where: { id: print.id }, + data: { tags: body.tags.map((t) => t.trim()).filter(Boolean) }, + }); + const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }); + await relocatePrint(updated, plates); + res.json({ print: await printOutById(print.id) }); + }), +); + +const metaSchema = z.object({ + name: z.string().optional(), + title: z.string().optional(), + notes: z.string().optional(), + creator: z.string().optional(), + collection: z.string().optional(), +}); +router.post( + "/print/:id/meta", + asyncHandler(async (req, res) => { + const body = parseBody(metaSchema, req.body); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + + const data: Prisma.PrintUpdateInput = {}; + const requestedName = body.name !== undefined ? body.name : body.title; + if (requestedName !== undefined) { + const nextName = await uniqueModelName(requestedName, print.folderId, print.id); + if (nextName !== print.name) { + data.name = nextName; + data.nameNormalized = nextName.trim().toLowerCase(); + data.title = nextName; + } + } + if (body.notes !== undefined) data.notes = body.notes; + if (body.creator !== undefined) data.creator = body.creator.trim() || null; + if (body.collection !== undefined) data.collection = body.collection.trim() || null; + + const updated = await prisma.print.update({ where: { id: print.id }, data }); + const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }); + await relocatePrint(updated, plates); + res.json({ print: await printOutById(print.id) }); + }), +); + +const folderUpdateSchema = z.object({ folder_id: z.string().nullable().optional() }); +router.post( + "/print/:id/folder", + asyncHandler(async (req, res) => { + const body = parseBody(folderUpdateSchema, req.body); + const print = await prisma.print.findUnique({ where: { id: req.params.id } }); + if (!print) throw new HttpError(404, "Print not found"); + const folderId = body.folder_id || null; + const data: Prisma.PrintUpdateInput = {}; + if (folderId) { + const folder = await prisma.folder.findUnique({ where: { id: folderId } }); + if (!folder) throw new HttpError(400, "Folder not found"); + await uniqueModelName(print.name, folder.id, print.id); + data.folder = { connect: { id: folder.id } }; + } else { + await uniqueModelName(print.name, null, print.id); + data.folder = { disconnect: true }; + } + const updated = await prisma.print.update({ where: { id: print.id }, data }); + const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } }); + await relocatePrint(updated, plates); + res.json({ print: await printOutById(print.id) }); + }), +); + +router.delete( + "/print/:id", + asyncHandler(async (req, res) => { + const full = await loadFullPrint(req.params.id); + await deleteAllPrintFiles(req.params.id); + await prisma.print.delete({ where: { id: req.params.id } }); + for (const plate of full.plates) { + await deletePlateFiles(plate); + await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined); + } + res.json({ ok: true }); + }), +); + +export default router; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..a62b4d6 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,78 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth } from "../auth"; +import { MOUNT_IMPORT_COPY, MOUNT_IMPORT_ENABLED, MOUNT_IMPORT_PATH } from "../config"; +import { parseBody } from "../utils/validate"; +import { asyncHandler } from "../utils/asyncHandler"; +import { getMountImportCopy, getMountImportEnabled, setMountImportCopy, setMountImportEnabled } from "../services/settingsService"; +import { + DEFAULT_STORAGE_TEMPLATE, + STORAGE_TEMPLATE_TOKENS, + getStorageTemplate, + reorganizeManagedPrints, + samplePlateStoragePaths, + setStorageTemplate, + validateStorageTemplate, +} from "../services/printService"; + +const router = Router(); +router.use(requireAuth); + +router.get( + "/settings/mount-import", + asyncHandler(async (_req, res) => { + res.json({ + enabled: await getMountImportEnabled(MOUNT_IMPORT_ENABLED), + copy_files: await getMountImportCopy(MOUNT_IMPORT_COPY), + path: MOUNT_IMPORT_PATH || null, + }); + }), +); + +const mountImportSchema = z.object({ enabled: z.boolean(), copy_files: z.boolean() }); +router.post( + "/settings/mount-import", + asyncHandler(async (req, res) => { + const body = parseBody(mountImportSchema, req.body); + await setMountImportEnabled(body.enabled); + await setMountImportCopy(body.copy_files); + res.json({ + enabled: await getMountImportEnabled(MOUNT_IMPORT_ENABLED), + copy_files: await getMountImportCopy(MOUNT_IMPORT_COPY), + path: MOUNT_IMPORT_PATH || null, + }); + }), +); + +function storageSettingsOut(template: string, moved = 0, skipped = 0) { + return { + template, + default_template: DEFAULT_STORAGE_TEMPLATE, + allowed_tokens: [...STORAGE_TEMPLATE_TOKENS], + plate_paths: samplePlateStoragePaths(template), + moved, + skipped, + }; +} + +router.get( + "/settings/storage", + asyncHandler(async (_req, res) => { + const template = validateStorageTemplate(await getStorageTemplate()); + res.json(storageSettingsOut(template)); + }), +); + +const storageSettingsSchema = z.object({ template: z.string(), apply_existing: z.boolean().default(false) }); +router.post( + "/settings/storage", + asyncHandler(async (req, res) => { + const body = parseBody(storageSettingsSchema, req.body); + const template = validateStorageTemplate(body.template); + await setStorageTemplate(template); + const { moved, skipped } = body.apply_existing ? await reorganizeManagedPrints(template) : { moved: 0, skipped: 0 }; + res.json(storageSettingsOut(template, moved, skipped)); + }), +); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..962e598 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,14 @@ +import { createApp } from "./app"; +import { API_PORT } from "./config"; +import { scanMountImports } from "./services/mountImport"; +import "./db"; // ensures storage directories exist before we start serving + +const app = createApp(); + +app.listen(API_PORT, () => { + console.log(`PrintStash API listening on port ${API_PORT}`); + // Fire-and-forget background scan, mirrors MakersVault's lifespan background thread. + scanMountImports().catch((err) => { + console.error("[mount-import] Unhandled error during scan:", err); + }); +}); diff --git a/backend/src/services/downloadZip.ts b/backend/src/services/downloadZip.ts new file mode 100644 index 0000000..34393e0 --- /dev/null +++ b/backend/src/services/downloadZip.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import type { Response } from "express"; +import { prisma } from "../db"; +import { HttpError } from "../utils/fileUtils"; +import { writeZip, type ZipEntryDescriptor } from "../utils/zipWriter"; +import { resolvePlateFilePath } from "./printCreation"; +import { managedPrintFilePath } from "./printFileService"; +import type { Folder, Plate, Print } from "@prisma/client"; + +type PrintWithPlatesAndFolder = Print & { plates: Plate[]; folder: Folder | null }; + +/** + * Builds zip entries for a set of prints: `{folder_or_unassigned}/{print.name}/{plate.filename}` + * for every plate, and `.../supporting/{file.filename}` for each print's supporting files. + */ +export async function buildZipEntries(prints: PrintWithPlatesAndFolder[]): Promise { + const printIds = prints.map((p) => p.id); + const supportingByPrint = new Map(); + if (printIds.length) { + const supporting = await prisma.printFile.findMany({ + where: { printId: { in: printIds }, role: "SUPPORTING" }, + }); + for (const file of supporting) { + const list = supportingByPrint.get(file.printId) ?? []; + list.push({ filename: file.filename, storagePath: file.storagePath }); + supportingByPrint.set(file.printId, list); + } + } + + const entries: ZipEntryDescriptor[] = []; + for (const print of prints) { + const folderPart = print.folder?.name || "unassigned"; + const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position); + for (const plate of sortedPlates) { + const filePath = resolvePlateFilePath(plate); + if (filePath) entries.push({ arcname: `${folderPart}/${print.name}/${plate.filename}`, filePath }); + } + for (const file of supportingByPrint.get(print.id) ?? []) { + const filePath = managedPrintFilePath(file); + if (fs.existsSync(filePath)) { + entries.push({ arcname: `${folderPart}/${print.name}/supporting/${file.filename}`, filePath }); + } + } + } + return entries; +} + +/** Builds a zip from `prints` and streams it as the HTTP response, deleting the temp file after. */ +export async function sendPrintsZip(res: Response, prints: PrintWithPlatesAndFolder[], downloadName: string): Promise { + const entries = await buildZipEntries(prints); + if (!entries.length) throw new HttpError(404, "No files available for download"); + + const tmpPath = path.join(os.tmpdir(), `printstash-zip-${crypto.randomBytes(8).toString("hex")}.zip`); + await writeZip(tmpPath, entries); + res.download(tmpPath, downloadName, (err) => { + fs.rm(tmpPath, { force: true }, () => undefined); + if (err && !res.headersSent) { + res.status(500).json({ detail: "Failed to send zip file" }); + } + }); +} diff --git a/backend/src/services/folderService.ts b/backend/src/services/folderService.ts new file mode 100644 index 0000000..7de8940 --- /dev/null +++ b/backend/src/services/folderService.ts @@ -0,0 +1,25 @@ +import { prisma } from "../db"; +import { HttpError } from "../utils/fileUtils"; + +/** Ensures a parent folder exists and that assigning it does not introduce a cycle. */ +export async function validateParentFolder( + parentId: string | null | undefined, + folderId?: string | null, +): Promise { + if (!parentId) return null; + const parent = await prisma.folder.findUnique({ where: { id: parentId } }); + if (!parent) throw new HttpError(400, "Parent folder not found"); + if (folderId && parentId === folderId) throw new HttpError(400, "Folder cannot be its own parent"); + + const visited = new Set(folderId ? [folderId] : []); + let ancestor = parent; + while (ancestor && ancestor.parentId) { + if (visited.has(ancestor.parentId)) throw new HttpError(400, "Invalid parent: would create a cycle"); + if (folderId && ancestor.parentId === folderId) throw new HttpError(400, "Invalid parent: would create a cycle"); + visited.add(ancestor.parentId); + const next = await prisma.folder.findUnique({ where: { id: ancestor.parentId } }); + if (!next) break; + ancestor = next; + } + return parentId; +} diff --git a/backend/src/services/importResolvers.ts b/backend/src/services/importResolvers.ts new file mode 100644 index 0000000..d749d27 --- /dev/null +++ b/backend/src/services/importResolvers.ts @@ -0,0 +1,761 @@ +import path from "node:path"; +import * as cheerio from "cheerio"; +import { + IMPORT_ALLOWED_EXTS, + IMPORT_BLOCKED_EXTS, + IMPORT_BROWSER_USER_AGENT, + IMPORT_EXT_PRIORITY, + IMPORT_HTML_MAX_BYTES, + IMPORT_TIMEOUT_SECONDS, + IMPORT_USER_AGENT, +} from "../config"; +import { HttpError, isJsonContentType } from "../utils/fileUtils"; + +export type ImportCookies = { + makerworld_cookie?: string | null; + thingiverse_cookie?: string | null; +}; + +function firstNonEmptyLine(raw: string): string | null { + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (!lines.length) return null; + let value = lines[0]; + for (const line of lines) { + if (line.toLowerCase().startsWith("cookie:")) { + value = line.slice("cookie:".length).trim(); + break; + } + } + return value || null; +} + +export function resolveMakerworldCookie(body: ImportCookies): string | null { + const raw = (body.makerworld_cookie || process.env.MAKERWORLD_COOKIE || "").trim(); + if (!raw) return null; + return firstNonEmptyLine(raw); +} + +export function resolveThingiverseCookie(body: ImportCookies): string | null { + const raw = (body.thingiverse_cookie || process.env.THINGIVERSE_COOKIE || "").trim(); + if (!raw) return null; + return firstNonEmptyLine(raw); +} + +export function makerworldHtmlHeaders(referer?: string | null, cookie?: string | null): Record { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "sec-ch-ua": '"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + }; + if (referer) headers.Referer = referer; + if (cookie) headers.Cookie = cookie; + return headers; +} + +function makerworldApiHeaders(referer?: string | null, nonce?: string | null, cookie?: string | null): Record { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.9", + "X-BBL-Client-Type": "web", + "X-BBL-Client-Version": "00.00.00.01", + "X-BBL-App-Source": "makerworld", + "X-BBL-Client-Name": "MakerWorld", + }; + if (referer) headers.Referer = referer; + if (nonce) headers["X-Nonce"] = nonce; + if (cookie) headers.Cookie = cookie; + return headers; +} + +export function isPrintablesPageHost(host: string): boolean { + const lowered = (host || "").toLowerCase(); + return lowered === "printables.com" || lowered === "www.printables.com"; +} + +export function printablesHtmlHeaders(referer?: string | null): Record { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Upgrade-Insecure-Requests": "1", + }; + if (referer) headers.Referer = referer; + return headers; +} + +export function isThingiversePageHost(host: string): boolean { + const lowered = (host || "").toLowerCase(); + return lowered === "thingiverse.com" || lowered === "www.thingiverse.com"; +} + +export function thingiverseHtmlHeaders(referer?: string | null, cookie?: string | null): Record { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Upgrade-Insecure-Requests": "1", + }; + if (referer) headers.Referer = referer; + if (cookie) headers.Cookie = cookie; + return headers; +} + +function thingiverseApiHeaders(referer?: string | null, cookie?: string | null, token?: string | null): Record { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.9", + Origin: "https://www.thingiverse.com", + }; + if (referer) headers.Referer = referer; + if (cookie) headers.Cookie = cookie; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +// -- Small bounded fetch helpers (used only for resolver-side HTML/JSON probes; the actual +// model-file download streams straight to disk in importService.ts, not through here). ------ + +async function fetchCappedBuffer( + url: string, + headers: Record, + init?: { method?: string; body?: string }, +): Promise<{ status: number; headers: Headers; buffer: Buffer; url: string } | null> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000); + try { + const res = await fetch(url, { + method: init?.method || "GET", + headers, + body: init?.body, + redirect: "follow", + signal: controller.signal, + }); + const arrayBuf = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuf); + return { status: res.status, headers: res.headers, buffer, url: res.url || url }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function parseJsonErrorMessage(raw: Buffer): string | null { + try { + const data = JSON.parse(raw.toString("utf-8")); + if (data && typeof data === "object" && !Array.isArray(data)) { + for (const key of ["error", "detail", "message"]) { + const value = (data as Record)[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + } + } catch { + // ignore + } + return null; +} + +async function fetchJsonFromUrl( + url: string, + referer?: string | null, + headers?: Record, +): Promise { + const requestHeaders: Record = { "User-Agent": IMPORT_USER_AGENT, Accept: "application/json" }; + if (referer) requestHeaders.Referer = referer; + if (headers) Object.assign(requestHeaders, headers); + const result = await fetchCappedBuffer(url, requestHeaders); + if (!result) return null; + if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null; + const contentType = result.headers.get("content-type") || ""; + if (!isJsonContentType(contentType)) { + const trimmed = result.buffer.toString("utf-8").trimStart(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + } + try { + return JSON.parse(result.buffer.toString("utf-8")); + } catch { + return null; + } +} + +// -- Generic download-link sniffing (non-site-specific) -------------------------------------- + +function scoreDownloadUrl(url: string): number { + const lower = url.toLowerCase(); + let score = 0; + if (lower.includes("download")) score += 6; + if (lower.includes("files")) score += 2; + IMPORT_EXT_PRIORITY.forEach((ext, idx) => { + if (lower.includes(ext)) score += (IMPORT_EXT_PRIORITY.length - idx) * 10; + }); + return score; +} + +function isPotentialUrl(value: string): boolean { + if (/\s/.test(value)) return false; + if (/[<>{}"\\^`]/.test(value)) return false; + return true; +} + +function containsAllowedExt(url: string): boolean { + const lower = url.toLowerCase(); + for (const ext of IMPORT_ALLOWED_EXTS) { + const re = new RegExp(`${ext.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:$|[?#&])`); + if (re.test(lower)) return true; + } + return false; +} + +function isAllowedDownloadCandidate(url: string): boolean { + const lower = url.toLowerCase(); + let ext = ""; + try { + ext = path.extname(new URL(url).pathname).toLowerCase(); + } catch { + ext = ""; + } + if (ext) { + if (IMPORT_ALLOWED_EXTS.has(ext)) return true; + if (IMPORT_BLOCKED_EXTS.has(ext)) return false; + if (lower.includes("download")) return true; + return containsAllowedExt(lower); + } + if (containsAllowedExt(lower)) return true; + return lower.includes("download"); +} + +/** Collects href/src/data-* link-ish attributes from every element in the page (a faithful + * DOM-based port of MakersVault's LinkCollector, using cheerio instead of HTMLParser). */ +function collectPageLinks(html: string): string[] { + const links: string[] = []; + const attrNames = ["href", "src", "data-download", "data-download-url", "data-url", "data-file", "data-href"]; + try { + const $ = cheerio.load(html); + $("*").each((_, el) => { + const attribs = (el as unknown as { attribs?: Record }).attribs; + if (!attribs) return; + for (const name of attrNames) { + const value = attribs[name]; + if (value) links.push(value); + } + }); + } catch { + // ignore malformed HTML, fall through with whatever we collected + } + return links; +} + +export function findDownloadUrl(html: string, baseUrl: string): string | null { + const links = collectPageLinks(html); + const urlMatches = html.match(/https?:\/\/[^\s"'<>]+/gi) || []; + links.push(...urlMatches); + + const candidates: string[] = []; + const seen = new Set(); + for (const rawLink of links) { + const link = (rawLink || "").trim(); + if (!link || link.startsWith("#")) continue; + if (/^javascript:/i.test(link) || /^mailto:/i.test(link)) continue; + let absUrl: string; + try { + absUrl = new URL(link, baseUrl).toString(); + } catch { + continue; + } + let parsed: URL; + try { + parsed = new URL(absUrl); + } catch { + continue; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue; + if (seen.has(absUrl)) continue; + seen.add(absUrl); + if (isAllowedDownloadCandidate(absUrl)) candidates.push(absUrl); + } + if (!candidates.length) return null; + candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a)); + return candidates[0]; +} + +function normalizeCandidateUrl(raw: string, baseUrl: string): string | null { + const value = (raw || "").trim(); + if (!value) return null; + if (!isPotentialUrl(value)) return null; + let candidate = value; + if (candidate.startsWith("//")) candidate = `https:${candidate}`; + if (candidate.startsWith("http://") || candidate.startsWith("https://")) { + return isAllowedDownloadCandidate(candidate) ? candidate : null; + } + if (candidate.startsWith("/") || candidate.includes("/")) { + try { + const abs = new URL(candidate, baseUrl).toString(); + return isAllowedDownloadCandidate(abs) ? abs : null; + } catch { + return null; + } + } + return null; +} + +function findDownloadUrlInJson(data: unknown, baseUrl: string): string | null { + const candidates: string[] = []; + const seen = new Set(); + const stack: unknown[] = [data]; + while (stack.length) { + const current = stack.pop(); + if (current && typeof current === "object" && !Array.isArray(current)) { + stack.push(...Object.values(current as Record)); + } else if (Array.isArray(current)) { + stack.push(...current); + } else if (typeof current === "string") { + if (seen.has(current)) continue; + seen.add(current); + const url = normalizeCandidateUrl(current, baseUrl); + if (url) candidates.push(url); + } + } + if (!candidates.length) return null; + candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a)); + return candidates[0]; +} + +function extractDownloadUrlFromResponse(data: unknown, baseUrl: string): string | null { + if (data && typeof data === "object" && !Array.isArray(data)) { + const dict = data as Record; + for (const key of ["url", "downloadUrl", "download_url"]) { + const value = dict[key]; + if (typeof value === "string" && value.trim()) return value; + } + const inner = dict.data; + if (inner && typeof inner === "object" && !Array.isArray(inner)) { + const innerDict = inner as Record; + for (const key of ["url", "downloadUrl", "download_url"]) { + const value = innerDict[key]; + if (typeof value === "string" && value.trim()) return value; + } + } + } + return findDownloadUrlInJson(data, baseUrl); +} + +// -- MakerWorld -------------------------------------------------------------------------------- + +function extractNextDataJson(html: string): unknown | null { + const match = html.match(/]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i); + if (!match) return null; + const raw = (match[1] || "").trim(); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function getPath(obj: unknown, ...keys: string[]): unknown { + let current: unknown = obj; + for (const key of keys) { + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[key]; + } + return current; +} + +function makerworldDesignIdFromNextData(data: unknown): string | null { + const designId = getPath(data, "props", "pageProps", "design", "id"); + return designId ? String(designId) : null; +} + +function makerworldInstanceIdFromNextData(data: unknown): string | null { + const design = getPath(data, "props", "pageProps", "design") as Record | undefined; + if (!design) return null; + const defaultInstance = design.defaultInstanceId; + if (defaultInstance) return String(defaultInstance); + const instances = design.instances; + if (Array.isArray(instances)) { + for (const inst of instances) { + if (inst && typeof inst === "object" && (inst as Record).id) { + return String((inst as Record).id); + } + } + } + return null; +} + +function makerworldNonceFromNextData(data: unknown): string | null { + const nonce = getPath(data, "props", "pageProps", "x-nonce"); + return typeof nonce === "string" && nonce.trim() ? nonce : null; +} + +function makerworldModelIdFromUrl(url: string): string | null { + try { + const match = new URL(url).pathname.match(/\/models\/(\d+)/); + return match ? match[1] : null; + } catch { + return null; + } +} + +async function fetchMakerworldInstanceDownloadUrl( + instanceId: string, + pageUrl: string, + cookie: string | null, +): Promise { + const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=3mfstl`; + const data = await fetchJsonFromUrl(apiUrl, pageUrl, makerworldApiHeaders(pageUrl, null, cookie)); + return data ? extractDownloadUrlFromResponse(data, apiUrl) : null; +} + +async function fetchMakerworldModelDownloadUrl( + modelId: string, + pageUrl: string, + nonce: string | null, + cookie: string | null, +): Promise { + const apiUrl = `https://makerworld.com/api/v1/models/${modelId}/download`; + const data = await fetchJsonFromUrl(apiUrl, pageUrl, makerworldApiHeaders(pageUrl, nonce, cookie)); + return data ? extractDownloadUrlFromResponse(data, apiUrl) : null; +} + +export async function resolveMakerworldDownloadUrl( + html: string, + pageUrl: string, + makerworldCookie: string | null, +): Promise { + const nextData = extractNextDataJson(html); + let designId: string | null = null; + let nonce: string | null = null; + let instanceId: string | null = null; + if (nextData) { + const url = findDownloadUrlInJson(nextData, pageUrl); + if (url) return url; + designId = makerworldDesignIdFromNextData(nextData); + nonce = makerworldNonceFromNextData(nextData); + instanceId = makerworldInstanceIdFromNextData(nextData); + } + + if (instanceId) { + const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie); + if (url) return url; + } + + const modelId = designId || makerworldModelIdFromUrl(pageUrl); + if (!modelId) return null; + const url = await fetchMakerworldModelDownloadUrl(modelId, pageUrl, nonce, makerworldCookie); + if (url) return url; + + const apiCandidates = [ + `https://makerworld.com/api/v1/models/${modelId}`, + `https://makerworld.com/api/v1/models/${modelId}/files`, + `https://makerworld.com/api/v1/model/${modelId}`, + `https://makerworld.com/api/v1/model/${modelId}/files`, + ]; + for (const apiUrl of apiCandidates) { + const data = await fetchJsonFromUrl(apiUrl, pageUrl); + if (!data) continue; + const found = findDownloadUrlInJson(data, apiUrl); + if (found) return found; + } + return null; +} + +// -- Printables -------------------------------------------------------------------------------- + +function printablesModelIdFromUrl(url: string): string | null { + try { + const match = new URL(url).pathname.match(/\/model\/(\d+)/); + return match ? match[1] : null; + } catch { + return null; + } +} + +async function fetchPrintablesGraphql(query: string, variables: Record, referer: string | null): Promise { + const headers: Record = { + "User-Agent": IMPORT_BROWSER_USER_AGENT, + Accept: "application/json", + "Content-Type": "application/json", + Origin: "https://www.printables.com", + }; + if (referer) headers.Referer = referer; + const result = await fetchCappedBuffer("https://api.printables.com/graphql/", headers, { + method: "POST", + body: JSON.stringify({ query, variables: variables || {} }), + }); + if (!result) return null; + if (result.status >= 400) { + const message = parseJsonErrorMessage(result.buffer); + if ([401, 403, 429].includes(result.status)) { + throw new HttpError(result.status, message || "Printables request failed"); + } + return null; + } + if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null; + try { + return JSON.parse(result.buffer.toString("utf-8")); + } catch { + return null; + } +} + +function parsePrintablesErrorMessages(errors: unknown): string | null { + if (!Array.isArray(errors)) return null; + const messages: string[] = []; + for (const err of errors) { + if (!err || typeof err !== "object") continue; + const values = (err as Record).messages; + if (Array.isArray(values)) { + for (const v of values) if (typeof v === "string" && v.trim()) messages.push(v.trim()); + } else if (typeof values === "string" && values.trim()) { + messages.push(values.trim()); + } + } + return messages.length ? messages.join(", ") : null; +} + +function selectBestDownloadLink(links: string[]): string | null { + const candidates = links.filter((l) => isAllowedDownloadCandidate(l)); + if (!candidates.length) return null; + candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a)); + return candidates[0]; +} + +function extractPrintablesIds(items: unknown): string[] { + if (!Array.isArray(items)) return []; + const ids: string[] = []; + for (const item of items) { + if (item && typeof item === "object" && (item as Record).id !== undefined) { + ids.push(String((item as Record).id)); + } + } + return ids; +} + +async function fetchPrintablesModelMeta(printId: string, pageUrl: string): Promise | null> { + const query = ` + query ($id: ID!) { + print(id: $id) { + id + downloadPacks { id fileType } + stls { id name } + } + } + `; + const data = (await fetchPrintablesGraphql(query, { id: printId }, pageUrl)) as + | { data?: { print?: Record } } + | null; + return data?.data?.print ?? null; +} + +async function fetchPrintablesDownloadOutput( + printId: string, + variables: Record, + pageUrl: string, +): Promise | null> { + const mutation = ` + mutation ($printId: ID!, $source: DownloadSourceEnum!, $fileType: DownloadFileTypeEnum, $id: ID, $files: [DownloadFileInput!]) { + getDownloadLink(printId: $printId, source: $source, fileType: $fileType, id: $id, files: $files) { + ok + errors { field messages code } + output { link files { id link fileType } } + } + } + `; + const data = (await fetchPrintablesGraphql(mutation, variables, pageUrl)) as + | { data?: { getDownloadLink?: Record } } + | null; + const result = data?.data?.getDownloadLink; + if (!result) return null; + if (result.ok === false) { + const message = parsePrintablesErrorMessages(result.errors); + if (message) throw new HttpError(400, message); + return null; + } + const output = result.output; + return output && typeof output === "object" ? (output as Record) : null; +} + +function collectPrintablesLinks(output: Record | null): string[] { + if (!output) return []; + const links: string[] = []; + if (typeof output.link === "string") links.push(output.link); + const files = output.files; + if (Array.isArray(files)) { + for (const entry of files) { + if (entry && typeof entry === "object" && typeof (entry as Record).link === "string") { + links.push((entry as Record).link as string); + } + } + } + return links; +} + +export async function resolvePrintablesDownloadUrl(pageUrl: string): Promise { + const printId = printablesModelIdFromUrl(pageUrl); + if (!printId) return null; + const meta = await fetchPrintablesModelMeta(printId, pageUrl); + if (!meta) return null; + + const packs = meta.downloadPacks; + let packId: string | null = null; + if (Array.isArray(packs)) { + for (const pack of packs) { + if (pack && typeof pack === "object" && (pack as Record).fileType === "MODEL_FILES" && (pack as Record).id) { + packId = String((pack as Record).id); + break; + } + } + if (!packId && packs.length) { + for (const pack of packs) { + if (pack && typeof pack === "object" && (pack as Record).id) { + packId = String((pack as Record).id); + break; + } + } + } + } + + if (packId) { + const output = await fetchPrintablesDownloadOutput( + printId, + { printId, source: "model_detail", fileType: "pack", id: packId }, + pageUrl, + ); + const best = selectBestDownloadLink(collectPrintablesLinks(output)); + if (best) return best; + } + + const stlIds = extractPrintablesIds(meta.stls); + if (stlIds.length) { + const output = await fetchPrintablesDownloadOutput( + printId, + { printId, source: "model_detail", files: [{ fileType: "stl", ids: stlIds }] }, + pageUrl, + ); + const best = selectBestDownloadLink(collectPrintablesLinks(output)); + if (best) return best; + } + + return null; +} + +// -- Thingiverse ------------------------------------------------------------------------------- + +function thingiverseThingIdFromUrl(url: string): string | null { + try { + const pathname = new URL(url).pathname; + const m1 = pathname.match(/thing:(\d+)/i); + if (m1) return m1[1]; + const m2 = pathname.match(/\/things\/(\d+)/i); + return m2 ? m2[1] : null; + } catch { + return null; + } +} + +async function fetchThingiverseJson( + url: string, + pageUrl: string, + cookie: string | null, + token: string | null, +): Promise { + const result = await fetchCappedBuffer(url, thingiverseApiHeaders(pageUrl, cookie, token)); + if (!result) return null; + if (result.status >= 400) { + const message = parseJsonErrorMessage(result.buffer); + if ([401, 403, 429].includes(result.status)) { + throw new HttpError(result.status, message || "Thingiverse request failed"); + } + return null; + } + if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null; + const contentType = result.headers.get("content-type") || ""; + if (!isJsonContentType(contentType)) { + const trimmed = result.buffer.toString("utf-8").trimStart(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + } + try { + return JSON.parse(result.buffer.toString("utf-8")); + } catch { + return null; + } +} + +async function fetchThingiverseViewToken(pageUrl: string, cookie: string | null): Promise { + const apiUrl = "https://www.thingiverse.com/api/v2/auth/view"; + const result = await fetchCappedBuffer(apiUrl, thingiverseApiHeaders(pageUrl, cookie)); + if (!result) return null; + if (result.status >= 400) { + const message = parseJsonErrorMessage(result.buffer); + if ([401, 403, 429].includes(result.status)) { + throw new HttpError( + result.status, + message || + "Thingiverse blocked the request. Paste the Cookie header from your browser session in Settings to import.", + ); + } + return null; + } + if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null; + let data: unknown; + try { + data = JSON.parse(result.buffer.toString("utf-8")); + } catch { + return null; + } + if (data && typeof data === "object") { + const dict = data as Record; + const token = dict.access || dict.token || dict.access_token; + if (typeof token === "string" && token.trim()) return token.trim(); + const inner = dict.data; + if (inner && typeof inner === "object") { + const innerDict = inner as Record; + const innerToken = innerDict.access || innerDict.token || innerDict.access_token; + if (typeof innerToken === "string" && innerToken.trim()) return innerToken.trim(); + } + } + return null; +} + +export async function resolveThingiverseDownloadUrl(pageUrl: string, thingiverseCookie: string | null): Promise { + const thingId = thingiverseThingIdFromUrl(pageUrl); + if (!thingId) return null; + const token = await fetchThingiverseViewToken(pageUrl, thingiverseCookie); + if (!token) { + if (!thingiverseCookie) { + throw new HttpError(403, "Thingiverse requires a session cookie to fetch download links."); + } + return null; + } + const apiCandidates = [ + `https://www.thingiverse.com/api/v2/things/${thingId}/files`, + `https://www.thingiverse.com/api/v2/things/${thingId}`, + ]; + for (const apiUrl of apiCandidates) { + const data = await fetchThingiverseJson(apiUrl, pageUrl, thingiverseCookie, token); + if (!data) continue; + const url = findDownloadUrlInJson(data, apiUrl); + if (url) return url; + } + return `https://www.thingiverse.com/thing:${thingId}/zip`; +} diff --git a/backend/src/services/importService.ts b/backend/src/services/importService.ts new file mode 100644 index 0000000..4eec278 --- /dev/null +++ b/backend/src/services/importService.ts @@ -0,0 +1,248 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { + IMPORT_HTML_MAX_BYTES, + IMPORT_MAX_BYTES, + IMPORT_TIMEOUT_SECONDS, + IMPORT_USER_AGENT, +} from "../config"; +import { HttpError, buildImportFilename, isHtmlContentType, mimeFromContentType } from "../utils/fileUtils"; +import { validateRemoteUrl } from "../utils/urlUtils"; +import { + findDownloadUrl, + isPrintablesPageHost, + isThingiversePageHost, + makerworldHtmlHeaders, + printablesHtmlHeaders, + resolveMakerworldCookie, + resolveMakerworldDownloadUrl, + resolvePrintablesDownloadUrl, + resolveThingiverseCookie, + resolveThingiverseDownloadUrl, + thingiverseHtmlHeaders, + type ImportCookies, +} from "./importResolvers"; +import { createPrint, type PrintMetaInput } from "./printCreation"; +import type { Plate, Print } from "@prisma/client"; + +export type ImportRequestBody = ImportCookies & { + url: string; + title?: string | null; + notes?: string | null; + tags?: string[]; + folder_id?: string | null; + filename?: string | null; +}; + +function parseCharset(contentType: string | null): string { + if (!contentType) return "utf-8"; + const match = contentType.match(/charset=([^;]+)/i); + return match ? match[1].trim().replace(/["']/g, "") : "utf-8"; +} + +async function readCapped(response: Response, maxBytes: number): Promise<{ buffer: Buffer; truncated: boolean }> { + if (!response.body) return { buffer: Buffer.alloc(0), truncated: false }; + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + let truncated = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + total += chunk.length; + if (total > maxBytes) { + const allowed = chunk.length - (total - maxBytes); + if (allowed > 0) chunks.push(chunk.subarray(0, allowed)); + truncated = true; + await reader.cancel().catch(() => undefined); + break; + } + chunks.push(chunk); + } + return { buffer: Buffer.concat(chunks), truncated }; +} + +async function fetchWithGuard(url: string, headers: Record): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000); + try { + const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal }); + if (!res.ok) { + throw new HttpError(res.status || 400, `Failed to fetch URL: ${res.statusText || res.status}`); + } + return res; + } catch (err) { + if (err instanceof HttpError) throw err; + throw new HttpError(400, "Failed to reach the provided URL"); + } finally { + clearTimeout(timeout); + } +} + +export type OpenImportResult = { response: Response; finalUrl: string }; + +/** + * Fetches `url`, following HTML "landing pages" recursively (MakerWorld/Printables/Thingiverse + * page scraping, or generic /JSON link sniffing) until it lands on the actual model file + * response. Mirrors MakersVault's open_import_response. + */ +export async function openImportResponse( + url: string, + body: ImportRequestBody, + referer?: string | null, + depth = 0, +): Promise { + if (depth > 3) throw new HttpError(400, "Too many redirects while resolving download link"); + const validatedUrl = await validateRemoteUrl(url); + + const headers: Record = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" }; + let host = ""; + try { + host = (new URL(validatedUrl).hostname || "").toLowerCase(); + } catch { + host = ""; + } + let makerworldCookie: string | null = null; + let thingiverseCookie: string | null = null; + if (host.endsWith("makerworld.com")) { + makerworldCookie = resolveMakerworldCookie(body); + Object.assign(headers, makerworldHtmlHeaders(referer || validatedUrl, makerworldCookie)); + } else if (isThingiversePageHost(host)) { + thingiverseCookie = resolveThingiverseCookie(body); + Object.assign(headers, thingiverseHtmlHeaders(referer || validatedUrl, thingiverseCookie)); + } else if (isPrintablesPageHost(host)) { + Object.assign(headers, printablesHtmlHeaders(referer || validatedUrl)); + } + if (referer) headers.Referer = referer; + + const res = await fetchWithGuard(validatedUrl, headers); + const finalUrl = res.url || validatedUrl; + await validateRemoteUrl(finalUrl); + + const contentType = res.headers.get("content-type") || ""; + if (isHtmlContentType(contentType)) { + const { buffer, truncated } = await readCapped(res, IMPORT_HTML_MAX_BYTES); + const html = buffer.toString(parseCharset(contentType) as BufferEncoding); + + let downloadUrl: string | null = null; + let pageHost = ""; + try { + pageHost = (new URL(finalUrl).hostname || "").toLowerCase(); + } catch { + pageHost = ""; + } + if (pageHost.endsWith("makerworld.com")) { + if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body); + downloadUrl = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie); + } + if (!downloadUrl && isThingiversePageHost(pageHost)) { + if (thingiverseCookie === null) thingiverseCookie = resolveThingiverseCookie(body); + downloadUrl = await resolveThingiverseDownloadUrl(finalUrl, thingiverseCookie); + } + if (!downloadUrl && isPrintablesPageHost(pageHost)) { + downloadUrl = await resolvePrintablesDownloadUrl(finalUrl); + } + if (!downloadUrl) { + downloadUrl = findDownloadUrl(html, finalUrl); + } + if (!downloadUrl) { + if (truncated) { + throw new HttpError( + 400, + "No downloadable model file found in the scanned portion of the page. Use a direct download link or increase IMPORT_HTML_MAX_KB.", + ); + } + throw new HttpError(400, "No downloadable model file found. Use a direct download link."); + } + return openImportResponse(downloadUrl, body, finalUrl, depth + 1); + } + + return { response: res, finalUrl }; +} + +async function streamToFileCapped(response: Response, destPath: string, maxBytes: number): Promise { + if (!response.body) { + await fs.writeFile(destPath, Buffer.alloc(0)); + return 0; + } + const reader = response.body.getReader(); + const handle = await fs.open(destPath, "w"); + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + total += chunk.length; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new HttpError(413, "Imported file exceeds size limit"); + } + await handle.write(chunk); + } + } finally { + await handle.close(); + } + return total; +} + +/** Downloads `url` to a temp file without creating a Print (used by inspect/zip-entries/zip-extract). */ +export async function downloadImportToTemp( + url: string, + body: ImportRequestBody, +): Promise<{ tempPath: string; filename: string; mime: string }> { + const { response, finalUrl } = await openImportResponse(url, body); + const contentLength = response.headers.get("content-length"); + if (contentLength && Number(contentLength) > IMPORT_MAX_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new HttpError(413, "Imported file exceeds size limit"); + } + const filename = buildImportFilename(finalUrl, response.headers, body.filename); + const mime = mimeFromContentType(response.headers.get("content-type"), filename); + const suffix = path.extname(filename) || ""; + const tempPath = path.join(os.tmpdir(), `printstash-import-${crypto.randomBytes(8).toString("hex")}${suffix}`); + try { + await streamToFileCapped(response, tempPath, IMPORT_MAX_BYTES); + } catch (err) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw err; + } + return { tempPath, filename, mime }; +} + +/** Inspects a link without downloading the file body: returns the filename/mime/is_zip it + * would resolve to. */ +export async function inspectImportLink( + url: string, + body: ImportRequestBody, +): Promise<{ filename: string; mime: string; is_zip: boolean }> { + const { response, finalUrl } = await openImportResponse(url, body); + await response.body?.cancel().catch(() => undefined); + const filename = buildImportFilename(finalUrl, response.headers, body.filename); + const mime = mimeFromContentType(response.headers.get("content-type"), filename); + const isZip = path.extname(filename).toLowerCase() === ".zip"; + return { filename, mime, is_zip: isZip }; +} + +/** Downloads a URL and creates a single-plate Print from it (POST /import). */ +export async function importPrintFromUrl( + url: string, + body: ImportRequestBody, +): Promise<{ print: Print; plates: Plate[] }> { + const { tempPath, filename, mime } = await downloadImportToTemp(url, body); + const meta: PrintMetaInput = { + title: body.title ?? null, + notes: body.notes ?? null, + tags: body.tags ?? [], + folderId: body.folder_id ?? null, + }; + try { + return await createPrint(meta, path.parse(filename).name, [{ filename, mime, tempFilePath: tempPath }]); + } finally { + if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined); + } +} diff --git a/backend/src/services/mountImport.ts b/backend/src/services/mountImport.ts new file mode 100644 index 0000000..563b59e --- /dev/null +++ b/backend/src/services/mountImport.ts @@ -0,0 +1,144 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { prisma } from "../db"; +import { + DEFAULT_MOUNT_IMPORT_EXTS, + IMPORT_MAX_BYTES, + MOUNT_IMPORT_COPY, + MOUNT_IMPORT_ENABLED, + MOUNT_IMPORT_EXTS_RAW, + MOUNT_IMPORT_INCLUDE_HIDDEN, + MOUNT_IMPORT_PATH, + STORAGE, +} from "../config"; +import { sanitizeFilename, guessMimeFromPath } from "../utils/fileUtils"; +import { getMountImportCopy, getMountImportEnabled } from "./settingsService"; +import { resolveZipFolderId } from "./zipService"; +import { createPrint } from "./printCreation"; + +function parseMountImportExts(raw: string): Set | null { + if (!raw) return new Set(DEFAULT_MOUNT_IMPORT_EXTS); + const lowered = raw.trim().toLowerCase(); + if (lowered === "*") return null; + const parts = lowered.split(/[,\s]+/).filter(Boolean); + const exts = new Set(); + for (const part of parts) { + exts.add(part.startsWith(".") ? part : `.${part}`); + } + return exts.size ? exts : new Set(DEFAULT_MOUNT_IMPORT_EXTS); +} + +function shouldSkipMountEntry(name: string): boolean { + return !MOUNT_IMPORT_INCLUDE_HIDDEN && name.startsWith("."); +} + +async function* walk(dir: string, skipDirNames: Set): AsyncGenerator { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (shouldSkipMountEntry(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (skipDirNames.has(entry.name)) continue; + yield* walk(full, skipDirNames); + } else if (entry.isFile()) { + yield full; + } + } +} + +/** + * Scans a read-only host directory (IMPORT_MOUNT_PATH) for model files not yet imported and + * creates a single-plate Print for each. Fire-and-forget: intended to run in the background + * after the server starts listening, mirroring MakersVault's startup background thread. + */ +export async function scanMountImports(): Promise { + const root = MOUNT_IMPORT_PATH; + if (!root) return; + if (!(await getMountImportEnabled(MOUNT_IMPORT_ENABLED))) return; + + let rootStat; + try { + rootStat = await fs.stat(root); + } catch { + console.log(`[mount-import] Skipping: mount path not found: ${root}`); + return; + } + if (!rootStat.isDirectory()) { + console.log(`[mount-import] Skipping: mount path not found: ${root}`); + return; + } + + const allowedExts = parseMountImportExts(MOUNT_IMPORT_EXTS_RAW); + const copyFiles = await getMountImportCopy(MOUNT_IMPORT_COPY); + const rootAbs = path.resolve(root); + const rootPrefix = rootAbs.replace(/\/+$/, ""); + const storageAbs = path.resolve(STORAGE); + const skipStorageDirs = rootAbs === storageAbs; + const skipDirNames = skipStorageDirs ? new Set(["thumbs", "bundles"]) : new Set(); + + let imported = 0; + let skipped = 0; + let failed = 0; + + console.log(`[mount-import] Scanning ${rootAbs}...`); + + const existingRows = await prisma.plate.findMany({ + where: { sourcePath: { startsWith: `${rootPrefix}/` } }, + select: { sourcePath: true }, + }); + const existingSources = new Set(existingRows.map((r) => r.sourcePath).filter((s): s is string => !!s)); + + const folderCache = new Map(); + + for await (const filePath of walk(rootAbs, skipDirNames)) { + const filename = path.basename(filePath); + if (shouldSkipMountEntry(filename)) continue; + const ext = path.extname(filename).toLowerCase(); + if (allowedExts !== null && !allowedExts.has(ext)) continue; + + const relPath = path.relative(rootAbs, filePath).split(path.sep).join("/"); + const sourcePath = `${rootPrefix}/${relPath}`; + if (existingSources.has(sourcePath)) { + skipped += 1; + continue; + } + + let size: number; + try { + size = (await fs.stat(filePath)).size; + } catch { + failed += 1; + continue; + } + if (size > IMPORT_MAX_BYTES) { + failed += 1; + continue; + } + + try { + const folderId = await resolveZipFolderId(null, relPath, folderCache); + const safeName = sanitizeFilename(filename); + const mime = guessMimeFromPath(filePath); + await createPrint({ folderId }, path.parse(safeName).name, [ + { + filename: safeName, + mime, + size, + sourcePath: filePath, + copyFromPath: copyFiles ? filePath : undefined, + }, + ]); + imported += 1; + existingSources.add(sourcePath); + } catch { + failed += 1; + } + } + + console.log(`[mount-import] Done. Imported ${imported}, skipped ${skipped}, failed ${failed}.`); +} diff --git a/backend/src/services/preparedPrint.ts b/backend/src/services/preparedPrint.ts new file mode 100644 index 0000000..f624fc6 --- /dev/null +++ b/backend/src/services/preparedPrint.ts @@ -0,0 +1,255 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { listZipEntries, readZipEntry } from "../utils/zipReader"; + +const TEXT_LIMIT = 2 * 1024 * 1024; +const PREPARED_SUFFIXES = [".gcode.3mf", ".gcode", ".gco", ".bgcode"]; + +export type PreparedMetadata = { + printer: string | null; + material: string | null; + nozzle_mm: number | null; + layer_height_mm: number | null; + estimated_seconds: number | null; + format: string; +}; + +export function isPreparedPrintFilename(filename: string): boolean { + const lower = (filename || "").toLowerCase(); + return PREPARED_SUFFIXES.some((suffix) => lower.endsWith(suffix)); +} + +export function preparedFormat(filename: string): string { + const lower = (filename || "").toLowerCase(); + if (lower.endsWith(".gcode.3mf")) return "gcode_3mf"; + if (lower.endsWith(".bgcode")) return "bgcode"; + return "gcode"; +} + +function firstFloat(value: string | undefined | null): number | null { + const match = (value || "").match(/[-+]?\d+(?:\.\d+)?/); + if (!match) return null; + const parsed = Number.parseFloat(match[0]); + return Number.isFinite(parsed) ? parsed : null; +} + +function durationSeconds(value: string | undefined | null): number | null { + const raw = (value || "").trim().toLowerCase(); + if (!raw) return null; + if (/^\d+$/.test(raw)) return Number.parseInt(raw, 10); + let total = 0; + let found = false; + const re = /(\d+(?:\.\d+)?)\s*(d|days?|h|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)\b/g; + let match: RegExpExecArray | null; + while ((match = re.exec(raw))) { + found = true; + const amount = Number.parseFloat(match[1]); + const unit = match[2]; + const multiplier = unit.startsWith("d") ? 86400 : unit.startsWith("h") ? 3600 : unit.startsWith("m") ? 60 : 1; + total += amount * multiplier; + } + return found ? Math.trunc(total) : null; +} + +const PROFILE_ALIASES: Record = { + p1s: "Bambu Lab P1S", + p1p: "Bambu Lab P1P", + x1c: "Bambu Lab X1 Carbon", + "x1 carbon": "Bambu Lab X1 Carbon", + a1: "Bambu Lab A1", + "a1 mini": "Bambu Lab A1 mini", +}; + +function cleanProfile(value: string | undefined | null): string | null { + let cleaned = (value || "").trim(); + cleaned = cleaned.replace(/\s*@\s*[^;]+$/, ""); + cleaned = cleaned.split(";")[0].trim(); + cleaned = cleaned.replace(/\s+\d+(?:\.\d+)?\s*(?:mm\s*)?nozzle$/i, ""); + if (!cleaned) return null; + return PROFILE_ALIASES[cleaned.toLowerCase()] ?? cleaned; +} + +type MutableMetadata = { + printer: string | null; + material: string | null; + nozzle_mm: number | null; + layer_height_mm: number | null; + estimated_seconds: number | null; +}; + +function mergeTextMetadata(metadata: MutableMetadata, text: string): void { + const attributeValues: Record = {}; + for (const tagMatch of text.matchAll(/<[^>]+>/g)) { + const tag = tagMatch[0]; + const attrs: Record = {}; + for (const attrMatch of tag.matchAll(/([\w:-]+)\s*=\s*["']([^"']*)["']/g)) { + attrs[attrMatch[1]] = attrMatch[2]; + } + const key = (attrs.key || attrs.name || "").toLowerCase(); + const value = attrs.value; + if (key && value !== undefined) attributeValues[key] = value; + if (metadata.material === null && /filament/i.test(tag) && attrs.type) { + metadata.material = attrs.type.split(";")[0].trim() || null; + } + } + + for (const [key, value] of Object.entries(attributeValues)) { + if (metadata.printer === null && ["printer_model", "printer_settings_id", "machine_name"].includes(key)) { + metadata.printer = cleanProfile(value); + } else if (metadata.material === null && ["filament_type", "material_type"].includes(key)) { + metadata.material = value.split(";")[0].trim() || null; + } else if (metadata.nozzle_mm === null && key === "nozzle_diameter") { + metadata.nozzle_mm = firstFloat(value); + } else if (metadata.layer_height_mm === null && key === "layer_height") { + metadata.layer_height_mm = firstFloat(value); + } else if (metadata.estimated_seconds === null && ["prediction", "estimated_time", "print_time"].includes(key)) { + metadata.estimated_seconds = durationSeconds(value); + } + } + + const patterns: Record = { + printer: [ + /^\s*;?\s*printer_settings_id\s*[:=]\s*([^\r\n]+)/im, + /^\s*;?\s*printer_model\s*[:=]\s*([^\r\n]+)/im, + /^\s*;?\s*machine_name\s*[:=]\s*([^\r\n]+)/im, + ], + material: [/^\s*;?\s*(?:filament_type|material_type)\s*[:=]\s*([^\r\n]+)/im], + nozzle_mm: [/^\s*;?\s*nozzle_diameter\s*[:=]\s*([^\r\n]+)/im], + layer_height_mm: [/^\s*;?\s*layer_height\s*[:=]\s*([^\r\n]+)/im], + estimated_seconds: [ + /^\s*;?\s*(?:total estimated time|model printing time|estimated printing time[^:=]*|time)\s*[:=]\s*([^\r\n]+)/im, + ], + }; + for (const key of Object.keys(patterns) as (keyof MutableMetadata)[]) { + if (metadata[key] !== null) continue; + for (const pattern of patterns[key]) { + const match = text.match(pattern); + if (!match) continue; + const value = match[1].trim(); + if (key === "printer") metadata.printer = cleanProfile(value); + else if (key === "material") metadata.material = value.split(";")[0].trim() || null; + else if (key === "nozzle_mm" || key === "layer_height_mm") metadata[key] = firstFloat(value); + else metadata.estimated_seconds = durationSeconds(value); + if (metadata[key] !== null) break; + } + } +} + +function visitJson(node: unknown, prefix: string, flattened: Record): void { + if (node && typeof node === "object" && !Array.isArray(node)) { + for (const [key, child] of Object.entries(node as Record)) { + visitJson(child, prefix ? `${prefix}.${key}` : key, flattened); + } + } else if (Array.isArray(node)) { + node.slice(0, 20).forEach((child, index) => visitJson(child, `${prefix}.${index}`, flattened)); + } else { + flattened[prefix.toLowerCase()] = node; + } +} + +function mergeJsonMetadata(metadata: MutableMetadata, raw: Buffer): void { + let value: unknown; + try { + value = JSON.parse(raw.toString("utf-8")); + } catch { + return; + } + const flattened: Record = {}; + visitJson(value, "", flattened); + for (const [key, rawValue] of Object.entries(flattened)) { + const text = String(rawValue); + if (metadata.printer === null && (key.includes("printer_model") || key.includes("machine_name"))) { + metadata.printer = cleanProfile(text); + } else if (metadata.material === null && (key.includes("filament_type") || key.includes("material_type"))) { + metadata.material = text.split(";")[0].trim() || null; + } else if (metadata.nozzle_mm === null && key.includes("nozzle_diameter")) { + metadata.nozzle_mm = firstFloat(text); + } else if (metadata.layer_height_mm === null && key.includes("layer_height")) { + metadata.layer_height_mm = firstFloat(text); + } else if ( + metadata.estimated_seconds === null && + (key.includes("prediction") || key.includes("estimated_time") || key.includes("print_time")) + ) { + metadata.estimated_seconds = durationSeconds(text); + } + } +} + +async function readTextSample(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const half = TEXT_LIMIT / 2; + const handle = await fs.open(filePath, "r"); + try { + const firstBuf = Buffer.alloc(Math.min(half, stat.size)); + await handle.read(firstBuf, 0, firstBuf.length, 0); + let lastBuf = Buffer.alloc(0); + if (stat.size > firstBuf.length) { + const start = Math.max(0, stat.size - half); + lastBuf = Buffer.alloc(stat.size - start); + await handle.read(lastBuf, 0, lastBuf.length, start); + } + return Buffer.concat([firstBuf, Buffer.from("\n"), lastBuf]).toString("utf-8"); + } finally { + await handle.close(); + } + } catch { + return ""; + } +} + +export async function inspectPreparedPrint(filePath: string, filename?: string): Promise { + const displayName = filename || path.basename(filePath); + const metadata: PreparedMetadata = { + printer: null, + material: null, + nozzle_mm: null, + layer_height_mm: null, + estimated_seconds: null, + format: preparedFormat(displayName), + }; + + const is3mf = displayName.toLowerCase().endsWith(".3mf"); + if (!is3mf) { + if (!isPreparedPrintFilename(displayName)) return null; + mergeTextMetadata(metadata, await readTextSample(filePath)); + return metadata; + } + + try { + const names = (await listZipEntries(filePath)).filter((e) => !e.isDirectory).map((e) => e.name); + const preparedMembers = names.filter((name) => /\.(gcode|gco|bgcode)$/i.test(name)); + if (preparedMembers.length === 0 && !displayName.toLowerCase().endsWith(".gcode.3mf")) { + return null; + } + metadata.format = "gcode_3mf"; + + for (const name of names) { + const lower = name.toLowerCase(); + const isMetadataArea = lower.includes("metadata/") || lower.includes("slice") || lower.includes("plate"); + if (lower.endsWith(".json") && isMetadataArea) { + const buf = await readZipEntry(filePath, name, TEXT_LIMIT); + if (buf) mergeJsonMetadata(metadata, buf); + } else if (/\.(config|xml|txt)$/i.test(lower) && (lower.includes("metadata/") || lower.includes("slice"))) { + const buf = await readZipEntry(filePath, name, TEXT_LIMIT); + if (buf) mergeTextMetadata(metadata, buf.toString("utf-8")); + } + } + + if (preparedMembers[0]) { + const buf = await readZipEntry(filePath, preparedMembers[0], TEXT_LIMIT); + if (buf) mergeTextMetadata(metadata, buf.toString("utf-8")); + } + } catch { + return null; + } + return metadata; +} + +export function preparedFilename(modelName: string, metadata: Pick | null): string { + const fmt = metadata?.format; + const suffix = fmt === "gcode_3mf" ? ".gcode.3mf" : fmt === "bgcode" ? ".bgcode" : ".gcode"; + // oxlint-disable-next-line no-control-regex -- stripping control chars is the point here. + let safe = (modelName || "prepared-print").trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[ .]+$/, ""); + return `${safe || "prepared-print"}${suffix}`; +} diff --git a/backend/src/services/printCreation.ts b/backend/src/services/printCreation.ts new file mode 100644 index 0000000..00fdd93 --- /dev/null +++ b/backend/src/services/printCreation.ts @@ -0,0 +1,218 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import { prisma } from "../db"; +import { STORAGE } from "../config"; +import { sanitizeFilename, guessMimeFromPath, mimeFromContentType } from "../utils/fileUtils"; +import { inspectPreparedPrint } from "./preparedPrint"; +import { + availableModelName, + availablePlateFilename, + ensurePlateThumbnail, + managedPlatePath, + pruneEmptyStorageDirs, + renderPlateStoragePath, + saveThumbFromFile, +} from "./printService"; +import { Prisma } from "@prisma/client"; +import type { Plate, Print } from "@prisma/client"; + +const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp"]); + +export type NewPlateInput = { + /** Original (untrusted) filename; will be sanitized. */ + filename: string; + mime?: string | null; + size?: number; + /** Exactly one of tempFilePath / copyFromPath / sourcePath should be set. */ + tempFilePath?: string; // moved (renamed) into managed storage, source is deleted + copyFromPath?: string; // copied into managed storage, source left intact + sourcePath?: string; // no-copy: file stays at this path, storagePath is still rendered/recorded +}; + +export type PrintMetaInput = { + title?: string | null; + notes?: string | null; + tags?: string[]; + folderId?: string | null; + creator?: string | null; + collection?: string | null; +}; + +async function placeFile(input: NewPlateInput, destAbsPath: string): Promise { + await fs.mkdir(path.dirname(destAbsPath), { recursive: true }); + if (input.tempFilePath) { + try { + await fs.rename(input.tempFilePath, destAbsPath); + } catch (err: any) { + if (err?.code === "EXDEV") { + await fs.copyFile(input.tempFilePath, destAbsPath); + await fs.rm(input.tempFilePath, { force: true }); + } else { + throw err; + } + } + return destAbsPath; + } + if (input.copyFromPath) { + await fs.copyFile(input.copyFromPath, destAbsPath); + return destAbsPath; + } + // no-copy: nothing placed on disk, caller should use sourcePath for reads. + return input.sourcePath ?? null; +} + +async function resolveSize(input: NewPlateInput, effectivePath: string | null): Promise { + if (typeof input.size === "number") return input.size; + if (!effectivePath) return 0; + try { + const stat = await fs.stat(effectivePath); + return stat.size; + } catch { + return 0; + } +} + +async function thumbnailAndSniff(plateId: string, filename: string, mime: string, effectivePath: string | null) { + if (!effectivePath || !fsSync.existsSync(effectivePath)) return; + const ext = path.extname(filename).toLowerCase(); + if (mime.toLowerCase().startsWith("image/") && IMAGE_EXTS.has(ext)) { + await saveThumbFromFile(plateId, effectivePath); + } else if (ext === ".3mf") { + await ensurePlateThumbnail(plateId, effectivePath); + } +} + +/** Resolves the on-disk path to read a plate's bytes from (managed storage, else its sourcePath). */ +export function resolvePlateFilePath(plate: Pick): string | null { + const managed = managedPlatePath(plate); + if (fsSync.existsSync(managed)) return managed; + if (plate.sourcePath && fsSync.existsSync(plate.sourcePath)) return plate.sourcePath; + return null; +} + +/** Re-inspects plate[0] of a print and refreshes the auto-detected Print.preparedMetadata. */ +export async function refreshAutoPreparedMetadata(printId: string): Promise { + const plate0 = await prisma.plate.findFirst({ where: { printId }, orderBy: { position: "asc" } }); + let metadata: Prisma.InputJsonValue | null = null; + if (plate0) { + const filePath = resolvePlateFilePath(plate0); + if (filePath) { + const sniffed = await inspectPreparedPrint(filePath, plate0.filename); + if (sniffed) metadata = sniffed as unknown as Prisma.InputJsonValue; + } + } + await prisma.print.update({ + where: { id: printId }, + data: { preparedMetadata: metadata === null ? Prisma.JsonNull : metadata }, + }); +} + +type CreatedPlate = { record: Plate; effectivePath: string | null }; + +async function createPlateAtPosition( + print: Pick, + input: NewPlateInput, + position: number, +): Promise { + const sanitized = sanitizeFilename(input.filename); + const desiredFilename = await availablePlateFilename(print.id, sanitized); + const storagePath = await renderPlateStoragePath(print, desiredFilename, position); + const destAbsPath = path.join(STORAGE, storagePath); + const effectivePath = await placeFile(input, destAbsPath); + const size = await resolveSize(input, effectivePath); + const mime = input.mime || guessMimeFromPath(desiredFilename) || "application/octet-stream"; + + const record = await prisma.plate.create({ + data: { + printId: print.id, + position, + filename: desiredFilename, + mime, + size, + storagePath, + sourcePath: input.sourcePath ?? null, + }, + }); + + await thumbnailAndSniff(record.id, desiredFilename, mime, effectivePath); + return { record, effectivePath }; +} + +/** Creates a new Print with one or more Plates (in the given order). Used by /upload (single + multiplate). */ +export async function createPrint( + meta: PrintMetaInput, + nameHint: string, + plateInputs: NewPlateInput[], +): Promise<{ print: Print; plates: Plate[] }> { + if (!plateInputs.length) throw new Error("createPrint requires at least one plate"); + const baseName = (meta.title || "").trim() || nameHint; + const finalName = await availableModelName(baseName, meta.folderId ?? null); + + const print = await prisma.print.create({ + data: { + name: finalName, + nameNormalized: finalName.trim().toLowerCase(), + title: meta.title ?? null, + notes: meta.notes ?? null, + creator: meta.creator?.trim() || null, + collection: meta.collection?.trim() || null, + tags: (meta.tags || []).map((t) => t.trim()).filter(Boolean), + folderId: meta.folderId ?? null, + }, + }); + + const plates: Plate[] = []; + let firstEffectivePath: string | null = null; + let firstFilename = ""; + for (let i = 0; i < plateInputs.length; i++) { + const { record, effectivePath } = await createPlateAtPosition(print, plateInputs[i], i); + plates.push(record); + if (i === 0) { + firstEffectivePath = effectivePath; + firstFilename = record.filename; + } + } + + if (firstEffectivePath) { + const sniffed = await inspectPreparedPrint(firstEffectivePath, firstFilename); + if (sniffed) { + await prisma.print.update({ + where: { id: print.id }, + data: { preparedMetadata: sniffed as unknown as Prisma.InputJsonValue }, + }); + } + } + + return { print, plates }; +} + +/** Appends one or more plates to an existing print (POST /print/:id/plates). */ +export async function addPlatesToPrint(printId: string, plateInputs: NewPlateInput[]): Promise { + const print = await prisma.print.findUnique({ where: { id: printId } }); + if (!print) throw new Error("Print not found"); + const maxPosition = await prisma.plate.aggregate({ where: { printId }, _max: { position: true } }); + let nextPosition = (maxPosition._max.position ?? -1) + 1; + + const created: Plate[] = []; + for (const input of plateInputs) { + const { record } = await createPlateAtPosition(print, input, nextPosition); + created.push(record); + nextPosition += 1; + } + return created; +} + +export async function deletePlateFiles(plate: Pick): Promise { + const abs = path.join(STORAGE, plate.storagePath); + try { + await fs.rm(abs, { force: true }); + await pruneEmptyStorageDirs(path.dirname(abs)); + } catch { + // best-effort cleanup + } +} + +export function mimeForUpload(contentType: string | null | undefined, filename: string): string { + return mimeFromContentType(contentType, filename); +} diff --git a/backend/src/services/printFileService.ts b/backend/src/services/printFileService.ts new file mode 100644 index 0000000..cd511af --- /dev/null +++ b/backend/src/services/printFileService.ts @@ -0,0 +1,173 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import { prisma } from "../db"; +import { STORAGE, IMPORT_MAX_BYTES } from "../config"; +import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils"; +import { isPreparedPrintFilename, inspectPreparedPrint } from "./preparedPrint"; +import type { Prisma, Print, PrintFile } from "@prisma/client"; + +export function managedPrintFilePath(printFile: Pick): string { + const candidate = path.resolve(STORAGE, printFile.storagePath); + const root = path.resolve(STORAGE); + if (candidate === root || !candidate.startsWith(root + path.sep)) { + throw new HttpError(400, "Related file resolved outside storage"); + } + return candidate; +} + +export async function listSupportingFiles(printId: string): Promise { + return prisma.printFile.findMany({ + where: { printId, role: "SUPPORTING" }, + orderBy: [{ filename: "asc" }, { id: "asc" }], + }); +} + +async function pruneBundleDirs(start: string): Promise { + const root = path.resolve(STORAGE, "bundles"); + let current = start; + while (fsSync.existsSync(current) && path.resolve(current) !== root && path.resolve(current).startsWith(root)) { + try { + await fs.rmdir(current); + } catch { + break; + } + current = path.dirname(current); + } +} + +/** + * Saves an uploaded supporting/prepared file from a temp path onto a print. Mirrors + * MakersVault's save_asset_file: role is auto-classified from the filename (or upgraded + * to PREPARED after sniffing a .3mf that turns out to be a sliced gcode.3mf); a new + * PREPARED file replaces (deletes) any existing one for the print. + */ +export async function saveFileFromTemp( + printId: string, + tempPath: string, + originalFilename: string, + contentType: string | null | undefined, +): Promise { + const print = await prisma.print.findUnique({ where: { id: printId } }); + if (!print) throw new HttpError(404, "Print not found"); + + const safeName = sanitizeFilename(originalFilename || "supporting-file"); + let role: "SUPPORTING" | "PREPARED" = isPreparedPrintFilename(safeName) ? "PREPARED" : "SUPPORTING"; + const mime = mimeFromContentType(contentType, safeName); + + const record = await prisma.printFile.create({ + data: { + printId: print.id, + filename: safeName, + mime, + role, + storagePath: "pending", + }, + }); + const storagePath = `bundles/${print.id}/${record.id}/${safeName}`; + const destination = path.join(STORAGE, storagePath); + + try { + await fs.mkdir(path.dirname(destination), { recursive: true }); + const stat = await fs.stat(tempPath); + if (stat.size > IMPORT_MAX_BYTES) throw new HttpError(413, "Related file exceeds size limit"); + try { + await fs.rename(tempPath, destination); + } catch (err: any) { + if (err?.code === "EXDEV") { + await fs.copyFile(tempPath, destination); + await fs.rm(tempPath, { force: true }); + } else { + throw err; + } + } + + let metadata: Record | null = null; + if (role === "PREPARED" || safeName.toLowerCase().endsWith(".3mf")) { + metadata = (await inspectPreparedPrint(destination, safeName)) as Record | null; + } + if (metadata) role = "PREPARED"; + + let oldPreparedPath: string | null = null; + let oldPreparedId: string | null = null; + const updatedPrint = await prisma.$transaction(async (tx) => { + const currentPrint = await tx.print.findUnique({ where: { id: printId } }); + if (!currentPrint) throw new HttpError(404, "Print not found"); + await tx.printFile.update({ + where: { id: record.id }, + data: { + storagePath, + size: stat.size, + role, + metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined, + }, + }); + if (role === "PREPARED") { + if (currentPrint.preparedFileId && currentPrint.preparedFileId !== record.id) { + const old = await tx.printFile.findUnique({ where: { id: currentPrint.preparedFileId } }); + if (old) { + oldPreparedPath = managedPrintFilePath(old); + oldPreparedId = old.id; + } + } + await tx.print.update({ where: { id: printId }, data: { preparedFileId: record.id } }); + if (oldPreparedId) { + await tx.printFile.delete({ where: { id: oldPreparedId } }); + } + } + return tx.print.findUniqueOrThrow({ where: { id: printId } }); + }); + + if (oldPreparedPath) { + await fs.rm(oldPreparedPath, { force: true }).catch(() => undefined); + await pruneBundleDirs(path.dirname(oldPreparedPath)); + } + return updatedPrint; + } catch (err) { + await fs.rm(destination, { force: true }).catch(() => undefined); + await pruneBundleDirs(path.dirname(destination)); + await prisma.printFile.delete({ where: { id: record.id } }).catch(() => undefined); + throw err; + } +} + +export async function deleteSupportingFile(printId: string, fileId: string): Promise { + const record = await prisma.printFile.findUnique({ where: { id: fileId } }); + if (!record || record.printId !== printId || record.role !== "SUPPORTING") { + throw new HttpError(404, "Supporting file not found"); + } + const filePath = managedPrintFilePath(record); + await prisma.printFile.delete({ where: { id: fileId } }); + await fs.rm(filePath, { force: true }).catch(() => undefined); + await pruneBundleDirs(path.dirname(filePath)); + return prisma.print.findUniqueOrThrow({ where: { id: printId } }); +} + +export async function deleteAllPrintFiles(printId: string): Promise { + const rows = await prisma.printFile.findMany({ where: { printId } }); + const paths = rows.map((r) => managedPrintFilePath(r)); + // Print.preparedFileId FK will already be gone via cascade delete of the Print row itself, + // but this helper runs against a still-live print (e.g. before a full print delete elsewhere). + await prisma.printFile.deleteMany({ where: { printId } }); + for (const p of paths) { + await fs.rm(p, { force: true }).catch(() => undefined); + await pruneBundleDirs(path.dirname(p)); + } +} + +export async function deletePreparedFile(printId: string): Promise { + const print = await prisma.print.findUnique({ where: { id: printId } }); + if (!print || !print.preparedFileId) throw new HttpError(404, "No prepared print file"); + const record = await prisma.printFile.findUnique({ where: { id: print.preparedFileId } }); + if (!record || record.printId !== printId || record.role !== "PREPARED") { + throw new HttpError(404, "No prepared print file"); + } + const filePath = managedPrintFilePath(record); + await prisma.$transaction([ + prisma.print.update({ where: { id: printId }, data: { preparedFileId: null } }), + prisma.printFile.delete({ where: { id: record.id } }), + ]); + await fs.rm(filePath, { force: true }).catch(() => undefined); + await pruneBundleDirs(path.dirname(filePath)); + return prisma.print.findUniqueOrThrow({ where: { id: printId } }); +} diff --git a/backend/src/services/printLoader.ts b/backend/src/services/printLoader.ts new file mode 100644 index 0000000..e480a8f --- /dev/null +++ b/backend/src/services/printLoader.ts @@ -0,0 +1,24 @@ +import { prisma } from "../db"; +import { HttpError } from "../utils/fileUtils"; +import { toPrintOut, type PrintOut } from "../dto"; +import type { Plate, Print, PrintFile } from "@prisma/client"; + +export type FullPrint = { print: Print; plates: Plate[]; files: PrintFile[]; preparedFile: PrintFile | null }; + +export async function loadFullPrint(printId: string): Promise { + const print = await prisma.print.findUnique({ where: { id: printId } }); + if (!print) throw new HttpError(404, "Print not found"); + const [plates, files] = await Promise.all([ + prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } }), + prisma.printFile.findMany({ where: { printId } }), + ]); + const preparedFile = print.preparedFileId + ? await prisma.printFile.findUnique({ where: { id: print.preparedFileId } }) + : null; + return { print, plates, files, preparedFile }; +} + +export async function printOutById(printId: string): Promise { + const full = await loadFullPrint(printId); + return toPrintOut(full.print, full.plates, full.files, full.preparedFile); +} diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts new file mode 100644 index 0000000..3f74e64 --- /dev/null +++ b/backend/src/services/printService.ts @@ -0,0 +1,353 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; +import { HttpError } from "../utils/fileUtils"; +import { STORAGE, THUMBS } from "../config"; +import { prisma } from "../db"; +import { listZipEntries, readZipEntry } from "../utils/zipReader"; +import type { Folder, Plate, Print } from "@prisma/client"; + +export const STORAGE_TEMPLATE_TOKENS = [ + "folder", + "collection", + "tags", + "creator", + "model", + "name", + "filename", + "id", + "plate", +] as const; + +export const DEFAULT_STORAGE_TEMPLATE = "{folder}/{model}/{filename}"; + +const TOKEN_RE = /\{([a-z_]+)\}/g; +// oxlint-disable-next-line no-control-regex -- stripping control chars is the point here. +const INVALID_SEGMENT_RE = /[<>:"|?*\x00-\x1f]/g; + +export function sanitizePathSegment(value: string | null | undefined, fallback: string): string { + let cleaned = (value || "").trim().replace(INVALID_SEGMENT_RE, "_"); + cleaned = cleaned.replace(/\//g, "_").replace(/\\/g, "_").replace(/^[ .]+|[ .]+$/g, ""); + if (cleaned === "" || cleaned === "." || cleaned === "..") cleaned = fallback; + return cleaned.slice(0, 120); +} + +export function validateStorageTemplate(template: string | null | undefined): string { + const normalized = (template || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + if (!normalized) throw new HttpError(400, "Storage template cannot be empty"); + const tokens = [...normalized.matchAll(TOKEN_RE)].map((m) => m[1]); + const unknown = tokens.filter((t) => !(STORAGE_TEMPLATE_TOKENS as readonly string[]).includes(t)); + if (unknown.length) throw new HttpError(400, `Unknown storage token: {${unknown[0]}}`); + const filenameCount = (normalized.match(/\{filename\}/g) || []).length; + if (filenameCount !== 1) throw new HttpError(400, "Storage template must contain {filename} exactly once"); + const finalSegment = normalized.split("/").pop() || ""; + if (!finalSegment.includes("{filename}")) { + throw new HttpError(400, "{filename} must be in the final path segment"); + } + const stripped = normalized.replace(TOKEN_RE, ""); + if (stripped.includes("{") || stripped.includes("}")) { + throw new HttpError(400, "Storage template contains an invalid token"); + } + if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) { + throw new HttpError(400, "Storage template contains an unsafe path segment"); + } + return normalized; +} + +function renderTemplate(template: string, values: Record): string { + return template.replace(TOKEN_RE, (_match, token: string) => { + if (!(token in values)) throw new HttpError(400, `Unknown storage token: {${token}}`); + return values[token]; + }); +} + +export async function folderSegments(folderId: string | null | undefined): Promise { + if (!folderId) return ["Unassigned"]; + const segments: string[] = []; + const visited = new Set(); + let current: Folder | null = await prisma.folder.findUnique({ where: { id: folderId } }); + while (current && !visited.has(current.id)) { + visited.add(current.id); + segments.unshift(sanitizePathSegment(current.name, "Folder")); + current = current.parentId ? await prisma.folder.findUnique({ where: { id: current.parentId } }) : null; + } + return segments.length ? segments : ["Unassigned"]; +} + +function assertWithinStorage(relative: string): string { + const candidate = path.resolve(STORAGE, relative); + const root = path.resolve(STORAGE); + if (candidate === root || !candidate.startsWith(root + path.sep)) { + throw new HttpError(400, "Storage template resolved outside the storage directory"); + } + return relative; +} + +type PrintLike = Pick; + +/** Renders the on-disk relative path for one plate of a print using the storage template. */ +export async function renderPlateStoragePath( + print: PrintLike, + plateFilename: string, + platePosition: number, + template?: string | null, +): Promise { + const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate())); + const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged"; + const modelLabel = sanitizePathSegment(print.name, "Model"); + const values: Record = { + folder: (await folderSegments(print.folderId)).join("/"), + collection: sanitizePathSegment(print.collection || "Uncollected", "Uncollected"), + tags: sanitizePathSegment(tagLabel, "Untagged"), + creator: sanitizePathSegment(print.creator || "Unknown creator", "Unknown creator"), + model: modelLabel, + name: modelLabel, + filename: sanitizePathSegment(plateFilename, "file"), + id: print.id, + plate: String(platePosition + 1), + }; + const rendered = renderTemplate(safeTemplate, values).replace(/\\/g, "/"); + const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item")); + if (!parts.length) throw new HttpError(400, "Storage template produced an empty path"); + return assertWithinStorage(path.join(...parts)); +} + +export function samplePlateStoragePaths(template: string): [string, string] { + const safeTemplate = validateStorageTemplate(template); + const base = { + folder: "Props/Workshop", + collection: "Tabletop", + tags: "Print in place + Useful", + creator: "Example creator", + model: "Multi-part gadget", + name: "Multi-part gadget", + id: "a1b2c3d4", + }; + const first = renderTemplate(safeTemplate, { ...base, filename: "Base.3mf", plate: "1" }); + const second = renderTemplate(safeTemplate, { ...base, filename: "Lid.3mf", plate: "2" }); + return [first, second]; +} + +function normalizeName(name: string): string { + return name.trim().toLowerCase(); +} + +/** Throws 409 if `requested` is already taken in this folder. Used for explicit renames. */ +export async function uniqueModelName( + requested: string, + folderId: string | null, + excludeId?: string, +): Promise { + const base = sanitizePathSegment(requested, "Model"); + const existing = await prisma.print.findFirst({ + where: { + folderId: folderId ?? null, + nameNormalized: normalizeName(base), + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + }); + if (existing) throw new HttpError(409, `A print named "${base}" already exists in this folder`); + return base; +} + +/** Auto-suffixes `requested` until it's free in this folder. Used for creation/reassignment. */ +export async function availableModelName( + requested: string, + folderId: string | null, + excludeId?: string, +): Promise { + const base = sanitizePathSegment(requested, "Model"); + let candidate = base; + let suffix = 2; + // Small folders in practice; a loop of sequential existence checks is simple and correct. + for (;;) { + const existing = await prisma.print.findFirst({ + where: { + folderId: folderId ?? null, + nameNormalized: normalizeName(candidate), + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + }); + if (!existing) return candidate; + candidate = `${base} (${suffix})`; + suffix += 1; + } +} + +/** Auto-suffixes a plate's filename until it's unique among its print's other plates. */ +export async function availablePlateFilename( + printId: string, + desiredFilename: string, + excludePlateId?: string, +): Promise { + const siblings = await prisma.plate.findMany({ + where: { printId, ...(excludePlateId ? { id: { not: excludePlateId } } : {}) }, + select: { filename: true }, + }); + const taken = new Set(siblings.map((p) => p.filename.toLowerCase())); + if (!taken.has(desiredFilename.toLowerCase())) return desiredFilename; + const ext = path.extname(desiredFilename); + const stem = path.basename(desiredFilename, ext); + let suffix = 2; + for (;;) { + const candidate = `${stem} (${suffix})${ext}`; + if (!taken.has(candidate.toLowerCase())) return candidate; + suffix += 1; + } +} + +export function managedPlatePath(plate: Pick): string { + return path.join(STORAGE, plate.storagePath); +} + +export async function pruneEmptyStorageDirs(start: string): Promise { + const root = path.resolve(STORAGE); + let current = start; + while (fsSync.existsSync(current) && path.resolve(current) !== root && path.resolve(current).startsWith(root)) { + try { + await fs.rmdir(current); + } catch { + break; + } + current = path.dirname(current); + } +} + +/** + * Re-renders and (if needed) moves every plate of a print after a name/folder/tag/creator/ + * collection change. Mirrors MakersVault's relocate_asset, generalized to N plates. Supporting + * and prepared PrintFiles are never touched here — they live at a fixed bundles/ path. + */ +export async function relocatePrint(print: PrintLike, plates: Plate[], template?: string | null): Promise { + for (const plate of plates) { + const oldPath = managedPlatePath(plate); + const newRelative = await renderPlateStoragePath(print, plate.filename, plate.position, template); + const newPath = path.join(STORAGE, newRelative); + if (path.resolve(oldPath) === path.resolve(newPath)) { + if (plate.storagePath !== newRelative) { + await prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } }); + } + continue; + } + const collision = await prisma.plate.findFirst({ where: { storagePath: newRelative, id: { not: plate.id } } }); + if (collision) throw new HttpError(409, `Storage path already exists: ${newRelative}`); + const oldExists = fsSync.existsSync(oldPath); + if (oldExists) { + await fs.mkdir(path.dirname(newPath), { recursive: true }); + await fs.rename(oldPath, newPath); + await pruneEmptyStorageDirs(path.dirname(oldPath)); + } + await prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } }); + } +} + +export async function reorganizeManagedPrints(template?: string | null): Promise<{ moved: number; skipped: number }> { + let moved = 0; + let skipped = 0; + const prints = await prisma.print.findMany({ include: { plates: true }, orderBy: { id: "asc" } }); + for (const print of prints) { + try { + const before = new Map(print.plates.map((p) => [p.id, p.storagePath])); + await relocatePrint(print, print.plates, template); + const after = await prisma.plate.findMany({ where: { printId: print.id } }); + for (const plate of after) { + if (before.get(plate.id) !== plate.storagePath) moved += 1; + } + } catch { + skipped += 1; + } + } + return { moved, skipped }; +} + +// -- Settings (storage template) ----------------------------------------- + +export async function getStorageTemplate(): Promise { + const row = await prisma.setting.findUnique({ where: { key: "storage_path_template" } }); + return typeof row?.value === "string" ? row.value : DEFAULT_STORAGE_TEMPLATE; +} + +export async function setStorageTemplate(value: string): Promise { + await prisma.setting.upsert({ + where: { key: "storage_path_template" }, + create: { key: "storage_path_template", value }, + update: { value }, + }); +} + +// -- Thumbnails ------------------------------------------------------------- + +async function saveThumbBuffer(plateId: string, input: Buffer): Promise { + const dest = path.join(THUMBS, `${plateId}.jpg`); + const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; + try { + await sharp(input) + .flatten({ background: { r: 248, g: 250, b: 252 } }) + .resize(512, 512, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 88, mozjpeg: true }) + .toFile(tmp); + await fs.rename(tmp, dest); + return true; + } catch { + await fs.rm(tmp, { force: true }); + return false; + } +} + +export async function saveThumbFromFile(plateId: string, srcPath: string): Promise { + try { + const buf = await fs.readFile(srcPath); + return await saveThumbBuffer(plateId, buf); + } catch { + return false; + } +} + +export async function saveThumbFromBytes(plateId: string, data: Buffer): Promise { + return saveThumbBuffer(plateId, data); +} + +const THUMBNAIL_ENTRY_PRIORITY: Record = { + "metadata/thumbnail.png": 0, + "3d/thumbnail.png": 1, + "thumbnail.png": 2, +}; + +/** Extracts an embedded thumbnail image from a .3mf archive's zip payload, if present. */ +export async function extract3mfThumbnail(plateId: string, srcPath: string): Promise { + if (!srcPath.toLowerCase().endsWith(".3mf")) return false; + try { + const entries = await listZipEntries(srcPath); + const imageNames = entries + .filter((e) => !e.isDirectory) + .filter((e) => /\.(png|jpe?g|webp)$/i.test(e.name) && /thumbnail/i.test(path.basename(e.name))) + .filter((e) => e.size <= 16 * 1024 * 1024) + .toSorted((a, b) => { + const pa = THUMBNAIL_ENTRY_PRIORITY[a.name.toLowerCase().replace(/^\//, "")] ?? 10; + const pb = THUMBNAIL_ENTRY_PRIORITY[b.name.toLowerCase().replace(/^\//, "")] ?? 10; + return pa - pb || a.name.length - b.name.length; + }); + for (const entry of imageNames) { + const buf = await readZipEntry(srcPath, entry.name, 16 * 1024 * 1024); + if (buf && (await saveThumbBuffer(plateId, buf))) return true; + } + } catch { + return false; + } + return false; +} + +export async function ensurePlateThumbnail(plateId: string, srcPath: string): Promise { + const existing = path.join(THUMBS, `${plateId}.jpg`); + if (fsSync.existsSync(existing)) return true; + return extract3mfThumbnail(plateId, srcPath); +} + +export function plateThumbPath(plateId: string): string { + return path.join(THUMBS, `${plateId}.jpg`); +} + +export function plateThumbExists(plateId: string): boolean { + return fsSync.existsSync(plateThumbPath(plateId)); +} diff --git a/backend/src/services/settingsService.ts b/backend/src/services/settingsService.ts new file mode 100644 index 0000000..0d4e122 --- /dev/null +++ b/backend/src/services/settingsService.ts @@ -0,0 +1,36 @@ +import { prisma } from "../db"; + +// Storage-template settings (get/setStorageTemplate) live in printService.ts — not duplicated here. + +const MOUNT_IMPORT_ENABLED_KEY = "mount_import_enabled"; +const MOUNT_IMPORT_COPY_KEY = "mount_import_copy"; + +async function getBoolSetting(key: string, fallback: boolean): Promise { + const row = await prisma.setting.findUnique({ where: { key } }); + if (row === null || row === undefined) return fallback; + return typeof row.value === "boolean" ? row.value : fallback; +} + +async function setBoolSetting(key: string, value: boolean): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value }, + update: { value }, + }); +} + +export async function getMountImportEnabled(fallback: boolean): Promise { + return getBoolSetting(MOUNT_IMPORT_ENABLED_KEY, fallback); +} + +export async function setMountImportEnabled(value: boolean): Promise { + await setBoolSetting(MOUNT_IMPORT_ENABLED_KEY, value); +} + +export async function getMountImportCopy(fallback: boolean): Promise { + return getBoolSetting(MOUNT_IMPORT_COPY_KEY, fallback); +} + +export async function setMountImportCopy(value: boolean): Promise { + await setBoolSetting(MOUNT_IMPORT_COPY_KEY, value); +} diff --git a/backend/src/services/zipService.ts b/backend/src/services/zipService.ts new file mode 100644 index 0000000..83352c5 --- /dev/null +++ b/backend/src/services/zipService.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { prisma } from "../db"; +import { HttpError, sanitizeFilename, guessMimeFromPath } from "../utils/fileUtils"; +import { IMPORT_MAX_BYTES } from "../config"; +import { listZipEntries as listRawZipEntries, readZipEntry } from "../utils/zipReader"; +import { validateParentFolder } from "./folderService"; +import { createPrint, type PrintMetaInput } from "./printCreation"; +import type { Print, Plate } from "@prisma/client"; + +export type ZipEntrySummary = { path: string; size: number }; + +/** Normalizes a raw zip entry name: backslashes -> slashes, strips leading slash, rejects + * empty/directory/".."-containing paths. Returns null if the entry should be ignored. */ +export function normalizeZipEntryPath(name: string): string | null { + let cleaned = (name || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!cleaned || cleaned.endsWith("/")) return null; + const parts = cleaned.split("/").filter((p) => p.length > 0); + if (!parts.length || parts.some((p) => p === "..")) return null; + const filtered = parts.filter((p) => p !== "."); + if (!filtered.length) return null; + return filtered.join("/"); +} + +function sanitizeFolderName(name: string): string { + const cleaned = (name || "").replace(/\0/g, "").trim().replace(/\//g, "_").replace(/\\/g, "_"); + return cleaned || "folder"; +} + +/** Lists every real (non-directory) entry in a zip archive, path-normalized and sorted. */ +export async function listZipEntries(zipPath: string): Promise { + const raw = await listRawZipEntries(zipPath); + const entries: ZipEntrySummary[] = []; + for (const entry of raw) { + if (entry.isDirectory) continue; + const name = normalizeZipEntryPath(entry.name); + if (!name) continue; + entries.push({ path: name, size: entry.size }); + } + entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + return entries; +} + +type FolderCache = Map; + +async function getOrCreateFolder( + parentId: string | null, + parentKey: string, + name: string, + cache: FolderCache, +): Promise { + const key = `${parentKey}/${name}`; + const cached = cache.get(key); + if (cached) return cached; + const existing = await prisma.folder.findFirst({ where: { parentId, name } }); + if (existing) { + cache.set(key, existing.id); + return existing.id; + } + const folder = await prisma.folder.create({ data: { name, parentId, tags: [] } }); + cache.set(key, folder.id); + return folder.id; +} + +/** Recreates the zip's directory structure as nested Folders, returning the leaf folder id + * for one entry's path. Mirrors MakersVault's resolve_zip_folder_id. */ +export async function resolveZipFolderId( + baseFolderId: string | null, + entryPath: string, + cache: FolderCache, +): Promise { + const segments = entryPath.split("/").slice(0, -1).filter(Boolean); + if (!segments.length) return baseFolderId; + let targetId = baseFolderId; + let parentKey = baseFolderId || "root"; + for (const segment of segments) { + const safe = sanitizeFolderName(segment); + if (!safe) continue; + targetId = await getOrCreateFolder(targetId, parentKey, safe, cache); + parentKey = `${parentKey}/${safe}`; + } + return targetId; +} + +export type ZipExtractOptions = { + title?: string | null; + notes?: string | null; + tags?: string[]; + folderId?: string | null; +}; + +/** + * Extracts selected zip entries into individual single-plate Prints (one per entry). Each + * entry lands in a Folder tree recreated from its path within the zip (nested under + * options.folderId, if any). Returns the created prints plus the list of entry names that + * failed to extract (missing, a directory, or over the size cap). + */ +export async function extractZipEntriesToPrints( + zipPath: string, + selections: string[], + options: ZipExtractOptions, +): Promise<{ prints: (Print & { plates: Plate[] })[]; failed: string[] }> { + const normalized = selections.map((s) => normalizeZipEntryPath(s)); + const ordered: string[] = []; + const seen = new Set(); + for (const name of normalized) { + if (!name || seen.has(name)) continue; + seen.add(name); + ordered.push(name); + } + if (!ordered.length) throw new HttpError(400, "No zip entries selected"); + + if (options.folderId) { + await validateParentFolder(options.folderId); + } + + const raw = await listRawZipEntries(zipPath); + const entryMap = new Map(); + for (const entry of raw) { + const name = normalizeZipEntryPath(entry.name); + if (!name) continue; + entryMap.set(name, { isDirectory: entry.isDirectory, size: entry.size }); + } + + const prints: (Print & { plates: Plate[] })[] = []; + const failed: string[] = []; + const folderCache: FolderCache = new Map(); + + for (const entryName of ordered) { + const info = entryMap.get(entryName); + if (!info || info.isDirectory) { + failed.push(entryName); + continue; + } + let tempPath: string | null = null; + try { + const targetFolderId = await resolveZipFolderId(options.folderId ?? null, entryName, folderCache); + const filename = sanitizeFilename(path.basename(entryName)); + const buffer = await readZipEntry(zipPath, entryName, IMPORT_MAX_BYTES); + if (!buffer) throw new Error("Extracted file exceeds size limit or could not be read"); + + tempPath = path.join(os.tmpdir(), `printstash-zip-${crypto.randomBytes(8).toString("hex")}`); + await fs.writeFile(tempPath, buffer); + + const meta: PrintMetaInput = { + title: options.title ?? null, + notes: options.notes ?? null, + tags: options.tags ?? [], + folderId: targetFolderId, + }; + const mime = guessMimeFromPath(filename); + const { print, plates } = await createPrint(meta, path.parse(filename).name, [ + { filename, mime, tempFilePath: tempPath }, + ]); + tempPath = null; + prints.push({ ...print, plates }); + } catch { + if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined); + failed.push(entryName); + } + } + + return { prints, failed }; +} diff --git a/backend/src/uploadMiddleware.ts b/backend/src/uploadMiddleware.ts new file mode 100644 index 0000000..5ecd1b7 --- /dev/null +++ b/backend/src/uploadMiddleware.ts @@ -0,0 +1,19 @@ +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import multer from "multer"; +import { IMPORT_MAX_BYTES } from "./config"; + +const diskStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, os.tmpdir()), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname || ""); + cb(null, `printstash-upload-${crypto.randomBytes(8).toString("hex")}${ext}`); + }, +}); + +/** Disk-backed multer instance for model file uploads (never memoryStorage — files can be large). */ +export const modelUpload = multer({ storage: diskStorage, limits: { fileSize: IMPORT_MAX_BYTES } }); + +/** Small in-memory multer instance for the 8MB-capped client-rendered thumbnail upload. */ +export const thumbnailUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 + 1 } }); diff --git a/backend/src/utils/asyncHandler.ts b/backend/src/utils/asyncHandler.ts new file mode 100644 index 0000000..c52c9a1 --- /dev/null +++ b/backend/src/utils/asyncHandler.ts @@ -0,0 +1,15 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +/** + * Express 4 does not forward rejected promises from async handlers to the error middleware — + * an unwrapped `async (req, res) => { throw new HttpError(...) }` would just hang the request. + * Wrap every async route handler with this so thrown/rejected errors reach the HttpError + * middleware in app.ts. + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} diff --git a/backend/src/utils/fileUtils.ts b/backend/src/utils/fileUtils.ts new file mode 100644 index 0000000..0880420 --- /dev/null +++ b/backend/src/utils/fileUtils.ts @@ -0,0 +1,91 @@ +import path from "node:path"; +import mimeTypes from "mime-types"; +import { IMPORT_ALLOWED_EXTS } from "../config"; + +export function sanitizeFilename(name: string | null | undefined): string { + let cleaned = (name || "").replace(/\0/g, "").trim(); + cleaned = cleaned.replace(/\//g, "_").replace(/\\/g, "_"); + cleaned = path.basename(cleaned); + return cleaned || "imported-file"; +} + +export function parseContentDisposition(cd: string | null | undefined): string | null { + if (!cd) return null; + const starMatch = cd.match(/filename\*=([^']*)''([^;]+)/i); + if (starMatch) return decodeURIComponent(starMatch[2]); + const plainMatch = cd.match(/filename="?([^";]+)"?/i); + return plainMatch ? plainMatch[1] : null; +} + +export class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export function buildImportFilename(url: string, headers: Headers, override?: string | null): string { + let name: string; + if (override) { + name = sanitizeFilename(override); + } else { + const disposition = headers.get("content-disposition"); + name = parseContentDisposition(disposition) || ""; + if (!name) { + try { + name = path.basename(new URL(url).pathname); + } catch { + name = ""; + } + } + name = sanitizeFilename(name); + } + + const contentType = headers.get("content-type") || ""; + const parsed = path.parse(name); + let ext = parsed.ext.toLowerCase(); + if (!ext) { + try { + ext = path.extname(new URL(url).pathname).toLowerCase(); + } catch { + ext = ""; + } + } + if (!ext && contentType) { + const guessed = mimeTypes.extension(contentType.split(";")[0].trim()); + ext = guessed ? `.${guessed}` : ""; + } + if (ext && !name.toLowerCase().endsWith(ext)) { + name = `${parsed.name || "imported-file"}${ext}`; + } + if (!ext) { + throw new HttpError(415, "Unable to determine file extension"); + } + if (!IMPORT_ALLOWED_EXTS.has(ext)) { + throw new HttpError(415, `Unsupported file type: ${ext}`); + } + return name; +} + +export function mimeFromContentType(contentType: string | null | undefined, filename: string): string { + const base = (contentType || "").split(";")[0].trim().toLowerCase(); + if (!base || base === "application/octet-stream" || base === "binary/octet-stream") { + return mimeTypes.lookup(filename) || base || "application/octet-stream"; + } + return base; +} + +export function isHtmlContentType(contentType: string | null | undefined): boolean { + const base = (contentType || "").split(";")[0].trim().toLowerCase(); + return base === "text/html" || base === "application/xhtml+xml"; +} + +export function isJsonContentType(contentType: string | null | undefined): boolean { + const base = (contentType || "").split(";")[0].trim().toLowerCase(); + return base === "application/json" || base === "text/json" || base === "application/ld+json"; +} + +export function guessMimeFromPath(filePath: string): string { + return mimeTypes.lookup(filePath) || "application/octet-stream"; +} diff --git a/backend/src/utils/urlUtils.ts b/backend/src/utils/urlUtils.ts new file mode 100644 index 0000000..316f505 --- /dev/null +++ b/backend/src/utils/urlUtils.ts @@ -0,0 +1,77 @@ +import dns from "node:dns/promises"; +import net from "node:net"; +import { HttpError } from "./fileUtils"; + +function isBlockedIp(address: string): boolean { + const type = net.isIP(address); + if (!type) return true; + if (type === 4) { + const [a, b] = address.split(".").map(Number); + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 0) return true; + if (a >= 224) return true; // multicast/reserved + return false; + } + const lower = address.toLowerCase(); + if (lower === "::1") return true; + if (lower.startsWith("fe80:")) return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local + if (lower.startsWith("ff")) return true; // multicast + if (lower === "::") return true; + return false; +} + +export async function validateRemoteHost(host: string): Promise { + if (!host) throw new HttpError(400, "Invalid URL host"); + const lowered = host.toLowerCase(); + if (lowered === "localhost" || lowered.endsWith(".local")) { + throw new HttpError(400, "Local addresses are not allowed"); + } + if (net.isIP(host)) { + if (isBlockedIp(host)) throw new HttpError(400, "Local addresses are not allowed"); + return; + } + let records: string[]; + try { + const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]); + records = [ + ...(v4.status === "fulfilled" ? v4.value : []), + ...(v6.status === "fulfilled" ? v6.value : []), + ]; + } catch { + throw new HttpError(400, "Unable to resolve host"); + } + if (records.length === 0) throw new HttpError(400, "Unable to resolve host"); + for (const ip of records) { + if (isBlockedIp(ip)) throw new HttpError(400, "Local addresses are not allowed"); + } +} + +export async function validateRemoteUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new HttpError(400, "Invalid URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new HttpError(400, "URL must start with http:// or https://"); + } + if (parsed.username || parsed.password) { + throw new HttpError(400, "Credentials in URL are not allowed"); + } + if (!parsed.hostname) throw new HttpError(400, "Invalid URL"); + await validateRemoteHost(parsed.hostname); + return url; +} + +export async function normalizeImportUrl(raw: string | null | undefined): Promise { + let value = (raw || "").trim(); + if (!value) throw new HttpError(400, "URL is required"); + if (!value.includes("://")) value = `https://${value}`; + return validateRemoteUrl(value); +} diff --git a/backend/src/utils/validate.ts b/backend/src/utils/validate.ts new file mode 100644 index 0000000..d58a717 --- /dev/null +++ b/backend/src/utils/validate.ts @@ -0,0 +1,13 @@ +import type { ZodType } from "zod"; +import { HttpError } from "./fileUtils"; + +// The explicit `any, any` params decouple T from the schema's Def/Input type positions — +// without them, TS can infer T from ZodEffects/ZodDefault's Input type (which is `T | undefined` +// for a field with `.default()`) instead of its Output type, making parsed fields look optional. +export function parseBody(schema: ZodType, data: unknown): T { + const result = schema.safeParse(data); + if (!result.success) { + throw new HttpError(400, result.error.issues.map((i) => i.message).join("; ") || "Invalid request body"); + } + return result.data; +} diff --git a/backend/src/utils/zipReader.ts b/backend/src/utils/zipReader.ts new file mode 100644 index 0000000..2e75844 --- /dev/null +++ b/backend/src/utils/zipReader.ts @@ -0,0 +1,127 @@ +import yauzl from "yauzl"; + +export type ZipEntryInfo = { name: string; size: number; isDirectory: boolean }; + +function openZip(filePath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(filePath, { lazyEntries: true, autoClose: false }, (err, zipfile) => { + if (err || !zipfile) return reject(err || new Error("Failed to open zip")); + resolve(zipfile); + }); + }); +} + +/** Lists every entry in a zip archive (files and directories) without extracting contents. */ +export async function listZipEntries(filePath: string): Promise { + const zipfile = await openZip(filePath); + return new Promise((resolve, reject) => { + const entries: ZipEntryInfo[] = []; + zipfile.on("entry", (entry) => { + const isDirectory = entry.fileName.endsWith('/'); + entries.push({ name: entry.fileName, size: entry.uncompressedSize, isDirectory }); + zipfile.readEntry(); + }); + zipfile.on("end", () => { + zipfile.close(); + resolve(entries); + }); + zipfile.on("error", (err) => { + zipfile.close(); + reject(err); + }); + zipfile.readEntry(); + }); +} + +/** + * Reads a single named entry into memory, capped at maxBytes (returns null if the + * entry doesn't exist, is a directory, or exceeds the cap). Used for small metadata + * files (thumbnails, slicer metadata) inside .3mf/.zip archives, never whole models. + */ +export async function readZipEntry(filePath: string, entryName: string, maxBytes: number): Promise { + const zipfile = await openZip(filePath); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (value: Buffer | null, err?: Error) => { + if (settled) return; + settled = true; + zipfile.close(); + if (err) reject(err); + else resolve(value); + }; + zipfile.on("entry", (entry) => { + if (entry.fileName !== entryName || entry.fileName.endsWith('/')) { + zipfile.readEntry(); + return; + } + if (entry.uncompressedSize > maxBytes) { + finish(null); + return; + } + zipfile.openReadStream(entry, (err, stream) => { + if (err || !stream) { + finish(null, err ?? undefined); + return; + } + const chunks: Buffer[] = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + stream.destroy(); + finish(null); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => finish(Buffer.concat(chunks))); + stream.on("error", (streamErr) => finish(null, streamErr)); + }); + }); + zipfile.on("end", () => finish(null)); + zipfile.on("error", (err) => finish(null, err)); + zipfile.readEntry(); + }); +} + +/** + * Streams every requested entry to an `onEntry` callback in one pass over the archive + * (used to extract many selected entries, or every entry, without reopening the zip + * per file). `onEntry` receives the entry metadata and a readable stream; it must + * consume or destroy the stream before returning so the walk can continue. + */ +export async function walkZipEntries( + filePath: string, + shouldExtract: (entry: ZipEntryInfo) => boolean, + onEntry: (entry: ZipEntryInfo, stream: NodeJS.ReadableStream) => Promise, +): Promise { + const zipfile = await openZip(filePath); + await new Promise((resolve, reject) => { + zipfile.on("entry", (entry) => { + const isDirectory = entry.fileName.endsWith('/'); + const info: ZipEntryInfo = { name: entry.fileName, size: entry.uncompressedSize, isDirectory }; + if (isDirectory || !shouldExtract(info)) { + zipfile.readEntry(); + return; + } + zipfile.openReadStream(entry, (err, stream) => { + if (err || !stream) { + zipfile.readEntry(); + return; + } + onEntry(info, stream) + .catch(() => undefined) + .finally(() => zipfile.readEntry()); + }); + }); + zipfile.on("end", () => { + zipfile.close(); + resolve(); + }); + zipfile.on("error", (err) => { + zipfile.close(); + reject(err); + }); + zipfile.readEntry(); + }); +} diff --git a/backend/src/utils/zipWriter.ts b/backend/src/utils/zipWriter.ts new file mode 100644 index 0000000..eae9c05 --- /dev/null +++ b/backend/src/utils/zipWriter.ts @@ -0,0 +1,166 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import zlib from "node:zlib"; + +// Minimal streaming ZIP writer (local header + streamed DEFLATE data + trailing data +// descriptor + central directory + EOCD). No external zip-writing dependency is available in +// this project (yauzl, the existing dependency, is read-only), so archive creation for +// /download/zip and /folder/:id/download is implemented directly against node:zlib here. + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32Update(crc: number, buf: Buffer): number { + let c = crc ^ 0xffffffff; + for (let i = 0; i < buf.length; i++) { + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date: Date): { time: number; date: number } { + const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f); + const dateVal = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f); + return { time, date: dateVal }; +} + +export type ZipEntryDescriptor = { arcname: string; filePath: string }; + +type CentralRecord = { + nameBuf: Buffer; + crc: number; + compressedSize: number; + uncompressedSize: number; + localHeaderOffset: number; + time: number; + date: number; +}; + +/** Streams `entries` into a new zip file at `destPath`. Skips any entry whose source file no + * longer exists on disk (best-effort, mirrors the caller filtering unresolved files first). */ +export async function writeZip(destPath: string, entries: ZipEntryDescriptor[]): Promise { + const out = fs.createWriteStream(destPath); + let offset = 0; + const write = (buf: Buffer): Promise => + new Promise((resolve, reject) => { + out.write(buf, (err) => (err ? reject(err) : resolve())); + offset += buf.length; + }); + + const records: CentralRecord[] = []; + + try { + for (const entry of entries) { + let stat; + try { + stat = await fsp.stat(entry.filePath); + } catch { + continue; + } + const nameBuf = Buffer.from(entry.arcname.replace(/\\/g, "/"), "utf-8"); + const { time, date } = dosDateTime(stat.mtime); + const localHeaderOffset = offset; + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0x0008, 6); // flags: data descriptor follows + localHeader.writeUInt16LE(8, 8); // method: deflate + localHeader.writeUInt16LE(time, 10); + localHeader.writeUInt16LE(date, 12); + localHeader.writeUInt32LE(0, 14); // crc (in descriptor) + localHeader.writeUInt32LE(0, 18); // compressed size (in descriptor) + localHeader.writeUInt32LE(0, 22); // uncompressed size (in descriptor) + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); + await write(localHeader); + await write(nameBuf); + + let crc = 0; + let uncompressedSize = 0; + let compressedSize = 0; + + await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(entry.filePath); + const deflate = zlib.createDeflateRaw(); + readStream.on("data", (raw: string | Buffer) => { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + crc = crc32Update(crc, chunk); + uncompressedSize += chunk.length; + }); + readStream.on("error", reject); + deflate.on("data", (raw: string | Buffer) => { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + compressedSize += chunk.length; + offset += chunk.length; + if (!out.write(chunk)) { + deflate.pause(); + out.once("drain", () => deflate.resume()); + } + }); + deflate.on("error", reject); + deflate.on("end", resolve); + readStream.pipe(deflate); + }); + + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(0x08074b50, 0); + descriptor.writeUInt32LE(crc, 4); + descriptor.writeUInt32LE(compressedSize, 8); + descriptor.writeUInt32LE(uncompressedSize, 12); + await write(descriptor); + + records.push({ nameBuf, crc, compressedSize, uncompressedSize, localHeaderOffset, time, date }); + } + + const cdStart = offset; + for (const rec of records) { + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0x0008, 8); // flags + central.writeUInt16LE(8, 10); // method + central.writeUInt16LE(rec.time, 12); + central.writeUInt16LE(rec.date, 14); + central.writeUInt32LE(rec.crc, 16); + central.writeUInt32LE(rec.compressedSize, 20); + central.writeUInt32LE(rec.uncompressedSize, 24); + central.writeUInt16LE(rec.nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra length + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); // disk number start + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(0, 38); // external attrs + central.writeUInt32LE(rec.localHeaderOffset, 42); + await write(central); + await write(rec.nameBuf); + } + const cdSize = offset - cdStart; + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(records.length, 8); + eocd.writeUInt16LE(records.length, 10); + eocd.writeUInt32LE(cdSize, 12); + eocd.writeUInt32LE(cdStart, 16); + eocd.writeUInt16LE(0, 20); + await write(eocd); + + await new Promise((resolve, reject) => out.end((err?: Error | null) => (err ? reject(err) : resolve()))); + } catch (err) { + out.destroy(); + throw err; + } +} diff --git a/backend/tests/api.test.ts b/backend/tests/api.test.ts new file mode 100644 index 0000000..9cdac8b --- /dev/null +++ b/backend/tests/api.test.ts @@ -0,0 +1,312 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createApp } from "../src/app"; +import { prisma } from "../src/db"; +import { listZipEntries } from "../src/utils/zipReader"; + +const app = createApp(); + +let token: string; +const createdPrintIds: string[] = []; +const createdFolderIds: string[] = []; + +function tmpFile(name: string, contents: string): string { + // supertest's .attach() uses the path's basename as the uploaded filename, so each file + // needs its own directory to keep that basename exactly as given (e.g. "solo.stl"). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "printstash-test-")); + const p = path.join(dir, name); + fs.writeFileSync(p, contents); + return p; +} + +async function trackPrint(id: string) { + createdPrintIds.push(id); +} + +async function cleanupPrint(id: string) { + await request(app).delete(`/print/${id}`).set("Authorization", `Bearer ${token}`); +} + +beforeAll(async () => { + const res = await request(app).post("/login").send({ username: "admin", password: "super-secret" }); + if (res.status !== 200) { + throw new Error(`Failed to log in during test setup: ${res.status} ${JSON.stringify(res.body)}`); + } + token = res.body.token; +}); + +afterAll(async () => { + for (const id of createdPrintIds) { + await cleanupPrint(id).catch(() => undefined); + } + for (const id of createdFolderIds) { + await request(app) + .delete(`/folder/${id}`) + .set("Authorization", `Bearer ${token}`) + .catch(() => undefined); + } + await prisma.$disconnect(); +}); + +function auth(req: request.Test): request.Test { + return req.set("Authorization", `Bearer ${token}`); +} + +describe("auth", () => { + it("rejects requests without a token", async () => { + const res = await request(app).get("/prints"); + expect(res.status).toBe(401); + }); + + it("rejects a bad login", async () => { + const res = await request(app).post("/login").send({ username: "admin", password: "wrong" }); + expect(res.status).toBe(401); + }); +}); + +describe("upload: separate vs multiplate", () => { + it("uploading a single file creates a one-plate print", async () => { + const f = tmpFile("solo.stl", "solid solo endsolid"); + const res = await auth(request(app).post("/upload")).attach("files", f); + expect(res.status).toBe(200); + expect(res.body.prints).toHaveLength(1); + expect(res.body.prints[0].plates).toHaveLength(1); + expect(res.body.prints[0].plates[0].filename).toBe("solo.stl"); + await trackPrint(res.body.prints[0].id); + fs.rmSync(f, { force: true }); + }); + + it("mode=separate creates one print per file", async () => { + const f1 = tmpFile("sep1.stl", "solid sep1 endsolid"); + const f2 = tmpFile("sep2.stl", "solid sep2 endsolid"); + const res = await auth(request(app).post("/upload")) + .field("mode", "separate") + .attach("files", f1) + .attach("files", f2); + expect(res.status).toBe(200); + expect(res.body.prints).toHaveLength(2); + for (const p of res.body.prints) { + expect(p.plates).toHaveLength(1); + await trackPrint(p.id); + } + fs.rmSync(f1, { force: true }); + fs.rmSync(f2, { force: true }); + }); + + it("mode=multiplate creates one print with several plates in submitted order", async () => { + const f1 = tmpFile("multi-a.stl", "solid a endsolid"); + const f2 = tmpFile("multi-b.stl", "solid b endsolid"); + const f3 = tmpFile("multi-c.stl", "solid c endsolid"); + const res = await auth(request(app).post("/upload")) + .field("mode", "multiplate") + .field("title", "Multi Test Print") + .attach("files", f1) + .attach("files", f2) + .attach("files", f3); + expect(res.status).toBe(200); + expect(res.body.prints).toHaveLength(1); + const print = res.body.prints[0]; + expect(print.plates.map((p: any) => p.filename)).toEqual(["multi-a.stl", "multi-b.stl", "multi-c.stl"]); + expect(print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]); + await trackPrint(print.id); + fs.rmSync(f1, { force: true }); + fs.rmSync(f2, { force: true }); + fs.rmSync(f3, { force: true }); + }); + + it("requires mode when uploading more than one file without specifying it", async () => { + const f1 = tmpFile("nomodeA.stl", "solid a endsolid"); + const f2 = tmpFile("nomodeB.stl", "solid b endsolid"); + const res = await auth(request(app).post("/upload")).attach("files", f1).attach("files", f2); + expect(res.status).toBe(400); + fs.rmSync(f1, { force: true }); + fs.rmSync(f2, { force: true }); + }); +}); + +describe("plate add/remove/reorder/rename", () => { + let printId: string; + let plateIds: string[] = []; + + beforeAll(async () => { + const f1 = tmpFile("plate-x.stl", "solid x endsolid"); + const f2 = tmpFile("plate-y.stl", "solid y endsolid"); + const res = await auth(request(app).post("/upload")) + .field("mode", "multiplate") + .field("title", "Plate Ops Print") + .attach("files", f1) + .attach("files", f2); + printId = res.body.prints[0].id; + plateIds = res.body.prints[0].plates.map((p: any) => p.id); + await trackPrint(printId); + fs.rmSync(f1, { force: true }); + fs.rmSync(f2, { force: true }); + }); + + it("adds a plate to an existing print", async () => { + const f = tmpFile("plate-z.stl", "solid z endsolid"); + const res = await auth(request(app).post(`/print/${printId}/plates`)).attach("files", f); + expect(res.status).toBe(200); + expect(res.body.print.plates).toHaveLength(3); + expect(res.body.print.plates[2].filename).toBe("plate-z.stl"); + plateIds = res.body.print.plates.map((p: any) => p.id); + fs.rmSync(f, { force: true }); + }); + + it("reorders plates", async () => { + const reversed = plateIds.toReversed(); + const res = await auth(request(app).post(`/print/${printId}/plates/reorder`)).send({ plate_ids: reversed }); + expect(res.status).toBe(200); + expect(res.body.print.plates.map((p: any) => p.id)).toEqual(reversed); + expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]); + }); + + it("renames a plate's filename", async () => { + const target = plateIds[0]; + const res = await auth(request(app).post(`/print/${printId}/plate/${target}/rename`)).send({ + filename: "renamed.stl", + }); + expect(res.status).toBe(200); + const renamed = res.body.print.plates.find((p: any) => p.id === target); + expect(renamed.filename).toBe("renamed.stl"); + }); + + it("removes plates down to one, then 409s on the last one", async () => { + let res = await auth(request(app).delete(`/print/${printId}/plates/${plateIds[0]}`)); + expect(res.status).toBe(200); + expect(res.body.print.plates).toHaveLength(2); + expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1]); + + res = await auth(request(app).delete(`/print/${printId}/plates/${plateIds[1]}`)); + expect(res.status).toBe(200); + expect(res.body.print.plates).toHaveLength(1); + + const lastPlateId = res.body.print.plates[0].id; + res = await auth(request(app).delete(`/print/${printId}/plates/${lastPlateId}`)); + expect(res.status).toBe(409); + }); +}); + +describe("folder CRUD + cycle rejection", () => { + it("creates, lists, updates, and deletes a folder", async () => { + const create = await auth(request(app).post("/folders")).send({ name: "Test Folder A", tags: ["x"] }); + expect(create.status).toBe(200); + const folderId = create.body.id; + + const list = await auth(request(app).get("/folders")); + expect(list.status).toBe(200); + expect(list.body.some((f: any) => f.id === folderId)).toBe(true); + + const update = await auth(request(app).patch(`/folder/${folderId}`)).send({ name: "Test Folder A Renamed", tags: [] }); + expect(update.status).toBe(200); + expect(update.body.name).toBe("Test Folder A Renamed"); + + const del = await auth(request(app).delete(`/folder/${folderId}`)); + expect(del.status).toBe(200); + }); + + it("rejects a parent that does not exist", async () => { + const res = await auth(request(app).post("/folders")).send({ name: "Orphan", tags: [], parent_id: "does-not-exist" }); + expect(res.status).toBe(400); + }); + + it("rejects creating a cycle", async () => { + const parent = await auth(request(app).post("/folders")).send({ name: "Cycle Parent", tags: [] }); + const child = await auth(request(app).post("/folders")).send({ + name: "Cycle Child", + tags: [], + parent_id: parent.body.id, + }); + const attempt = await auth(request(app).patch(`/folder/${parent.body.id}`)).send({ + name: "Cycle Parent", + tags: [], + parent_id: child.body.id, + }); + expect(attempt.status).toBe(400); + + await auth(request(app).delete(`/folder/${child.body.id}`)); + await auth(request(app).delete(`/folder/${parent.body.id}`)); + }); +}); + +describe("prepared-print attach/detect/remove", () => { + let printId: string; + + beforeAll(async () => { + const f = tmpFile("bare.stl", "solid bare endsolid"); + const res = await auth(request(app).post("/upload")).attach("files", f); + printId = res.body.prints[0].id; + await trackPrint(printId); + fs.rmSync(f, { force: true }); + }); + + it("starts with no prepared print", async () => { + const res = await auth(request(app).get("/prints")); + const print = res.body.find((p: any) => p.id === printId); + expect(print.prepared_print).toBeNull(); + }); + + it("attaches an explicit prepared gcode file", async () => { + const gcode = tmpFile( + "sliced.gcode", + "; printer_model = Bambu Lab X1 Carbon\n; filament_type = PETG\n; nozzle_diameter = 0.4\n; layer_height = 0.16\n; estimated printing time (normal mode) = 1h 5m\nG28\n", + ); + const res = await auth(request(app).post(`/print/${printId}/files`)).attach("file", gcode); + expect(res.status).toBe(200); + expect(res.body.print.prepared_print).toMatchObject({ + printer: "Bambu Lab X1 Carbon", + material: "PETG", + nozzle_mm: 0.4, + layer_height_mm: 0.16, + removable: true, + }); + expect(res.body.print.slicer_filename).toBe("sliced.gcode"); + fs.rmSync(gcode, { force: true }); + }); + + it("removes the prepared file and falls back to null (bare .stl isn't sniffable)", async () => { + const res = await auth(request(app).delete(`/print/${printId}/prepared-print`)); + expect(res.status).toBe(200); + expect(res.body.print.prepared_print).toBeNull(); + }); +}); + +describe("zip download arcname structure", () => { + it("nests plates under {folder}/{print.name}/ and supporting files under .../supporting/", async () => { + const folder = await auth(request(app).post("/folders")).send({ name: "Zip Folder Test", tags: [] }); + createdFolderIds.push(folder.body.id); + + const f = tmpFile("zippy.stl", "solid zippy endsolid"); + const upload = await auth(request(app).post("/upload")) + .field("title", "Zippy Print") + .field("folder_id", folder.body.id) + .attach("files", f); + const printId = upload.body.prints[0].id; + createdPrintIds.push(printId); + fs.rmSync(f, { force: true }); + + const note = tmpFile("readme.txt", "hello"); + await auth(request(app).post(`/print/${printId}/files`)).attach("file", note); + fs.rmSync(note, { force: true }); + + const zipRes = await auth(request(app).post("/download/zip")) + .send({ print_ids: [printId] }) + .buffer(true) + .parse((res, cb) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => cb(null, Buffer.concat(chunks))); + }); + expect(zipRes.status).toBe(200); + + const tmpZip = path.join(os.tmpdir(), `test-${Date.now()}.zip`); + fs.writeFileSync(tmpZip, zipRes.body as Buffer); + const entries = await listZipEntries(tmpZip); + const names = entries.filter((e) => !e.isDirectory).map((e) => e.name).toSorted(); + expect(names).toEqual(["Zip Folder Test/Zippy Print/supporting/readme.txt", "Zip Folder Test/Zippy Print/zippy.stl"]); + fs.rmSync(tmpZip, { force: true }); + }); +}); diff --git a/backend/tests/printService.test.ts b/backend/tests/printService.test.ts new file mode 100644 index 0000000..4899aaf --- /dev/null +++ b/backend/tests/printService.test.ts @@ -0,0 +1,110 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { prisma } from "../src/db"; +import { + availableModelName, + availablePlateFilename, + samplePlateStoragePaths, + uniqueModelName, + validateStorageTemplate, + DEFAULT_STORAGE_TEMPLATE, +} from "../src/services/printService"; + +describe("validateStorageTemplate", () => { + it("accepts the default template", () => { + expect(validateStorageTemplate(DEFAULT_STORAGE_TEMPLATE)).toBe(DEFAULT_STORAGE_TEMPLATE); + }); + + it("rejects a template missing {filename}", () => { + expect(() => validateStorageTemplate("{folder}/{model}")).toThrow(/exactly once/); + }); + + it("rejects an unknown token", () => { + expect(() => validateStorageTemplate("{folder}/{bogus}/{filename}")).toThrow(/Unknown storage token/); + }); + + it("rejects {filename} outside the final segment", () => { + expect(() => validateStorageTemplate("{filename}/{model}")).toThrow(/final path segment/); + }); + + it("accepts the new {plate} token", () => { + expect(validateStorageTemplate("{model}/plate-{plate}/{filename}")).toBe("{model}/plate-{plate}/{filename}"); + }); +}); + +describe("samplePlateStoragePaths", () => { + it("renders two sibling plate paths sharing the same model directory", () => { + const [first, second] = samplePlateStoragePaths(DEFAULT_STORAGE_TEMPLATE); + expect(first).not.toBe(second); + const firstDir = first.split("/").slice(0, -1).join("/"); + const secondDir = second.split("/").slice(0, -1).join("/"); + expect(firstDir).toBe(secondDir); + }); +}); + +describe("print name + plate filename uniqueness (integration, real Postgres)", () => { + const createdPrintIds: string[] = []; + + afterAll(async () => { + if (createdPrintIds.length) { + await prisma.print.deleteMany({ where: { id: { in: createdPrintIds } } }); + } + await prisma.$disconnect(); + }); + + it("availableModelName auto-suffixes root-level collisions", async () => { + const name = `Test Widget ${Date.now()}`; + const first = await prisma.print.create({ + data: { name, nameNormalized: name.toLowerCase(), tags: [] }, + }); + createdPrintIds.push(first.id); + + const nextName = await availableModelName(name, null); + expect(nextName).toBe(`${name} (2)`); + }); + + it("uniqueModelName throws 409 on an explicit collision", async () => { + const name = `Test Gadget ${Date.now()}`; + const first = await prisma.print.create({ + data: { name, nameNormalized: name.toLowerCase(), tags: [] }, + }); + createdPrintIds.push(first.id); + + await expect(uniqueModelName(name, null)).rejects.toMatchObject({ status: 409 }); + }); + + it("the partial unique index rejects two root-level prints with the same name", async () => { + const name = `Test Collision ${Date.now()}`; + const first = await prisma.print.create({ + data: { name, nameNormalized: name.toLowerCase(), tags: [] }, + }); + createdPrintIds.push(first.id); + + await expect( + prisma.print.create({ data: { name, nameNormalized: name.toLowerCase(), tags: [] } }), + ).rejects.toThrow(/unique constraint/i); + }); + + it("availablePlateFilename auto-suffixes a duplicate filename within the same print", async () => { + const name = `Test Multiplate ${Date.now()}`; + const print = await prisma.print.create({ + data: { name, nameNormalized: name.toLowerCase(), tags: [] }, + }); + createdPrintIds.push(print.id); + await prisma.plate.create({ + data: { + printId: print.id, + position: 0, + filename: "body.stl", + mime: "model/stl", + size: 100, + storagePath: `test/${print.id}/body.stl`, + }, + }); + + const nextFilename = await availablePlateFilename(print.id, "body.stl"); + expect(nextFilename).toBe("body (2).stl"); + + const untakenFilename = await availablePlateFilename(print.id, "lid.stl"); + expect(untakenFilename).toBe("lid.stl"); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..91bff9d --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..051e657 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 20000, + hookTimeout: 20000, + }, +}); diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..15032e8 --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER:-printstash} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-printstash} + - POSTGRES_DB=${POSTGRES_DB:-printstash} + volumes: + - printstash_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-printstash}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + image: ${BACKEND_IMAGE:-printstash-backend:latest} + restart: unless-stopped + environment: + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} + - AUTH_USERNAME=${AUTH_USERNAME:-admin} + - AUTH_PASSWORD=${AUTH_PASSWORD:-super-secret} + - AUTH_SECRET=${AUTH_SECRET:-changeme-secret} + - AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200} + - FILE_STORAGE=/app/storage + - DATABASE_URL=postgresql://${POSTGRES_USER:-printstash}:${POSTGRES_PASSWORD:-printstash}@db:5432/${POSTGRES_DB:-printstash}?schema=public + - PUBLIC_URL=${PUBLIC_URL:-} + - CORS_ORIGINS=${CORS_ORIGINS:-} + - IMPORT_MOUNT_PATH=${IMPORT_MOUNT_PATH:-} + - IMPORT_MOUNT_EXTS=${IMPORT_MOUNT_EXTS:-stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip} + - IMPORT_MOUNT_INCLUDE_HIDDEN=${IMPORT_MOUNT_INCLUDE_HIDDEN:-false} + - IMPORT_MOUNT_ON_STARTUP=${IMPORT_MOUNT_ON_STARTUP:-false} + - IMPORT_MOUNT_COPY=${IMPORT_MOUNT_COPY:-true} + depends_on: + db: + condition: service_healthy + volumes: + - printstash_storage:/app/storage + - ${IMPORT_MOUNT_PATH_HOST:-./imports}:/imports:ro + ports: + - "${API_PORT:-8000}:8000" + +volumes: + printstash_storage: + printstash_db: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a06ca6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER:-printstash} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-printstash} + - POSTGRES_DB=${POSTGRES_DB:-printstash} + volumes: + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-printstash}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - "${API_PORT:-8000}:8000" + environment: + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} + - FILE_STORAGE=/app/storage + - DATABASE_URL=postgresql://${POSTGRES_USER:-printstash}:${POSTGRES_PASSWORD:-printstash}@db:5432/${POSTGRES_DB:-printstash}?schema=public + - CORS_ORIGINS=${CORS_ORIGINS:-} + - AUTH_USERNAME=${AUTH_USERNAME:-admin} + - AUTH_PASSWORD=${AUTH_PASSWORD:-super-secret} + - AUTH_SECRET=${AUTH_SECRET:-changeme-secret} + - AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200} + depends_on: + db: + condition: service_healthy + volumes: + - ./data/storage:/app/storage diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md new file mode 100644 index 0000000..99c7e40 --- /dev/null +++ b/docs/API_CONTRACT.md @@ -0,0 +1,159 @@ +# PrintStash API contract + +Node.js + Express + Prisma (Postgres) backend, ported from MakersVault's FastAPI/SQLModel/SQLite +backend, with one structural change: a **Print** is a set of one or more **Plates** (multi-part +prints) instead of a single file. Everything else behaves the same as MakersVault. + +Base path: no prefix, ever — routes are mounted directly (`/health`, `/prints`, `/login`, ...). +In production the backend also serves the built frontend as static files from the same origin +(see `backend/src/app.ts`'s `express.static(FRONTEND_DIST)`), so the UI calls these routes with +plain relative paths and no reverse proxy is involved. + +Auth: identical scheme to MakersVault — single-user JWT (HS256). `AUTH_USERNAME`/`AUTH_PASSWORD`/ +`AUTH_SECRET`/`AUTH_TOKEN_TTL` env vars. `AUTH_ENABLED = Boolean(username && password)`. Every +route (except `/health`) runs a `requireAuth` middleware that accepts either an `Authorization: +Bearer ` header or a `?token=` query param, and is a no-op when auth is disabled. + +## Types + +```ts +type PreparedPrint = { + printer?: string | null; + material?: string | null; + nozzle_mm?: number | null; + layer_height_mm?: number | null; + estimated_seconds?: number | null; + format?: string | null; // "gcode" | "bgcode" | "gcode_3mf" + removable: boolean; +}; + +type Plate = { + id: string; + print_id: string; + position: number; // dense, 0-based, ordered + filename: string; + mime: string; + size: number; + url: string; // /print/{printId}/plate/{plateId}/file/{filename} + thumb_url?: string | null; // /plate/{plateId}/thumb.jpg?v=... if a thumb exists +}; + +type PrintFile = { // supporting doc or prepared-print attachment (print-level, not per-plate) + id: string; + filename: string; + mime: string; + size: number; + url: string; // /print/{printId}/files/{fileId} +}; + +type Print = { + id: string; + name: string; + title?: string | null; + notes?: string | null; + creator?: string | null; + collection?: string | null; + tags: string[]; + folder_id?: string | null; + storage_path?: string | null; // parent directory shared by all plates + plates: Plate[]; // ordered by position, length >= 1 + thumb_url?: string | null; // denormalized = plates[0].thumb_url, for card grids + supporting_file_count: number; + prepared_print?: PreparedPrint | null; + slicer_url?: string | null; // prepared file if present, else plates[0].url + slicer_filename?: string | null; +}; + +type Folder = { id: string; name: string; tags: string[]; parent_id?: string | null }; +``` + +## Routes + +Mechanical renames from MakersVault (identical request/response shape, `asset` → `print`): + +- `GET /health` -> `{ ok, auth_required }` +- `GET/POST /settings/mount-import` +- `GET/POST /settings/storage` (sample path now returns a `plate_paths: string[]` array of 2 + example rendered paths instead of a single `sample_path` string, to show that sibling plates + share the `{model}` directory) +- `POST /login`, `POST /refresh` +- `GET/POST /print/:id/files`, `GET/DELETE /print/:id/files/:fileId` (supporting files, unchanged + semantics) +- `GET/DELETE /print/:id/prepared-print` +- `POST /print/:id/tags`, `POST /print/:id/meta`, `DELETE /print/:id`, `POST /print/:id/folder` +- `GET/POST /folders`, `PATCH /folder/:id`, `DELETE /folder/:id`, `GET /folder/:id/download` +- `POST /import`, `POST /import/inspect`, `POST /import/zip/entries`, `POST /import/zip` — URL and + zip-extraction imports always create a single-plate print per item (the separate/multiplate + choice below only applies to the direct upload picker/drag-drop). + +Shape changes / new routes (plate-aware): + +- `POST /upload` — multipart, **multiple `files` fields accepted**. Additional field + `mode: "separate" | "multiplate"` (required only when more than one file is sent; ignored for a + single file). Always responds `{ prints: Print[] }`. + - `mode=separate` (or single file): each file becomes its own single-plate `Print`. + - `mode=multiplate`: all files become `Plate`s (in submitted order) of one new `Print`. +- `POST /print/:id/plates` — multipart, add one or more plates to an existing print (used by + "drop a file onto an existing print card" and the print detail view's "Add plate" action). + Appends at `position = max(position)+1`. Responds `{ print: Print }`. +- `DELETE /print/:id/plates/:plateId` — remove a plate; 409 if it is the only remaining plate + (delete the whole print instead). Renumbers remaining plates densely. Responds `{ print: Print }`. +- `POST /print/:id/plates/reorder` — body `{ plate_ids: string[] }` (full ordered list), rewrites + `position` transactionally. Responds `{ print: Print }`. +- `POST /print/:id/plate/:plateId/rename` — body `{ filename: string }`. Responds `{ print: Print }`. +- `GET /print/:id/plate/:plateId/file/:filename` — streams the plate's file (replaces + `GET /file/{asset_id}/{name}`). +- `GET /print/:id/thumb.jpg` — the print card thumbnail (= plate[0]'s thumbnail). +- `GET /plate/:plateId/thumb.jpg` — a specific plate's thumbnail (for the plate switcher UI). +- `POST /plate/:plateId/thumbnail-generated` — client-rendered PNG/JPEG/WebP snapshot for one + plate (replaces `POST /asset/{id}/thumbnail-generated`), same 8MB cap. +- `GET /prints` (was `GET /assets`) — same filters (`q`, `tags`, `folder_id`) and pagination + (`limit`, `offset`, `X-Has-More`/`X-Next-Offset` headers); sort key becomes + `name, plates[0].filename, id`. +- `GET /tags` (was `GET /tags`) — same filters, implemented via `unnest(tags)` over the filtered + `Print` set. +- `POST /download/zip` — body `{ print_ids?: string[], tag?: string, folder_id?: string, filename?: string }` + (was `{ asset_ids, tag, folder_id, filename }`). +- `POST /import/zip` response — `{ prints: Print[], failed: string[] }` (was `{ assets, failed }`). +- `POST /download/zip`, `GET /folder/:id/download` — zip arcname becomes + `{folder_or_unassigned}/{print.name}/{plate.filename}` for every plate, and + `{folder_or_unassigned}/{print.name}/supporting/{file.filename}` for supporting files. The extra + `{print.name}` directory level (new vs. MakersVault) is required so sibling plates from + different prints, or same-named plates across prints, can't collide in the zip. + +Retired: `POST /asset/{id}/rename` (renaming "the file" doesn't map onto a print with no single +filename) — replaced by `POST /print/:id/plate/:plateId/rename` for plate filenames, while +`Print.name` (the folder-unique display name) renames via the existing `POST /print/:id/meta`. + +## Storage path templating + +Same template contract as MakersVault (`validate_storage_template`, default +`{folder}/{model}/{filename}`, tokens `folder,collection,tags,creator,model,name,filename,id`, +`{filename}` required exactly once in the final path segment). Rendered **once per Plate**: + +- `{model}` / `{name}` = `Print.name` (shared by every plate of a print — this is what makes all + plates land in the same `{folder}/{model}/` directory under the default template). +- `{filename}` = the individual `Plate.filename`. +- `{folder}`, `{collection}`, `{tags}`, `{creator}` = pulled from the parent `Print`. +- `{id}` = the owning `Print.id` (not a plate id). +- New optional token `{plate}` = 1-based `Plate.position + 1`, for templates that want an explicit + per-plate disambiguator. Not needed by the default template. + +Filename collisions between two plates of the *same* print (e.g. two uploads both named +`body.stl`) are resolved by an `availablePlateFilename(printId, dirTokens, desiredFilename)` +helper that auto-suffixes (`body.stl` -> `body-2.stl`), mirroring the existing per-folder +`unique_model_name`/`available_model_name` dedup pattern. `PrintFile`s (supporting/prepared) are +never template-rendered; they always live at `STORAGE/bundles/{printId}/{fileId}/{filename}`. + +## Behavioral notes carried over unchanged + +- Folder-scoped `Print.name` uniqueness (case-insensitive), including at the root — enforced via + `nameNormalized` + the composite unique index plus a partial unique index for `folderId IS + NULL` (see `schema.prisma` migration comment). +- Prepared-print detection/metadata sniffing (`prepared_print.py` logic) stays **print-level**: a + sliced job output covers the whole build plate across all plates, so it is not tied to a single + `Plate`. +- Mount-import volume scanning, zip-import wizard, MakerWorld/Printables/Thingiverse link import, + and settings (mount-import, storage template) all behave the same; each imported/extracted item + is a single-plate `Print` (no multiplate prompt in these flows). +- SSRF guard on outbound import URLs, filename sanitization, size limits — unchanged. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7f171e6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +npm-debug.log* diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..5040127 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "react", "react-perf", "jsx-a11y", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "warn" + }, + "rules": { + "no-await-in-loop": "off" + }, + "env": { + "builtin": true, + "browser": true + }, + "ignorePatterns": ["dist", "node_modules", "coverage"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..78ff3cb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-alpine AS dependencies +WORKDIR /app +COPY package*.json ./ +RUN npm ci --no-audit --no-fund + +FROM dependencies AS test +COPY . . +RUN npm run verify +CMD ["npm", "run", "verify"] + +FROM dependencies AS build +COPY . . +# VITE_API_URL is baked in at build time (Vite env vars aren't readable at container start). +# Leave it unset to default to same-origin/localhost:8000 resolution (see src/services/api.ts), +# or pass --build-arg VITE_API_URL=https://api.example.com for a fixed backend address. +ARG VITE_API_URL +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +# A standalone static-file image: no nginx, no reverse proxy, no /api rewriting. This client +# talks to the backend directly (same-origin default, VITE_API_URL, or the Settings > Network +# override) — it doesn't assume anything about how or where the backend is deployed, since the +# frontend is meant to be one of several possible client apps (web today, others later). +FROM node:20-alpine AS runtime +WORKDIR /app +RUN npm install --global serve@14 --no-audit --no-fund +COPY --from=build /app/dist ./dist +EXPOSE 5173 +CMD ["serve", "-s", "dist", "-l", "5173"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b63060 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + PrintStash + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..00110a0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3194 @@ +{ + "name": "printstash-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "printstash-frontend", + "version": "0.0.1", + "dependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.0", + "@mui/material": "^6.1.0", + "fflate": "0.8.3", + "i18next": "^23.15.0", + "i18next-browser-languagedetector": "^8.0.0", + "occt-import-js": "0.0.23", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^15.0.0", + "three": "^0.160.0" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@types/three": "^0.160.0", + "oxlint": "^1.81.0", + "typescript": "5.6.3", + "vite": "^6.4.3", + "vitest": "3.2.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", + "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz", + "integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", + "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.5.0", + "@mui/system": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.9", + "@mui/styled-engine": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.160.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.160.0.tgz", + "integrity": "sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.6.10", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/occt-import-js": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz", + "integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==", + "license": "LGPL-2.1" + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/three": { + "version": "0.160.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz", + "integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e8c135d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "printstash-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "lint": "oxlint .", + "verify": "npm run typecheck && npm run lint && npm run test:run && npm run build" + }, + "dependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/icons-material": "^6.1.0", + "@mui/material": "^6.1.0", + "fflate": "0.8.3", + "i18next": "^23.15.0", + "i18next-browser-languagedetector": "^8.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^15.0.0", + "three": "^0.160.0", + "occt-import-js": "0.0.23" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@types/three": "^0.160.0", + "oxlint": "^1.81.0", + "typescript": "5.6.3", + "vitest": "3.2.6", + "vite": "^6.4.3" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..accd4fc --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..12cc15f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import Box from "@mui/material/Box"; +import AppLayout from "./common/layout/AppLayout"; +import PrintGrid from "./routes/library"; +import Login from "./routes/login"; +import Settings from "./routes/settings"; +import { apiHealth, getApiBase, refreshToken, type HealthInfo } from "./services/api"; +import { clearToken, readToken, storeToken } from "./services/auth"; +import { type AppSettings, loadSettings, saveSettings } from "./services/settings"; +import { resolveTheme } from "./utils/settingsHelpers"; +import { buildTheme } from "./theme"; + +const DEFAULT_REFRESH_SECONDS = 6 * 60 * 60; // 6 hours + +export default function App() { + const { t } = useTranslation(["app", "common"]); + const [token, setToken] = React.useState(() => readToken()); + const [nonce, setNonce] = React.useState(0); + const [folderId, setFolderId] = React.useState(null); + const [folderVersion, setFolderVersion] = React.useState(0); + const [health, setHealth] = React.useState(null); + const [tokenTtl, setTokenTtl] = React.useState(null); + const [sessionExpired, setSessionExpired] = React.useState(false); + const [activeView, setActiveView] = React.useState<"library" | "settings">("library"); + const [settings, setSettings] = React.useState(() => loadSettings()); + const resolvedTheme = React.useMemo( + () => resolveTheme(settings.theme.selected), + [settings.theme.selected] + ); + const muiTheme = React.useMemo(() => buildTheme(resolvedTheme), [resolvedTheme]); + React.useEffect(() => { (async ()=> setHealth(await apiHealth()))(); }, [settings.network.publicUrl]); + React.useEffect(() => { + saveSettings(settings); + }, [settings]); + const apiUp = health?.ok ?? null; + const authRequired = health?.auth_required ?? true; + + const handleLogin = (tok: string, ttl: number) => { + storeToken(tok); + setToken(tok); + setTokenTtl(ttl); + setSessionExpired(false); + }; + + const handleUnauthorized = React.useCallback(() => { + if (sessionExpired) return; + clearToken(); + setToken(null); + setTokenTtl(null); + setSessionExpired(true); + alert(t("shell.sessionExpired")); + }, [sessionExpired, t]); + + const handleFoldersChanged = React.useCallback(() => { + setFolderVersion(v => v + 1); + }, []); + + const handlePrintsChanged = React.useCallback(() => { + setNonce(n => n + 1); + }, []); + + const handleSelectFolder = React.useCallback((id: string | null) => { + setFolderId(id); + setActiveView("library"); + }, []); + + const handleLogout = () => { + clearToken(); + setToken(null); + setTokenTtl(null); + }; + + const resetSavedProxyUrl = () => { + setSettings(prev => ({ + ...prev, + network: { + ...prev.network, + publicUrl: "", + }, + })); + }; + + React.useEffect(() => { + if (!token) return; + const ttl = tokenTtl ?? DEFAULT_REFRESH_SECONDS; + const refreshMs = Math.max( + 5 * 60 * 1000, + Math.min(ttl * 0.8 * 1000, ttl * 1000 - 5 * 60 * 1000) + ); + const timer = window.setTimeout(async () => { + try { + const res = await refreshToken(); + storeToken(res.token); + setToken(res.token); + setTokenTtl(res.expires_in); + } catch { + handleUnauthorized(); + } + }, refreshMs); + return () => window.clearTimeout(timer); + }, [token, tokenTtl, handleUnauthorized]); + + if (authRequired && !token) { + return ( + + + theme.printstash.pageBackground, + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + p: 2, + }} + > + + + + ); + } + + return ( + setActiveView("settings")} + onBackToLibrary={() => setActiveView("library")} + onLogout={handleLogout} + makerworldCookie={settings.makerworld.cookie} + thingiverseCookie={settings.thingiverse.cookie} + > + {activeView === "library" ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/common/TagBadge.tsx b/frontend/src/common/TagBadge.tsx new file mode 100644 index 0000000..b6e7ef5 --- /dev/null +++ b/frontend/src/common/TagBadge.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import Chip from "@mui/material/Chip"; +import { useTranslation } from "react-i18next"; +import { colorForTag } from "../utils/tagColors"; + +type TagBadgeProps = { + tag: string; + onRemove?: () => void; +}; + +export default function TagBadge({ tag, onRemove }: TagBadgeProps) { + const { t } = useTranslation("app"); + const colors = colorForTag(tag); + return ( + × : undefined + } + sx={{ + backgroundColor: colors.bg, + color: colors.text, + border: "1px solid", + borderColor: colors.border, + fontWeight: 500, + "& .MuiChip-deleteIcon": { + color: "inherit", + opacity: 0.7, + "&:hover": { opacity: 1, color: "inherit" }, + }, + }} + /> + ); +} diff --git a/frontend/src/common/TagInput.tsx b/frontend/src/common/TagInput.tsx new file mode 100644 index 0000000..97868a4 --- /dev/null +++ b/frontend/src/common/TagInput.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import InputBase from "@mui/material/InputBase"; +import TagBadge from "./TagBadge"; + +type TagInputProps = { + value: string[]; + onChange: (tags: string[]) => void; + placeholder?: string; +}; + +export default function TagInput({ value, onChange, placeholder }: TagInputProps) { + const [draft, setDraft] = React.useState(""); + const inputRef = React.useRef(null); + + const normalized = (t: string) => t.trim().replace(/\s+/g, " "); + + const addTag = (raw: string) => { + const tag = normalized(raw); + if (!tag) return; + if (value.some(v => v.toLowerCase() === tag.toLowerCase())) { + setDraft(""); + return; + } + onChange([...value, tag]); + setDraft(""); + }; + + const removeTag = (idx: number) => { + const next = [...value.slice(0, idx), ...value.slice(idx + 1)]; + onChange(next); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(draft); + } else if (e.key === "Backspace" && draft === "" && value.length) { + e.preventDefault(); + removeTag(value.length - 1); + } + }; + + const handleBlur = () => { + if (draft.trim()) addTag(draft); + }; + + return ( + inputRef.current?.focus()} + sx={{ + display: "flex", + flexWrap: "wrap", + gap: 1, + px: 1.5, + py: 1, + borderRadius: 1, + border: "1px solid", + borderColor: "divider", + bgcolor: "background.paper", + fontSize: 14, + cursor: "text", + }} + > + {value.map((tag, idx) => ( + removeTag(idx)} /> + ))} + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + placeholder={value.length === 0 ? placeholder : ""} + sx={{ flex: 1, minWidth: 120, fontSize: 14 }} + /> + + ); +} diff --git a/frontend/src/common/Wordmark.tsx b/frontend/src/common/Wordmark.tsx new file mode 100644 index 0000000..e00035f --- /dev/null +++ b/frontend/src/common/Wordmark.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { type ResolvedTheme } from "../constants/settingsOptions"; + +// PrintStash has no logo asset (yet) -- this text wordmark is an intentional +// placeholder so both the login screen and the library header render +// something branded instead of a 404'd . Swap for a real logo image +// whenever one exists; keep referencing plausible filenames +// (e.g. /img/printstash-logo-light.svg) if that work resumes. +type Props = { + theme: ResolvedTheme; + size?: "lg" | "md"; +}; + +export default function Wordmark({ size = "md" }: Props) { + const fontSize = size === "lg" ? "2.25rem" : "1.5rem"; + return ( + + + ); +} diff --git a/frontend/src/common/layout/AppLayout.tsx b/frontend/src/common/layout/AppLayout.tsx new file mode 100644 index 0000000..9566776 --- /dev/null +++ b/frontend/src/common/layout/AppLayout.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ThemeProvider, type Theme } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Sidebar from "../../routes/library/components/Sidebar"; +import UploadBar from "../../routes/library/components/UploadBar"; +import Wordmark from "../Wordmark"; +import type { ResolvedTheme } from "../../constants/settingsOptions"; + +type AppLayoutProps = { + muiTheme: Theme; + resolvedTheme: ResolvedTheme; + apiUp: boolean | null; + publicUrl: string; + onResetProxyUrl: () => void; + folderId: string | null; + onSelectFolder: (id: string | null) => void; + folderVersion: number; + onFoldersChanged: () => void; + onPrintsChanged: () => void; + onUnauthorized: () => void; + activeView: "library" | "settings"; + onOpenSettings: () => void; + onBackToLibrary: () => void; + onLogout: () => void; + makerworldCookie: string; + thingiverseCookie: string; + apiBase: string; + children: React.ReactNode; +}; + +/** The persistent app shell: theming, the folder sidebar, and the header row (wordmark, upload + * bar or back-to-library button, logout). `children` is the active route's content. */ +export default function AppLayout({ + muiTheme, + resolvedTheme, + apiUp, + publicUrl, + onResetProxyUrl, + folderId, + onSelectFolder, + folderVersion, + onFoldersChanged, + onPrintsChanged, + onUnauthorized, + activeView, + onOpenSettings, + onBackToLibrary, + onLogout, + makerworldCookie, + thingiverseCookie, + apiBase, + children, +}: AppLayoutProps) { + const { t } = useTranslation(["app", "common"]); + + return ( + + + theme.printstash.pageBackground, + minHeight: "100vh", + display: "flex", + }} + > + + + {apiUp === false && ( + + {t("shell.resetProxyUrl")} + + ) + } + > + {t("shell.apiUnreachable", { base: apiBase })} + + )} + + {activeView === "library" && } + + {activeView === "library" ? ( + + ) : ( + + )} + + + + {children} + + + + ); +} diff --git a/frontend/src/constants/featureFlags.ts b/frontend/src/constants/featureFlags.ts new file mode 100644 index 0000000..860e9ee --- /dev/null +++ b/frontend/src/constants/featureFlags.ts @@ -0,0 +1,7 @@ +/** + * The "Open in Slicer" feature depends on the standalone slicer-bridge helper (a desktop app + * registering the printstash-slicer:// protocol), which has been removed from this project for + * now. Flip this back to true once a bridge (or another launch mechanism) exists again — every + * "open in slicer" UI touchpoint is gated behind this single flag. + */ +export const SLICER_BRIDGE_ENABLED = false; diff --git a/frontend/src/constants/fileTypes.ts b/frontend/src/constants/fileTypes.ts new file mode 100644 index 0000000..8f61d33 --- /dev/null +++ b/frontend/src/constants/fileTypes.ts @@ -0,0 +1,30 @@ +/** Extensions (lowercase, no leading dot) the interactive 3D viewer can render. */ +export const MODEL_EXTS = new Set(["stl", "3mf", "step", "stp", "obj"]); + +/** LightBurn project file extensions. */ +export const LIGHTBURN_EXTS = new Set(["lbrn", "lbrn2"]); + +/** Extensions eligible for the "Open in Engraving Software" action. */ +export const ENGRAVING_EXTS = new Set([ + "svg", + "dxf", + "ai", + "eps", + "pdf", + "plt", + "hpgl", + "png", + "jpg", + "jpeg", + "bmp", + "gif", + "tif", + "tiff", + "lbrn", + "lbrn2", + "ezd", +]); + +/** Extensions offered by Settings' local-folder-scan import feature. */ +export const SCAN_EXTS = ["stl", "3mf", "step", "stp", "obj", "lbrn", "lbrn2"]; +export const SCAN_EXT_SET = new Set(SCAN_EXTS); diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts new file mode 100644 index 0000000..6db8de7 --- /dev/null +++ b/frontend/src/constants/languages.ts @@ -0,0 +1,8 @@ +export const SUPPORTED_LANGUAGES = [ + { code: "en", label: "English" }, + { code: "lt", label: "Lietuvių" }, +] as const; + +export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"]; + +export const LANGUAGE_STORAGE_KEY = "printstash_language"; diff --git a/frontend/src/constants/settingsOptions.ts b/frontend/src/constants/settingsOptions.ts new file mode 100644 index 0000000..742cc5f --- /dev/null +++ b/frontend/src/constants/settingsOptions.ts @@ -0,0 +1,41 @@ +export type SlicerOption = { id: string; label: string }; + +export const SLICER_OPTIONS: SlicerOption[] = [ + { id: "orca", label: "OrcaSlicer" }, + { id: "bambu", label: "Bambu Studio" }, + { id: "prusa", label: "PrusaSlicer" }, + { id: "superslicer", label: "SuperSlicer" }, + { id: "cura", label: "UltiMaker Cura" }, + { id: "ideamaker", label: "ideaMaker" }, + { id: "simplify3d", label: "Simplify3D" }, + { id: "kisslicer", label: "KISSlicer" }, + { id: "repetier", label: "Repetier-Host" }, + { id: "chitubox", label: "ChiTuBox" }, + { id: "lychee", label: "Lychee Slicer" }, + { id: "photon", label: "Anycubic Photon Workshop" }, + { id: "creality", label: "Creality Print" }, + { id: "other", label: "Other / Manual" }, +]; + +export type EngraverOption = { id: string; label: string }; + +export const ENGRAVER_OPTIONS: EngraverOption[] = [ + { id: "lightburn", label: "LightBurn" }, + { id: "ezcad", label: "EZCAD" }, + { id: "other", label: "Other / Manual" }, +]; + +/** The user's persisted theme choice — "system" defers to the OS preference. */ +export type ThemeSelection = "system" | "light" | "dark" | "neon" | "purple" | "blue"; +/** A theme choice after "system" has been resolved to light/dark — what buildTheme() consumes. */ +export type ResolvedTheme = "light" | "dark" | "neon" | "purple" | "blue"; +export type ThemeOption = { id: ThemeSelection; label: string; description: string }; + +export const THEME_OPTIONS: ThemeOption[] = [ + { id: "system", label: "System", description: "Match your device preference." }, + { id: "light", label: "Light", description: "Bright backgrounds, dark text." }, + { id: "dark", label: "Dark", description: "Dimmed panels for low light." }, + { id: "neon", label: "Neon Green", description: "Black UI with neon green accents." }, + { id: "purple", label: "Neon Purple", description: "Black UI with purple glow highlights." }, + { id: "blue", label: "Neon Blue", description: "Black UI with blue glow highlights." }, +]; diff --git a/frontend/src/constants/themeSwatches.ts b/frontend/src/constants/themeSwatches.ts new file mode 100644 index 0000000..8d04963 --- /dev/null +++ b/frontend/src/constants/themeSwatches.ts @@ -0,0 +1,11 @@ +import type { ThemeSelection } from "./settingsOptions"; + +/** Gradient swatch preview shown next to each theme choice in Settings > Theme. */ +export const THEME_SWATCHES: Record = { + system: "linear-gradient(135deg, #f8fafc 0%, #f8fafc 50%, #0b0f19 50%, #0b0f19 100%)", + light: "linear-gradient(135deg, #ffffff, #e2e8f0)", + dark: "linear-gradient(135deg, #0b0f19, #1f2937)", + neon: "linear-gradient(135deg, #b6ff2b, #0a1508)", + purple: "linear-gradient(135deg, #c77dff, #12091f)", + blue: "linear-gradient(135deg, #74d4ff, #0a1324)", +}; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..85c029d --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,38 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; + +import commonEn from "./locales/en/common.json"; +import appEn from "./locales/en/app.json"; +import libraryEn from "./locales/en/library.json"; +import commonLt from "./locales/lt/common.json"; +import appLt from "./locales/lt/app.json"; +import libraryLt from "./locales/lt/library.json"; +import { LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES } from "./constants/languages"; + +// Namespaces are split by ownership area rather than kept in one giant file: +// "common" - generic actions/words reused everywhere (Save, Cancel, Delete, ...) +// "app" - shell chrome: login, sidebar, settings, upload bar, import/zip modals, tag editor +// "library" - the print grid/card, print preview modal + plate switcher, model viewer overlays, +// LightBurn preview +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { common: commonEn, app: appEn, library: libraryEn }, + lt: { common: commonLt, app: appLt, library: libraryLt }, + }, + ns: ["common", "app", "library"], + defaultNS: "common", + fallbackLng: "en", + supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code), + interpolation: { escapeValue: false }, + detection: { + order: ["localStorage", "navigator"], + lookupLocalStorage: LANGUAGE_STORAGE_KEY, + caches: ["localStorage"], + }, + }); + +export default i18n; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..06f062d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,5 @@ +html, +body, +#root { + height: 100%; +} diff --git a/frontend/src/locales/en/app.json b/frontend/src/locales/en/app.json new file mode 100644 index 0000000..7c6328d --- /dev/null +++ b/frontend/src/locales/en/app.json @@ -0,0 +1,251 @@ +{ + "shell": { + "apiUnreachable": "API unreachable at {{base}}. Ensure the API container is running and reachable.", + "resetProxyUrl": "Reset saved proxy URL", + "backToLibrary": "Back to library", + "confirmLogout": "Are you sure you want to log out?", + "sessionExpired": "Your session has expired. Please sign in again." + }, + "login": { + "subtitle": "Sign in to continue", + "apiOffline": "API appears offline. Ensure the API service is running.", + "loginFailed": "Login failed", + "usernameLabel": "Username", + "passwordLabel": "Password", + "signIn": "Sign in", + "signingIn": "Signing in…" + }, + "sidebar": { + "brand": "PrintStash", + "libraryTitle": "Library", + "folderCountTitle": "{{count}} folders", + "newFolder": "New", + "newFolderTitle": "New folder", + "searchPlaceholder": "Search folders", + "searchAriaLabel": "Search folders", + "clearSearchAria": "Clear folder search", + "allFiles": "All files", + "root": "ROOT", + "foldersHeading": "Folders", + "noFoldersYet": "No folders yet", + "createFirstFolder": "Create your first folder", + "noMatchingFolders": "No matching folders", + "clearSearch": "Clear search", + "actionsFor": "Actions for {{name}}", + "collapse": "Collapse", + "expand": "Expand", + "subfolder": "Subfolder", + "renameEdit": "Rename & edit", + "downloadZip": "Download ZIP", + "delete": "Delete", + "confirmDeleteFolder": "Delete folder? (Prints remain but become unassigned)", + "untitled": "Untitled", + "createFolderTitle": "New folder", + "createFolderSubtitle": "Choose a name and location", + "editFolderTitle": "Edit folder", + "editFolderSubtitle": "Update its details", + "nameLabel": "Name", + "folderNamePlaceholder": "Folder name", + "locationLabel": "Location", + "rootOption": "(Root)", + "create": "Create", + "saveChanges": "Save changes", + "addFolderTagsPlaceholder": "Add folder tags", + "settings": "Settings", + "errors": { + "loadFolders": "Unable to load folders.", + "createFolder": "Folder creation failed. Please try again.", + "updateFolder": "Folder update failed. Please try again.", + "deleteFolder": "Failed to delete folder.", + "downloadFolder": "Unable to download folder.", + "uploadFailed": "Failed to upload: {{files}}" + }, + "multiplateImportFailedWithMessage": "Multi-plate import ({{message}})", + "multiplateImportFailed": "Multi-plate import" + }, + "uploadBar": { + "upload": "Upload", + "uploading": "Uploading…", + "uploadFolder": "Upload folder", + "linkPlaceholder": "Paste model link (MakerWorld, Printables, Thingiverse...)", + "importLink": "Import link", + "importing": "Importing…", + "importFailed": "Import failed", + "uploadFailed": "Failed to upload: {{files}}", + "importFailedList": "Failed to import: {{files}}", + "multiplateImportFailedWithMessage": "Multi-plate import ({{message}})", + "multiplateImportFailed": "Multi-plate import" + }, + "zipImport": { + "title": "Import zip", + "chooseFilesTitle": "Choose files", + "prompt": "How would you like to handle this zip file?", + "importAsZip": "Import as zip", + "importAndUnzip": "Import and unzip", + "selectedCount": "{{selected}} of {{total}} selected", + "selectAll": "Select all", + "clear": "Clear", + "noFiles": "No files found in this zip.", + "importSelected": "Import selected", + "cancel": "Cancel", + "close": "Close", + "readError": "Unable to read zip contents", + "importError": "Import failed", + "selectAtLeastOne": "Select at least one file to import." + }, + "importMode": { + "title": "Import {{count}} files", + "prompt": "How should these files be imported?", + "separateTitle": "Import as separate prints", + "separateDesc": "Each file becomes its own print with a single plate.", + "multiplateTitle": "Import as one multi-plate print", + "multiplateDesc": "All files become plates of a single new print, in the order selected.", + "importing": "Importing…", + "close": "Close", + "importError": "Import failed" + }, + "tagInput": { + "removeTag": "Remove {{tag}}" + }, + "settings": { + "root": { + "slicerTitle": "Slicer", + "slicerHeading": "Open in Slicer", + "slicerDesc": "Pick a slicer for 3D model downloads.", + "engravingTitle": "Engraving", + "engravingHeading": "Open in Engraving Software", + "engravingDesc": "Pick a laser app for vector and image files.", + "themeTitle": "Theme", + "themeHeading": "Appearance", + "themeDesc": "Switch between light, dark, and neon colors.", + "networkTitle": "Network", + "networkHeading": "API address", + "networkDesc": "Point the app at a different API address, if needed.", + "previewsTitle": "Performance", + "previewsHeading": "Model Previews", + "previewsDesc": "Choose automatic, on-demand, or disabled preview generation.", + "storageTitle": "Files", + "storageHeading": "Storage Structure", + "storageDesc": "Organize files with folders, model names, creators, tags, and collections.", + "importsTitle": "Imports", + "importsHeading": "Imports & Folder Scan", + "importsDesc": "Manage import cookies and scan local folders.", + "languageTitle": "Language", + "languageHeading": "Interface Language", + "languageDesc": "Choose the language used throughout PrintStash." + }, + "back": "Back", + "slicer": { + "heading": "Slicer", + "subtitle": "Control the Open in Slicer button for 3D files.", + "helperPrefix": "Requires the Slicer Bridge helper to be installed on each client to register the printstash-slicer:// protocol. Download the installer from", + "helperLinkText": "PrintStash releases", + "helperSuffix": ". On Linux, mark the download as executable if prompted.", + "enableLabel": "Enable Open in Slicer", + "preferredLabel": "Preferred slicer", + "footerNote": "The Open in Slicer button launches the custom protocol with a signed download URL." + }, + "engraving": { + "heading": "Engraving Software", + "subtitle": "Control the Open in Engraving Software button for laser-ready files.", + "helperPrefix": "Requires the Bridge helper to be installed on each client to register the printstash-engrave:// protocol. Download the installer from", + "helperLinkText": "PrintStash releases", + "helperSuffix": ". On Linux, mark the download as executable if prompted.", + "enableLabel": "Enable Open in Engraving Software", + "preferredLabel": "Preferred engraving software", + "footerNote": "The Open in Engraving Software button launches the custom protocol with a signed download URL." + }, + "theme": { + "heading": "Theme", + "subtitle": "Choose how PrintStash looks and feels." + }, + "network": { + "heading": "Network", + "subtitle": "PrintStash serves the UI and API from the same address by default — you only need this if the API lives somewhere else.", + "publicUrlHeading": "API address override", + "publicUrlDesc": "Set this only if the API isn't reachable at the page's own address — for example, if you're hosting the frontend separately from the backend.", + "storedNote": "This setting is stored in your browser, so each device can point to a different URL if needed.", + "urlLabel": "API address", + "urlPlaceholder": "https://api.printstash.example", + "apiBase": "Calling the API at: {{url}}", + "apiBaseAuto": "Same origin as this page", + "save": "Save", + "clear": "Clear" + }, + "storage": { + "heading": "Storage Structure", + "subtitle": "Choose how managed model files are organized on disk.", + "templateHeading": "Path template", + "templateDesc": "The model name is unique inside its PrintStash folder. Renaming a model also renames its file while preserving the extension.", + "templateLabel": "Template", + "templatePlaceholder": "{folder}/{model}/{filename}", + "examplePathsHeading": "Example paths (two plates of one print)", + "examplePathsEmpty": "Enter a template to preview its path", + "reorganizeLabel": "Reorganize existing managed files now", + "reorganizeHint": "Mounted no-copy files are skipped. Existing URLs continue to use stable print IDs.", + "save": "Save storage structure", + "saving": "Saving…", + "savedApplied": "Saved. Moved {{moved}} file(s){{skippedSuffix}}.", + "skippedSuffix": "; skipped {{skipped}}", + "savedNoApply": "Storage structure saved for new and updated models.", + "failed": "Failed to save storage settings." + }, + "previews": { + "heading": "Model Previews", + "subtitle": "Control background preview work on this browser.", + "automaticLabel": "Automatic", + "automaticDesc": "Load missing previews only as cards enter view, then save them for future visits.", + "onDemandLabel": "On demand", + "onDemandDesc": "Show saved previews immediately and generate missing ones only when requested.", + "disabledLabel": "Disabled", + "disabledDesc": "Never generate card previews. Interactive viewing remains available by opening a model.", + "footerNote": "Embedded 3MF thumbnails and previously generated server thumbnails are always used when available." + }, + "imports": { + "heading": "Imports", + "subtitle": "Manage session cookies and import local folders.", + "mountHeading": "Mount import (server)", + "mountDesc": "Index files from the server's mounted volume. Enable the no-copy option to keep files in place and avoid duplicates.", + "mountPath": "Mount path: {{path}}", + "mountPathNotConfigured": "Not configured", + "mountNote": "Changes apply on the next server restart. In no-copy mode, mounted files stay read-only and renaming is disabled.", + "mountEnableLabel": "Enable mount import on startup", + "mountCopyLabel": "Copy files into PrintStash storage (disable to use mounted files directly)", + "save": "Save", + "saving": "Saving…", + "loadingSettings": "Loading settings…", + "scanHeading": "Local folder scan", + "scanDesc": "Select a folder from this device (USB included) and import supported files. The folder name becomes a top-level folder, and subfolders stay nested.", + "scanExtensions": "Supported extensions: {{extensions}}. Selecting a folder uploads immediately.", + "scanSkipRootLabel": "Skip the top-level folder name", + "chooseFolder": "Choose folder", + "scanNow": "Scan now", + "rescanNow": "Rescan now", + "uploadingStatus": "Uploading…", + "clear": "Clear", + "noFolderSelected": "No folder selected yet.", + "selectedSummary": "Selected {{total}} file(s){{fromSuffix}}. Ready: {{ready}}. New: {{fresh}}. Skipped: {{skipped}}.", + "fromSuffix": " from \"{{root}}\"", + "noNewFiles": "No new files to upload.", + "uploadedStatus": "Uploaded {{count}} file(s).", + "uploadedWithFailures": "Uploaded {{count}} file(s). Failed: {{failed}}.", + "uploadFailed": "Upload failed. Please try again.", + "noSupportedFiles": "No supported files selected.", + "noFilesFoundInFolder": "No files found in that folder.", + "rootFolderCreateFailed": "Failed to create the top-level folder.", + "makerworldHeading": "MakerWorld", + "makerworldDesc": "Optional auth for importing MakerWorld models.", + "makerworldHelp": "Paste the Cookie header from a logged-in makerworld.com request to lift download limits. This value is stored only in your browser.", + "sessionCookieLabel": "Session cookie", + "makerworldPlaceholder": "example: mw_session=...; mw_token=...;", + "thingiverseHeading": "Thingiverse", + "thingiverseDesc": "Optional auth for importing Thingiverse models.", + "thingiverseHelp": "Paste your Thingiverse Cookie header to pass Cloudflare checks when importing. This value is stored only in your browser.", + "thingiversePlaceholder": "example: cf_clearance=...; PHPSESSID=...;" + }, + "language": { + "heading": "Interface Language", + "subtitle": "Choose the language used throughout PrintStash." + } + } +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json new file mode 100644 index 0000000..792cf12 --- /dev/null +++ b/frontend/src/locales/en/common.json @@ -0,0 +1,36 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "remove": "Remove", + "rename": "Rename", + "edit": "Edit", + "add": "Add", + "upload": "Upload", + "download": "Download", + "import": "Import", + "loading": "Loading…", + "search": "Search", + "settings": "Settings", + "back": "Back", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "ok": "OK", + "name": "Name", + "notes": "Notes", + "tags": "Tags", + "folder": "Folder", + "folders": "Folders", + "creator": "Creator", + "collection": "Collection", + "unassigned": "Unassigned", + "selectAll": "Select all", + "clearSelection": "Clear selection", + "logOut": "Log out", + "error": "Error", + "success": "Success", + "retry": "Retry", + "language": "Language" +} diff --git a/frontend/src/locales/en/library.json b/frontend/src/locales/en/library.json new file mode 100644 index 0000000..99d239e --- /dev/null +++ b/frontend/src/locales/en/library.json @@ -0,0 +1,150 @@ +{ + "grid": { + "searchPlaceholder": "Search title, filename, notes...", + "search": "Search", + "loading": "Loading...", + "sortLabel": "Sort", + "sortOptions": { + "name": "Name", + "size": "Size", + "type": "File type", + "folder": "Folder" + }, + "sortAsc": "Asc", + "sortDesc": "Desc", + "resetTags": "Reset", + "loadMore": "Load more", + "loadingMore": "Loading...", + "groupItemCount_one": "{{count}} item", + "groupItemCount_few": "{{count}} items", + "groupItemCount_other": "{{count}} items", + "groupShow": "Show", + "groupHide": "Hide", + "groupEmpty": "No prints in this folder", + "groupUngrouped": "Unassigned", + "groupUntitled": "Untitled", + "groupOther": "Other", + "allItems": "All Items", + "thisFolder": "this folder" + }, + "dropOverlay": { + "uploading": "Uploading to {{target}}...", + "prompt": "Drop files or folders to upload to {{target}}" + }, + "errors": { + "loadPrintsFailed": "Failed to load prints. Please sign in again or refresh.", + "loadMoreFailed": "Failed to load more prints.", + "loadFoldersFailed": "Failed to load folders. Please refresh.", + "uploadFailed": "Failed to upload: {{items}}", + "tagUpdateFailed": "Tag update failed for: {{items}}", + "saveDetailsFailed": "Failed to save model details.", + "renameFailed": "Rename failed. Please try again.", + "folderUpdateFailed": "Folder update failed. Please try again.", + "downloadFailed": "Download failed. Please try again.", + "bulkDownloadFailed": "Bulk download failed.", + "downloadByTagFailed": "Download by tag failed.", + "selectAtLeastOne": "Select at least one item to download.", + "deleteFailed": "Delete failed for: {{items}}", + "loadFilesFailed": "Failed to load supporting files.", + "addFilesFailed": "Failed to add files.", + "removeFileFailed": "Failed to remove file.", + "clearPreparedFailed": "Failed to clear prepared print.", + "addPlatesFailed": "Failed to add plate(s).", + "addPlateFailed": "Failed to add plate.", + "removePlateFailed": "Failed to remove plate.", + "multiplateImportFailed": "Multi-plate import", + "multiplateImportFailedWithMessage": "Multi-plate import ({{message}})" + }, + "confirm": { + "deleteMultiple": "Delete {{count}} selected items? This cannot be undone.", + "deleteSingle": "Delete \"{{name}}\"? This cannot be undone.", + "removeFile": "Remove {{filename}} from this model?", + "clearPrepared": "Clear the attached prepared print from this model?", + "removePlate": "Remove plate \"{{filename}}\"?" + }, + "card": { + "select": "Select", + "plateCountBadge_one": "{{count}} plate", + "plateCountBadge_few": "{{count}} plates", + "plateCountBadge_other": "{{count}} plates", + "addingPlates": "Adding plate(s)...", + "dropToAddPlate": "Drop to add as plate", + "doubleClickPreview": "Double-click to open large preview", + "downloadPlaceholder": "Download...", + "downloadAllPlatesZip": "Download all plates (zip)", + "downloadFile": "Download file", + "downloadSelected": "Download all selected", + "downloadTag": "Download tag: {{tag}}", + "downloading": "Downloading...", + "preparing": "Preparing...", + "openInSlicer": "Open in {{slicer}}", + "openInEngraving": "Open in {{engraver}}", + "folderLabel": "Folder", + "folderUpdating": "Updating...", + "titleTooltip": "{{name}} · {{filename}}", + "morePlates": "{{filename}} + {{count}} more", + "byCreator": "By {{creator}}", + "collectionLabel": "Collection: {{collection}}", + "noTags": "No tags", + "supportingFiles": "Supporting files", + "addFiles": "Add files", + "addingFiles": "Adding...", + "addFilesTooltip": "Add documentation or a prepared print", + "loadingFiles": "Loading files...", + "removeFile": "Remove", + "supportingFilesEmpty": "Add assembly instructions, notes, other documentation, or a prepared print.", + "modelDetails": "Model details", + "expand": "Expand", + "collapse": "Collapse", + "creatorPlaceholder": "Designer or source", + "collectionPlaceholder": "Collection name", + "notesPlaceholder": "Add some details about this print", + "notSet": "Not set", + "addNotes": "Add notes", + "editDetails": "Edit details", + "editTags": "Edit tags", + "tagInputPlaceholder": "Type and press comma/Enter", + "deleting": "Deleting...", + "noDetails": "No details" + }, + "preparedPrint": { + "preparedFor": "Prepared for {{printer}}", + "available": "Prepared print available", + "clear": "Clear", + "clearing": "Clearing..." + }, + "duration": { + "days": "{{count}}d", + "hours": "{{count}}h", + "minutes": "{{count}}m" + }, + "previewModal": { + "plateCount_one": "{{count}} plate", + "plateCount_few": "{{count}} plates", + "plateCount_other": "{{count}} plates", + "removePlateTooltip": "Remove plate", + "addPlateTooltip": "Add plate", + "addingPlate": "Adding...", + "addPlate": "Add plate", + "openPlateInSlicer": "Open plate in {{slicer}}", + "openPlateInEngraving": "Open plate in {{engraver}}", + "noTags": "No tags", + "downloadPlate": "Download plate", + "close": "Close" + }, + "modelViewer": { + "previewAlt": "Preview", + "previewUnsupported": "Preview unsupported for this file type.", + "previewFailed": "Preview failed to load.", + "generatingPreview": "Generating preview...", + "waitingToGenerate": "Waiting to generate preview...", + "generatePreview": "Generate preview", + "previewDisabled": "Preview generation disabled", + "noPreview": "No preview", + "previewUnavailable": "Preview unavailable" + }, + "lightburn": { + "generatingPreview": "Generating preview...", + "previewUnavailable": "Preview unavailable" + } +} diff --git a/frontend/src/locales/lt/app.json b/frontend/src/locales/lt/app.json new file mode 100644 index 0000000..64f1cc1 --- /dev/null +++ b/frontend/src/locales/lt/app.json @@ -0,0 +1,251 @@ +{ + "shell": { + "apiUnreachable": "API nepasiekiamas adresu {{base}}. Įsitikinkite, kad API konteineris veikia ir pasiekiamas.", + "resetProxyUrl": "Atstatyti išsaugotą proxy adresą", + "backToLibrary": "Grįžti į biblioteką", + "confirmLogout": "Ar tikrai norite atsijungti?", + "sessionExpired": "Jūsų sesija baigėsi. Prašome prisijungti iš naujo." + }, + "login": { + "subtitle": "Prisijunkite, kad tęstumėte", + "apiOffline": "Panašu, kad API neveikia. Įsitikinkite, kad API paslauga paleista.", + "loginFailed": "Prisijungti nepavyko", + "usernameLabel": "Naudotojo vardas", + "passwordLabel": "Slaptažodis", + "signIn": "Prisijungti", + "signingIn": "Jungiamasi…" + }, + "sidebar": { + "brand": "PrintStash", + "libraryTitle": "Biblioteka", + "folderCountTitle": "{{count}} aplankų", + "newFolder": "Naujas", + "newFolderTitle": "Naujas aplankas", + "searchPlaceholder": "Ieškoti aplankų", + "searchAriaLabel": "Ieškoti aplankų", + "clearSearchAria": "Išvalyti aplankų paiešką", + "allFiles": "Visi failai", + "root": "ŠAKNIS", + "foldersHeading": "Aplankai", + "noFoldersYet": "Kol kas nėra aplankų", + "createFirstFolder": "Sukurti pirmą aplanką", + "noMatchingFolders": "Atitinkančių aplankų nerasta", + "clearSearch": "Išvalyti paiešką", + "actionsFor": "Veiksmai su {{name}}", + "collapse": "Suskleisti", + "expand": "Išskleisti", + "subfolder": "Poaplankis", + "renameEdit": "Pervadinti ir redaguoti", + "downloadZip": "Atsisiųsti ZIP", + "delete": "Ištrinti", + "confirmDeleteFolder": "Ištrinti aplanką? (Modeliai išliks, bet taps nepriskirti)", + "untitled": "Be pavadinimo", + "createFolderTitle": "Naujas aplankas", + "createFolderSubtitle": "Pasirinkite pavadinimą ir vietą", + "editFolderTitle": "Redaguoti aplanką", + "editFolderSubtitle": "Atnaujinkite jo duomenis", + "nameLabel": "Pavadinimas", + "folderNamePlaceholder": "Aplanko pavadinimas", + "locationLabel": "Vieta", + "rootOption": "(Šaknis)", + "create": "Sukurti", + "saveChanges": "Išsaugoti pakeitimus", + "addFolderTagsPlaceholder": "Pridėti aplanko žymas", + "settings": "Nustatymai", + "errors": { + "loadFolders": "Nepavyko įkelti aplankų.", + "createFolder": "Nepavyko sukurti aplanko. Bandykite dar kartą.", + "updateFolder": "Nepavyko atnaujinti aplanko. Bandykite dar kartą.", + "deleteFolder": "Nepavyko ištrinti aplanko.", + "downloadFolder": "Nepavyko atsisiųsti aplanko.", + "uploadFailed": "Nepavyko įkelti: {{files}}" + }, + "multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})", + "multiplateImportFailed": "Kelių plokščių importas" + }, + "uploadBar": { + "upload": "Įkelti", + "uploading": "Įkeliama…", + "uploadFolder": "Įkelti aplanką", + "linkPlaceholder": "Įklijuokite modelio nuorodą (MakerWorld, Printables, Thingiverse...)", + "importLink": "Importuoti nuorodą", + "importing": "Importuojama…", + "importFailed": "Importuoti nepavyko", + "uploadFailed": "Nepavyko įkelti: {{files}}", + "importFailedList": "Nepavyko importuoti: {{files}}", + "multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})", + "multiplateImportFailed": "Kelių plokščių importas" + }, + "zipImport": { + "title": "Importuoti zip", + "chooseFilesTitle": "Pasirinkite failus", + "prompt": "Kaip norėtumėte tvarkyti šį zip failą?", + "importAsZip": "Importuoti kaip zip", + "importAndUnzip": "Importuoti ir išpakuoti", + "selectedCount": "Pažymėta {{selected}} iš {{total}}", + "selectAll": "Pažymėti visus", + "clear": "Išvalyti", + "noFiles": "Šiame zip faile failų nerasta.", + "importSelected": "Importuoti pažymėtus", + "cancel": "Atšaukti", + "close": "Uždaryti", + "readError": "Nepavyko perskaityti zip failo turinio", + "importError": "Importuoti nepavyko", + "selectAtLeastOne": "Pasirinkite bent vieną failą importavimui." + }, + "importMode": { + "title": "Importuoti {{count}} failų", + "prompt": "Kaip turėtų būti importuojami šie failai?", + "separateTitle": "Importuoti kaip atskirus modelius", + "separateDesc": "Kiekvienas failas taps atskiru modeliu su viena plokšte.", + "multiplateTitle": "Importuoti kaip vieną kelių plokščių modelį", + "multiplateDesc": "Visi failai taps vieno naujo modelio plokštėmis pasirinkta tvarka.", + "importing": "Importuojama…", + "close": "Uždaryti", + "importError": "Importuoti nepavyko" + }, + "tagInput": { + "removeTag": "Pašalinti {{tag}}" + }, + "settings": { + "root": { + "slicerTitle": "Pjaustyklė", + "slicerHeading": "Atverti pjaustyklėje", + "slicerDesc": "Pasirinkite pjaustyklę 3D modelių atsisiuntimams.", + "engravingTitle": "Graviravimas", + "engravingHeading": "Atverti graviravimo programoje", + "engravingDesc": "Pasirinkite lazerio programą vektoriniams ir vaizdo failams.", + "themeTitle": "Tema", + "themeHeading": "Išvaizda", + "themeDesc": "Perjunkite tarp šviesios, tamsios ir neoninių spalvų.", + "networkTitle": "Tinklas", + "networkHeading": "API adresas", + "networkDesc": "Jei reikia, nurodykite kitą API adresą.", + "previewsTitle": "Našumas", + "previewsHeading": "Modelių peržiūros", + "previewsDesc": "Pasirinkite automatinį, pagal poreikį arba išjungtą peržiūrų generavimą.", + "storageTitle": "Failai", + "storageHeading": "Saugojimo struktūra", + "storageDesc": "Tvarkykite failus pagal aplankus, modelių pavadinimus, autorius, žymas ir kolekcijas.", + "importsTitle": "Importas", + "importsHeading": "Importas ir aplankų skenavimas", + "importsDesc": "Tvarkykite importo slapukus ir skenuokite vietinius aplankus.", + "languageTitle": "Kalba", + "languageHeading": "Sąsajos kalba", + "languageDesc": "Pasirinkite kalbą, naudojamą visame PrintStash." + }, + "back": "Atgal", + "slicer": { + "heading": "Pjaustyklė", + "subtitle": "Valdykite mygtuką „Atverti pjaustyklėje“ 3D failams.", + "helperPrefix": "Reikalauja, kad kiekviename kliente būtų įdiegtas Slicer Bridge pagalbinis įrankis, registruojantis printstash-slicer:// protokolą. Atsisiųskite diegimo programą iš", + "helperLinkText": "PrintStash leidimų", + "helperSuffix": ". Sistemoje Linux, jei reikalaujama, pažymėkite atsisiųstą failą kaip vykdomąjį.", + "enableLabel": "Įjungti „Atverti pjaustyklėje“", + "preferredLabel": "Pageidaujama pjaustyklė", + "footerNote": "Mygtukas „Atverti pjaustyklėje“ paleidžia pasirinktinį protokolą su pasirašytu atsisiuntimo adresu." + }, + "engraving": { + "heading": "Graviravimo programa", + "subtitle": "Valdykite mygtuką „Atverti graviravimo programoje“ lazeriui paruoštiems failams.", + "helperPrefix": "Reikalauja, kad kiekviename kliente būtų įdiegtas Bridge pagalbinis įrankis, registruojantis printstash-engrave:// protokolą. Atsisiųskite diegimo programą iš", + "helperLinkText": "PrintStash leidimų", + "helperSuffix": ". Sistemoje Linux, jei reikalaujama, pažymėkite atsisiųstą failą kaip vykdomąjį.", + "enableLabel": "Įjungti „Atverti graviravimo programoje“", + "preferredLabel": "Pageidaujama graviravimo programa", + "footerNote": "Mygtukas „Atverti graviravimo programoje“ paleidžia pasirinktinį protokolą su pasirašytu atsisiuntimo adresu." + }, + "theme": { + "heading": "Tema", + "subtitle": "Pasirinkite, kaip PrintStash atrodo ir jaučiasi." + }, + "network": { + "heading": "Tinklas", + "subtitle": "PrintStash pagal nutylėjimą aptarnauja sąsają ir API iš to paties adreso — tai reikalinga tik jei API veikia kitur.", + "publicUrlHeading": "API adreso perrašymas", + "publicUrlDesc": "Nurodykite tik tuo atveju, jei API nepasiekiamas tuo pačiu adresu kaip puslapis — pavyzdžiui, jei sąsają talpinate atskirai nuo backend'o.", + "storedNote": "Šis nustatymas saugomas jūsų naršyklėje, todėl kiekvienas įrenginys gali naudoti kitą adresą.", + "urlLabel": "API adresas", + "urlPlaceholder": "https://api.printstash.example", + "apiBase": "Kreipiamasi į API adresu: {{url}}", + "apiBaseAuto": "Tas pats adresas kaip puslapio", + "save": "Išsaugoti", + "clear": "Išvalyti" + }, + "storage": { + "heading": "Saugojimo struktūra", + "subtitle": "Pasirinkite, kaip valdomi modelių failai tvarkomi diske.", + "templateHeading": "Kelio šablonas", + "templateDesc": "Modelio pavadinimas yra unikalus jo PrintStash aplanke. Modelio pervadinimas taip pat pervadina jo failą, išsaugant plėtinį.", + "templateLabel": "Šablonas", + "templatePlaceholder": "{folder}/{model}/{filename}", + "examplePathsHeading": "Kelių pavyzdžiai (dvi vieno modelio plokštės)", + "examplePathsEmpty": "Įveskite šabloną, kad pamatytumėte kelio pavyzdį", + "reorganizeLabel": "Pertvarkyti esamus valdomus failus dabar", + "reorganizeHint": "Prijungti be kopijavimo failai praleidžiami. Esami adresai toliau naudoja stabilius modelio ID.", + "save": "Išsaugoti saugojimo struktūrą", + "saving": "Saugoma…", + "savedApplied": "Išsaugota. Perkelta {{moved}} failų{{skippedSuffix}}.", + "skippedSuffix": "; praleista {{skipped}}", + "savedNoApply": "Saugojimo struktūra išsaugota naujiems ir atnaujintiems modeliams.", + "failed": "Nepavyko išsaugoti saugojimo nustatymų." + }, + "previews": { + "heading": "Modelių peržiūros", + "subtitle": "Valdykite fono peržiūrų generavimą šioje naršyklėje.", + "automaticLabel": "Automatinis", + "automaticDesc": "Trūkstamas peržiūras įkelti tik joms atsiradus ekrane, tada išsaugoti ateičiai.", + "onDemandLabel": "Pagal poreikį", + "onDemandDesc": "Iš karto rodyti išsaugotas peržiūras, o trūkstamas generuoti tik paprašius.", + "disabledLabel": "Išjungta", + "disabledDesc": "Niekada negeneruoti kortelių peržiūrų. Interaktyvi peržiūra vis tiek pasiekiama atidarius modelį.", + "footerNote": "Įtaisytosios 3MF miniatiūros ir anksčiau sugeneruotos serverio miniatiūros visada naudojamos, kai jos yra." + }, + "imports": { + "heading": "Importas", + "subtitle": "Tvarkykite sesijos slapukus ir importuokite vietinius aplankus.", + "mountHeading": "Prijungimo importas (serveris)", + "mountDesc": "Indeksuokite failus iš serverio prijungto tomo. Įjunkite be kopijavimo parinktį, kad failai liktų vietoje ir būtų išvengta dublikatų.", + "mountPath": "Prijungimo kelias: {{path}}", + "mountPathNotConfigured": "Nesukonfigūruota", + "mountNote": "Pakeitimai įsigalioja iš naujo paleidus serverį. Be kopijavimo režime prijungti failai lieka tik skaitymui, o pervadinimas išjungtas.", + "mountEnableLabel": "Įjungti prijungimo importą paleidžiant", + "mountCopyLabel": "Kopijuoti failus į PrintStash saugyklą (išjunkite, kad naudotumėte prijungtus failus tiesiogiai)", + "save": "Išsaugoti", + "saving": "Saugoma…", + "loadingSettings": "Kraunami nustatymai…", + "scanHeading": "Vietinio aplanko skenavimas", + "scanDesc": "Pasirinkite aplanką iš šio įrenginio (įskaitant USB) ir importuokite palaikomus failus. Aplanko pavadinimas taps aukščiausio lygio aplanku, o poaplankiai išliks įdėti.", + "scanExtensions": "Palaikomi plėtiniai: {{extensions}}. Pasirinkus aplanką, įkėlimas prasideda iš karto.", + "scanSkipRootLabel": "Praleisti aukščiausio lygio aplanko pavadinimą", + "chooseFolder": "Pasirinkti aplanką", + "scanNow": "Skenuoti dabar", + "rescanNow": "Skenuoti iš naujo", + "uploadingStatus": "Įkeliama…", + "clear": "Išvalyti", + "noFolderSelected": "Aplankas dar nepasirinktas.", + "selectedSummary": "Pasirinkta {{total}} failų{{fromSuffix}}. Paruošta: {{ready}}. Naujų: {{fresh}}. Praleista: {{skipped}}.", + "fromSuffix": " iš „{{root}}“", + "noNewFiles": "Nėra naujų failų įkėlimui.", + "uploadedStatus": "Įkelta {{count}} failų.", + "uploadedWithFailures": "Įkelta {{count}} failų. Nepavyko: {{failed}}.", + "uploadFailed": "Įkėlimas nepavyko. Bandykite dar kartą.", + "noSupportedFiles": "Nepasirinkta jokių palaikomų failų.", + "noFilesFoundInFolder": "Tame aplanke failų nerasta.", + "rootFolderCreateFailed": "Nepavyko sukurti aukščiausio lygio aplanko.", + "makerworldHeading": "MakerWorld", + "makerworldDesc": "Neprivaloma autentifikacija MakerWorld modelių importui.", + "makerworldHelp": "Įklijuokite Cookie antraštę iš prisijungusio makerworld.com užklausos, kad panaikintumėte atsisiuntimo limitus. Ši reikšmė saugoma tik jūsų naršyklėje.", + "sessionCookieLabel": "Sesijos slapukas", + "makerworldPlaceholder": "pavyzdys: mw_session=...; mw_token=...;", + "thingiverseHeading": "Thingiverse", + "thingiverseDesc": "Neprivaloma autentifikacija Thingiverse modelių importui.", + "thingiverseHelp": "Įklijuokite savo Thingiverse Cookie antraštę, kad importuojant būtų praeitas Cloudflare patikrinimas. Ši reikšmė saugoma tik jūsų naršyklėje.", + "thingiversePlaceholder": "pavyzdys: cf_clearance=...; PHPSESSID=...;" + }, + "language": { + "heading": "Sąsajos kalba", + "subtitle": "Pasirinkite kalbą, naudojamą visame PrintStash." + } + } +} diff --git a/frontend/src/locales/lt/common.json b/frontend/src/locales/lt/common.json new file mode 100644 index 0000000..735e162 --- /dev/null +++ b/frontend/src/locales/lt/common.json @@ -0,0 +1,36 @@ +{ + "save": "Išsaugoti", + "cancel": "Atšaukti", + "close": "Uždaryti", + "delete": "Ištrinti", + "remove": "Pašalinti", + "rename": "Pervadinti", + "edit": "Redaguoti", + "add": "Pridėti", + "upload": "Įkelti", + "download": "Atsisiųsti", + "import": "Importuoti", + "loading": "Kraunama…", + "search": "Ieškoti", + "settings": "Nustatymai", + "back": "Atgal", + "confirm": "Patvirtinti", + "yes": "Taip", + "no": "Ne", + "ok": "Gerai", + "name": "Pavadinimas", + "notes": "Pastabos", + "tags": "Žymos", + "folder": "Aplankas", + "folders": "Aplankai", + "creator": "Autorius", + "collection": "Kolekcija", + "unassigned": "Nepriskirta", + "selectAll": "Pažymėti visus", + "clearSelection": "Išvalyti pažymėjimą", + "logOut": "Atsijungti", + "error": "Klaida", + "success": "Pavyko", + "retry": "Bandyti dar kartą", + "language": "Kalba" +} diff --git a/frontend/src/locales/lt/library.json b/frontend/src/locales/lt/library.json new file mode 100644 index 0000000..21c88cf --- /dev/null +++ b/frontend/src/locales/lt/library.json @@ -0,0 +1,150 @@ +{ + "grid": { + "searchPlaceholder": "Ieškoti pagal pavadinimą, failą, pastabas...", + "search": "Ieškoti", + "loading": "Kraunama...", + "sortLabel": "Rūšiuoti", + "sortOptions": { + "name": "Pavadinimas", + "size": "Dydis", + "type": "Failo tipas", + "folder": "Aplankas" + }, + "sortAsc": "Didėjančiai", + "sortDesc": "Mažėjančiai", + "resetTags": "Atstatyti", + "loadMore": "Rodyti daugiau", + "loadingMore": "Kraunama...", + "groupItemCount_one": "{{count}} elementas", + "groupItemCount_few": "{{count}} elementai", + "groupItemCount_other": "{{count}} elementų", + "groupShow": "Rodyti", + "groupHide": "Slėpti", + "groupEmpty": "Šiame aplanke nėra spaudinių", + "groupUngrouped": "Nepriskirta", + "groupUntitled": "Be pavadinimo", + "groupOther": "Kita", + "allItems": "Visi elementai", + "thisFolder": "šį aplanką" + }, + "dropOverlay": { + "uploading": "Įkeliama į {{target}}...", + "prompt": "Vilkite failus ar aplankus, kad įkeltumėte į {{target}}" + }, + "errors": { + "loadPrintsFailed": "Nepavyko įkelti spaudinių. Prisijunkite iš naujo arba atnaujinkite puslapį.", + "loadMoreFailed": "Nepavyko įkelti daugiau spaudinių.", + "loadFoldersFailed": "Nepavyko įkelti aplankų. Atnaujinkite puslapį.", + "uploadFailed": "Nepavyko įkelti: {{items}}", + "tagUpdateFailed": "Nepavyko atnaujinti žymų: {{items}}", + "saveDetailsFailed": "Nepavyko išsaugoti modelio informacijos.", + "renameFailed": "Nepavyko pervadinti. Bandykite dar kartą.", + "folderUpdateFailed": "Nepavyko atnaujinti aplanko. Bandykite dar kartą.", + "downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.", + "bulkDownloadFailed": "Nepavyko masinio atsisiuntimo.", + "downloadByTagFailed": "Nepavyko atsisiųsti pagal žymą.", + "selectAtLeastOne": "Pasirinkite bent vieną elementą atsisiuntimui.", + "deleteFailed": "Nepavyko ištrinti: {{items}}", + "loadFilesFailed": "Nepavyko įkelti papildomų failų.", + "addFilesFailed": "Nepavyko pridėti failų.", + "removeFileFailed": "Nepavyko pašalinti failo.", + "clearPreparedFailed": "Nepavyko išvalyti paruošto spaudinio.", + "addPlatesFailed": "Nepavyko pridėti plokštės (-ių).", + "addPlateFailed": "Nepavyko pridėti plokštės.", + "removePlateFailed": "Nepavyko pašalinti plokštės.", + "multiplateImportFailed": "Kelių plokščių importas", + "multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})" + }, + "confirm": { + "deleteMultiple": "Ištrinti {{count}} pasirinktus elementus? Šio veiksmo anuliuoti negalima.", + "deleteSingle": "Ištrinti „{{name}}“? Šio veiksmo anuliuoti negalima.", + "removeFile": "Pašalinti {{filename}} iš šio modelio?", + "clearPrepared": "Išvalyti prie modelio pridėtą paruoštą spaudinį?", + "removePlate": "Pašalinti plokštę „{{filename}}“?" + }, + "card": { + "select": "Pasirinkti", + "plateCountBadge_one": "{{count}} plokštė", + "plateCountBadge_few": "{{count}} plokštės", + "plateCountBadge_other": "{{count}} plokščių", + "addingPlates": "Pridedamos plokštės...", + "dropToAddPlate": "Vilkite čia, kad pridėtumėte kaip plokštę", + "doubleClickPreview": "Dukart spustelėkite, kad atidarytumėte didelį peržiūros langą", + "downloadPlaceholder": "Atsisiųsti...", + "downloadAllPlatesZip": "Atsisiųsti visas plokštes (zip)", + "downloadFile": "Atsisiųsti failą", + "downloadSelected": "Atsisiųsti visus pasirinktus", + "downloadTag": "Atsisiųsti žymą: {{tag}}", + "downloading": "Atsisiunčiama...", + "preparing": "Ruošiama...", + "openInSlicer": "Atidaryti su {{slicer}}", + "openInEngraving": "Atidaryti su {{engraver}}", + "folderLabel": "Aplankas", + "folderUpdating": "Atnaujinama...", + "titleTooltip": "{{name}} · {{filename}}", + "morePlates": "{{filename}} + dar {{count}}", + "byCreator": "Autorius: {{creator}}", + "collectionLabel": "Kolekcija: {{collection}}", + "noTags": "Nėra žymų", + "supportingFiles": "Papildomi failai", + "addFiles": "Pridėti failų", + "addingFiles": "Pridedama...", + "addFilesTooltip": "Pridėkite dokumentaciją arba paruoštą spaudinį", + "loadingFiles": "Kraunami failai...", + "removeFile": "Pašalinti", + "supportingFilesEmpty": "Pridėkite surinkimo instrukcijas, pastabas, kitą dokumentaciją arba paruoštą spaudinį.", + "modelDetails": "Modelio informacija", + "expand": "Išskleisti", + "collapse": "Suskleisti", + "creatorPlaceholder": "Dizaineris arba šaltinis", + "collectionPlaceholder": "Kolekcijos pavadinimas", + "notesPlaceholder": "Pridėkite informacijos apie šį spaudinį", + "notSet": "Nenurodyta", + "addNotes": "Pridėti pastabų", + "editDetails": "Redaguoti informaciją", + "editTags": "Redaguoti žymas", + "tagInputPlaceholder": "Rašykite ir spauskite kablelį arba Enter", + "deleting": "Trinama...", + "noDetails": "Nėra informacijos" + }, + "preparedPrint": { + "preparedFor": "Paruošta spausdintuvui {{printer}}", + "available": "Yra paruoštas spaudinys", + "clear": "Išvalyti", + "clearing": "Valoma..." + }, + "duration": { + "days": "{{count}} d.", + "hours": "{{count}} val.", + "minutes": "{{count}} min." + }, + "previewModal": { + "plateCount_one": "{{count}} plokštė", + "plateCount_few": "{{count}} plokštės", + "plateCount_other": "{{count}} plokščių", + "removePlateTooltip": "Pašalinti plokštę", + "addPlateTooltip": "Pridėti plokštę", + "addingPlate": "Pridedama...", + "addPlate": "Pridėti plokštę", + "openPlateInSlicer": "Atidaryti plokštę su {{slicer}}", + "openPlateInEngraving": "Atidaryti plokštę su {{engraver}}", + "noTags": "Nėra žymų", + "downloadPlate": "Atsisiųsti plokštę", + "close": "Uždaryti" + }, + "modelViewer": { + "previewAlt": "Peržiūra", + "previewUnsupported": "Šio failo tipo peržiūra nepalaikoma.", + "previewFailed": "Nepavyko įkelti peržiūros.", + "generatingPreview": "Generuojama peržiūra...", + "waitingToGenerate": "Laukiama peržiūros generavimo...", + "generatePreview": "Generuoti peržiūrą", + "previewDisabled": "Peržiūros generavimas išjungtas", + "noPreview": "Peržiūros nėra", + "previewUnavailable": "Peržiūra nepasiekiama" + }, + "lightburn": { + "generatingPreview": "Generuojama peržiūra...", + "previewUnavailable": "Peržiūra nepasiekiama" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4531fc5 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import "./i18n"; +import App from "./App"; + +const el = document.getElementById("root"); +if (!el) throw new Error("#root not found"); +createRoot(el).render( + + + +); diff --git a/frontend/src/routes/library/components/ImportModeModal.tsx b/frontend/src/routes/library/components/ImportModeModal.tsx new file mode 100644 index 0000000..25453f1 --- /dev/null +++ b/frontend/src/routes/library/components/ImportModeModal.tsx @@ -0,0 +1,177 @@ +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import ButtonBase from "@mui/material/ButtonBase"; +import Alert from "@mui/material/Alert"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; + +export type ImportMode = "separate" | "multiplate"; + +type ImportModePromptConfig = { + label: string; + count: number; + onChoose: (mode: ImportMode) => Promise; +}; + +type ImportModePromptState = { + config: ImportModePromptConfig; +}; + +function errorMessage(err: unknown, fallback: string) { + if (err instanceof Error && err.message.trim()) return err.message; + return fallback; +} + +/** + * Prompts the user, for a flat multi-file drop or picker selection, to choose + * between importing each file as its own print ("separate") or bundling all + * of them as plates of one new multi-part print ("multiplate"). + * + * Structurally similar to useZipImportPrompt() in ZipImportModal.tsx, but + * this is a distinct decision (single vs. multi-plate print) with its own + * simpler one-step UI -- it intentionally does not share state with the zip + * import wizard. + */ +export function useImportModePrompt() { + const { t } = useTranslation("app"); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const resolveRef = useRef<(() => void) | null>(null); + + const close = () => { + setState(null); + setBusy(false); + setError(null); + if (resolveRef.current) { + resolveRef.current(); + resolveRef.current = null; + } + }; + + const prompt = (config: ImportModePromptConfig) => { + if (state) { + return Promise.resolve(); + } + setBusy(false); + setError(null); + setState({ config }); + return new Promise(resolve => { + resolveRef.current = resolve; + }); + }; + + const choose = async (mode: ImportMode) => { + if (!state) return; + setBusy(true); + setError(null); + try { + await state.config.onChoose(mode); + close(); + } catch (err) { + setError(errorMessage(err, t("importMode.importError"))); + setBusy(false); + } + }; + + const modal = state ? ( + + ) : null; + + return { prompt, modal, isOpen: Boolean(state) }; +} + +function ImportModeModal({ + label, + count, + busy, + error, + onChoose, + onClose, +}: { + label: string; + count: number; + busy: boolean; + error: string | null; + onChoose: (mode: ImportMode) => void; + onClose: () => void; +}) { + const { t } = useTranslation("app"); + return ( + + + + {t("importMode.title", { count })} + {label} + + + + + + + + {t("importMode.prompt")} + + onChoose("separate")} + disabled={busy} + sx={{ + textAlign: "left", + borderRadius: 2, + border: "1px solid", + borderColor: "divider", + bgcolor: "background.paper", + p: 1.5, + display: "block", + "&:hover": { boxShadow: 1 }, + }} + > + {t("importMode.separateTitle")} + + {t("importMode.separateDesc")} + + + onChoose("multiplate")} + disabled={busy} + sx={{ + textAlign: "left", + borderRadius: 2, + border: "1px solid", + borderColor: "divider", + bgcolor: "background.paper", + p: 1.5, + display: "block", + "&:hover": { boxShadow: 1 }, + }} + > + {t("importMode.multiplateTitle")} + + {t("importMode.multiplateDesc")} + + + + {busy && ( + + {t("importMode.importing")} + + )} + {error && {error}} + + + + ); +} diff --git a/frontend/src/routes/library/components/LightBurnPreview.tsx b/frontend/src/routes/library/components/LightBurnPreview.tsx new file mode 100644 index 0000000..9cbd154 --- /dev/null +++ b/frontend/src/routes/library/components/LightBurnPreview.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import type { SxProps, Theme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { extractLightBurnPreview } from "../../../services/lightburnExtract"; + +type PreviewState = "idle" | "loading" | "error"; + +type LightBurnPreviewProps = { + url: string; + assetId?: string; + filename: string; + /** Sizing/fit styles for the resolved , e.g. { objectFit: "cover" } for a card thumbnail + * vs. { objectFit: "contain", bgcolor: "action.hover" } for the full preview modal. */ + imgSx?: SxProps; +}; + +const previewCache = new Map(); + +export default function LightBurnPreview({ url, assetId, filename, imgSx }: LightBurnPreviewProps) { + const { t } = useTranslation(["library"]); + const [preview, setPreview] = useState(null); + const [state, setState] = useState("idle"); + + useEffect(() => { + let alive = true; + const cacheKey = assetId ? `asset:${assetId}` : url; + const cached = previewCache.get(cacheKey); + if (cached) { + setPreview(cached); + setState("idle"); + return () => { + alive = false; + }; + } + + setPreview(null); + setState("loading"); + (async () => { + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Preview fetch failed: ${res.status}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + const payload = await extractLightBurnPreview(bytes); + if (!payload) { + throw new Error("Preview not found in LightBurn file"); + } + const objectUrl = URL.createObjectURL(new Blob([payload.data], { type: payload.mime })); + previewCache.set(cacheKey, objectUrl); + if (alive) { + setPreview(objectUrl); + setState("idle"); + } + } catch (err) { + console.warn("LightBurn preview failed:", err); + if (alive) { + setState("error"); + } + } + })(); + + return () => { + alive = false; + }; + }, [url, assetId]); + + return ( + + {preview ? ( + + ) : state === "loading" ? ( + + {t("library:lightburn.generatingPreview")} + + ) : ( + + {t("library:lightburn.previewUnavailable")} + + )} + + ); +} diff --git a/frontend/src/routes/library/components/ModelViewer/components/ModelSnapshot.tsx b/frontend/src/routes/library/components/ModelViewer/components/ModelSnapshot.tsx new file mode 100644 index 0000000..317f3e6 --- /dev/null +++ b/frontend/src/routes/library/components/ModelViewer/components/ModelSnapshot.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import { useTranslation } from "react-i18next"; +import { uploadGeneratedThumbnail } from "../../../../../services/api"; +import { type ResolvedTheme } from "../../../../../constants/settingsOptions"; +import { generateModelSnapshot, queueSnapshotJob, snapshotCache } from "../../../../../services/modelSnapshotCache"; + +type SnapshotState = "idle" | "loading" | "error"; +type ModelSnapshotProps = { + url: string; + ext: string; + plateId?: string; + theme: ResolvedTheme; + mode?: "automatic" | "on-demand"; +}; + +export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSnapshotProps) { + const { t } = useTranslation(["library"]); + const containerRef = useRef(null); + const [snapshot, setSnapshot] = useState(null); + const [state, setState] = useState("idle"); + const [requested, setRequested] = useState(mode === "automatic"); + + useEffect(() => { + if (mode === "automatic") setRequested(true); + }, [mode]); + + useEffect(() => { + let disposed = false; + let observer: IntersectionObserver | null = null; + const cacheKey = plateId ? `plate:${plateId}` : url; + if (snapshotCache.has(cacheKey)) { + setSnapshot(snapshotCache.get(cacheKey)!); + return; + } + const load = async () => { + if (!containerRef.current) return; + setState("loading"); + try { + const rect = containerRef.current.getBoundingClientRect(); + const width = Math.max(120, Math.floor(rect.width || 240)); + const height = Math.max(120, Math.floor(rect.height || 180)); + const image = await queueSnapshotJob(async () => { + if (disposed) return null; + return generateModelSnapshot(url, ext, width, height, "light"); + }); + if (disposed || !image) return; + snapshotCache.set(cacheKey, image); + setSnapshot(image); + setState("idle"); + if (plateId) { + try { + const imageBlob = await fetch(image).then(response => response.blob()); + await uploadGeneratedThumbnail(plateId, imageBlob); + } catch (err) { + console.warn("Generated preview could not be persisted:", err); + } + } + } catch (err) { + console.warn("Snapshot generation failed:", err); + if (!disposed) { + setState("error"); + } + } + }; + if (!requested) { + setState("idle"); + return () => { disposed = true; }; + } + if (mode === "automatic" && typeof IntersectionObserver !== "undefined") { + observer = new IntersectionObserver(entries => { + if (!entries.some(entry => entry.isIntersecting)) return; + observer?.disconnect(); + void load(); + }, { rootMargin: "240px" }); + if (containerRef.current) observer.observe(containerRef.current); + } else { + void load(); + } + return () => { + disposed = true; + observer?.disconnect(); + }; + }, [url, ext, plateId, mode, requested]); + + return ( + + {snapshot ? ( + + ) : state === "loading" ? ( + + {t("library:modelViewer.generatingPreview")} + + ) : mode === "on-demand" && !requested ? ( + + ) : ( + + {t("library:modelViewer.waitingToGenerate")} + + )} + + ); +} diff --git a/frontend/src/routes/library/components/ModelViewer/index.tsx b/frontend/src/routes/library/components/ModelViewer/index.tsx new file mode 100644 index 0000000..8bbc5f7 --- /dev/null +++ b/frontend/src/routes/library/components/ModelViewer/index.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import { type ResolvedTheme } from "../../../../constants/settingsOptions"; +import { applyThemeToObject, disposeObject3D, loadObjectFromAsset, paletteForTheme } from "../../../../services/modelLoaders"; + +type ModelViewerProps = { url: string; ext: string; viewKey?: string; theme: ResolvedTheme }; + +type ViewErrorKey = "unsupported" | "failed"; + +export default function ModelViewer({ url, ext, viewKey, theme }: ModelViewerProps) { + const { t } = useTranslation(["library"]); + const mountRef = useRef(null); + const [viewError, setViewError] = useState(null); + + useEffect(() => { + let disposed = false; + let activeObject: THREE.Object3D | null = null; + const mount = mountRef.current; + if (!mount) return; + const palette = paletteForTheme(theme); + setViewError(null); + const reportError = (key: ViewErrorKey) => { + if (!disposed) { + setViewError(key); + } + }; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(mount.clientWidth || 300, mount.clientHeight || 300); + mount.appendChild(renderer.domElement); + + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6)); + scene.add(new THREE.AmbientLight(0xffffff, 0.9)); + const dir1 = new THREE.DirectionalLight(0xffffff, 1.4); + dir1.position.set(3, 6, 2); + const dir2 = new THREE.DirectionalLight(0xffffff, 1.2); + dir2.position.set(-4, -2, 3); + scene.add(dir1); + scene.add(dir2); + camera.position.set(1, 1, 3); + + let controls: any; + let teardown: (() => void) | undefined; + const storageKey = viewKey ? `ps-view-${viewKey}` : null; + + const loadSavedView = () => { + if (!storageKey || typeof window === "undefined") return false; + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return false; + const data = JSON.parse(raw); + if (!Array.isArray(data?.position) || !Array.isArray(data?.target)) return false; + camera.position.fromArray(data.position); + controls?.target.fromArray(data.target); + controls?.update(); + return true; + } catch (err) { + console.warn("Failed to load saved view", err); + return false; + } + }; + + const saveView = () => { + if (!storageKey || typeof window === "undefined" || !controls) return; + try { + const payload = { + position: camera.position.toArray(), + target: controls.target.toArray(), + }; + window.localStorage.setItem(storageKey, JSON.stringify(payload)); + } catch {} + }; + + (async () => { + try { + const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js"); + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.enablePan = true; + controls.target.set(0, 0, 0); + controls.addEventListener("change", saveView); + + const centerSceneOn = (box: THREE.Box3) => { + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z) || 1; + if (!loadSavedView()) { + camera.position.copy(center).add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)); + camera.lookAt(center); + controls?.target.copy(center); + controls?.update(); + } + }; + + const e = (ext || "").toLowerCase(); + + try { + const obj = await loadObjectFromAsset(e, url); + if (!obj) { + reportError("unsupported"); + return; + } + if (disposed) { + disposeObject3D(obj); + return; + } + applyThemeToObject(obj, palette); + activeObject = obj; + scene.add(obj); + const box = new THREE.Box3().setFromObject(obj); + if (!box.isEmpty()) { + centerSceneOn(box); + } + } catch (err) { + console.error("Viewer asset load failed:", err); + reportError("failed"); + } + } catch (err) { + console.error("Viewer init failed:", err); + } + + const onResize = () => { + if (!mount) return; + const w = mount.clientWidth || 300; + const h = mount.clientHeight || 300; + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }; + window.addEventListener("resize", onResize); + teardown = () => window.removeEventListener("resize", onResize); + + const animate = () => { + if (disposed) return; + renderer.render(scene, camera); + requestAnimationFrame(animate); + }; + animate(); + })(); + + return () => { + disposed = true; + try { + teardown?.(); + mount.removeChild(renderer.domElement); + } catch {} + if (activeObject) { + disposeObject3D(activeObject); + } + try { + controls?.removeEventListener("change", saveView); + controls?.dispose(); + } catch {} + try { + renderer.forceContextLoss?.(); + } catch {} + renderer.dispose(); + }; +}, [url, ext, viewKey, theme]); + + return ( + + {viewError && ( + + + {viewError === "unsupported" + ? t("library:modelViewer.previewUnsupported") + : t("library:modelViewer.previewFailed")} + + + )} + + ); +} diff --git a/frontend/src/routes/library/components/PreparedPrintSummary.tsx b/frontend/src/routes/library/components/PreparedPrintSummary.tsx new file mode 100644 index 0000000..5a499ba --- /dev/null +++ b/frontend/src/routes/library/components/PreparedPrintSummary.tsx @@ -0,0 +1,47 @@ +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import { type PreparedPrint } from "../../../services/api"; +import { formatPreparedDuration } from "../../../utils/duration"; + +// Shared by PrintCard (card-level prepared-print banner) and PrintPreviewModal (header banner). +export default function PreparedPrintSummary({ + prepared, + onClear, + clearing = false, +}: { + prepared: PreparedPrint; + onClear?: () => void; + clearing?: boolean; +}) { + const { t } = useTranslation(["library"]); + const details = [ + prepared.material, + prepared.nozzle_mm ? `${prepared.nozzle_mm} mm` : null, + formatPreparedDuration(t, prepared.estimated_seconds), + ].filter(Boolean); + return ( + + {clearing ? t("library:preparedPrint.clearing") : t("library:preparedPrint.clear")} + + ) : undefined + } + > + + + {prepared.printer + ? t("library:preparedPrint.preparedFor", { printer: prepared.printer }) + : t("library:preparedPrint.available")} + + {details.length > 0 && <> · {details.join(" · ")}} + + + ); +} diff --git a/frontend/src/routes/library/components/PrintCard/components/SupportingFilesPanel.tsx b/frontend/src/routes/library/components/PrintCard/components/SupportingFilesPanel.tsx new file mode 100644 index 0000000..29b873e --- /dev/null +++ b/frontend/src/routes/library/components/PrintCard/components/SupportingFilesPanel.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import ButtonBase from "@mui/material/ButtonBase"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Divider from "@mui/material/Divider"; +import Typography from "@mui/material/Typography"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { useTranslation } from "react-i18next"; +import { + type Print, + type PrintFile, + fileUrl, + listPrintFiles, + uploadPrintFile, + deletePrintFile, +} from "../../../../../services/api"; + +// The "Supporting files" list/upload/remove panel on a PrintCard -- self-contained state (its own +// expanded/loading/uploading flags and file list) that only PrintCard renders, so it lives under +// PrintCard/components rather than being shared. +export default function SupportingFilesPanel({ + item, + onPrintChanged, +}: { + item: Print; + onPrintChanged: (print: Print) => void; +}) { + const { t } = useTranslation(["library"]); + const [filesExpanded, setFilesExpanded] = useState(false); + const [supportingFiles, setSupportingFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(false); + const [filesUploading, setFilesUploading] = useState(false); + const relatedInputRef = useRef(null); + + const loadSupportingFiles = async () => { + setFilesLoading(true); + try { + setSupportingFiles(await listPrintFiles(item.id)); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.loadFilesFailed")); + } finally { + setFilesLoading(false); + } + }; + + useEffect(() => { + if (filesExpanded) void loadSupportingFiles(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filesExpanded, item.id]); + + const addRelatedFiles = async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (!files.length) return; + setFilesUploading(true); + try { + let updated = item; + for (const file of files) { + updated = await uploadPrintFile(item.id, file); + onPrintChanged(updated); + } + setSupportingFiles(await listPrintFiles(item.id)); + setFilesExpanded(true); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.addFilesFailed")); + } finally { + setFilesUploading(false); + } + }; + + const removeRelatedFile = async (file: PrintFile) => { + if (!window.confirm(t("library:confirm.removeFile", { filename: file.filename }))) return; + try { + const updated = await deletePrintFile(item.id, file.id); + onPrintChanged(updated); + setSupportingFiles(current => current.filter(entry => entry.id !== file.id)); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.removeFileFailed")); + } + }; + + return ( + + + setFilesExpanded(value => !value)} + sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }} + > + {t("library:card.supportingFiles")} + + + + + + {filesExpanded && ( + <> + + + {filesLoading ? ( + {t("library:card.loadingFiles")} + ) : supportingFiles.length ? ( + + {supportingFiles.map(file => ( + + + {file.filename} + + + + ))} + + ) : ( + {t("library:card.supportingFilesEmpty")} + )} + + + )} + + ); +} diff --git a/frontend/src/routes/library/components/PrintCard/index.tsx b/frontend/src/routes/library/components/PrintCard/index.tsx new file mode 100644 index 0000000..9a0dc35 --- /dev/null +++ b/frontend/src/routes/library/components/PrintCard/index.tsx @@ -0,0 +1,603 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Menu from "@mui/material/Menu"; +import Tooltip from "@mui/material/Tooltip"; +import LayersIcon from "@mui/icons-material/Layers"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import FolderIcon from "@mui/icons-material/Folder"; +import EditIcon from "@mui/icons-material/Edit"; +import { type Print, UnauthorizedError, addPlates, deletePreparedPrint } from "../../../../services/api"; +import { type PreviewSettings } from "../../../../services/settings"; +import { type ResolvedTheme } from "../../../../constants/settingsOptions"; +import { MODEL_EXTS, ENGRAVING_EXTS } from "../../../../constants/fileTypes"; +import { extOf } from "../../../../utils/fileExtensions"; +import { entriesFromDataTransfer } from "../../../../services/uploadTree"; +import TagBadge from "../../../../common/TagBadge"; +import TagInput from "../../../../common/TagInput"; +import PreparedPrintSummary from "../PreparedPrintSummary"; +import { renderPreviewContent } from "../renderPreviewContent"; +import SupportingFilesPanel from "./components/SupportingFilesPanel"; + +export default function PrintCard({ + item, + onSaveTags, + onSaveDetails, + onRename, + onPreview, + onDownloadSingle, + onDownloadByTag, + onDownloadSelected, + downloading, + onDelete, + deleting, + onMoveFolder, + folderOptions, + moving, + selected, + onToggleSelected, + hasSelection, + bulkDownloading, + slicerEnabled, + slicerLabel, + onOpenInSlicer, + engravingEnabled, + engraverLabel, + onOpenInEngraving, + theme, + previewMode, + onPrintChanged, + onUnauthorized, +}: { + item: Print; + onSaveTags: (id: string, tags: string[]) => void; + onSaveDetails: (id: string, payload: { notes: string; creator: string; collection: string }) => void; + onRename: (id: string, filename: string) => void; + onPreview: (print: Print | null) => void; + onDownloadSingle: (print: Print) => void; + onDownloadByTag: (tag: string) => void; + onDownloadSelected: () => void; + downloading: boolean; + onDelete: (print: Print) => void; + deleting: boolean; + onMoveFolder: (id: string, folder_id: string | null) => void; + folderOptions: { id: string | null; name: string }[]; + moving: boolean; + selected: boolean; + onToggleSelected: () => void; + hasSelection: boolean; + bulkDownloading: boolean; + slicerEnabled: boolean; + slicerLabel: string; + onOpenInSlicer: (print: Print) => void; + engravingEnabled: boolean; + engraverLabel: string; + onOpenInEngraving: (print: Print) => void; + theme: ResolvedTheme; + previewMode: PreviewSettings["mode"]; + onPrintChanged: (print: Print) => void; + onUnauthorized?: () => void; +}) { + const { t } = useTranslation(["library", "common"]); + const [editingTags, setEditingTags] = useState(false); + const [tagList, setTagList] = useState(item.tags); + const [editingNotes, setEditingNotes] = useState(false); + const [notesValue, setNotesValue] = useState(item.notes || ""); + const [creatorValue, setCreatorValue] = useState(item.creator || ""); + const [collectionValue, setCollectionValue] = useState(item.collection || ""); + const [notesCollapsed, setNotesCollapsed] = useState(true); + const [renaming, setRenaming] = useState(false); + const [nameValue, setNameValue] = useState(item.name); + const nameInputRef = useRef(null); + const [downloadMenuAnchor, setDownloadMenuAnchor] = useState(null); + const [folderMenuAnchor, setFolderMenuAnchor] = useState(null); + const [preparedClearing, setPreparedClearing] = useState(false); + const [plateDropActive, setPlateDropActive] = useState(false); + const [addingPlates, setAddingPlates] = useState(false); + const plateDragDepth = useRef(0); + + useEffect(() => { + if (!editingTags) { + setTagList(item.tags); + } + }, [item.tags, editingTags]); + + useEffect(() => { + if (!editingNotes) { + setNotesValue(item.notes || ""); + setCreatorValue(item.creator || ""); + setCollectionValue(item.collection || ""); + } + }, [item.notes, item.creator, item.collection, editingNotes]); + + useEffect(() => { + if (!renaming) { + setNameValue(item.name); + } else { + setTimeout(() => nameInputRef.current?.select(), 0); + } + }, [item.name, renaming]); + + const clearPreparedPrint = async () => { + if (!window.confirm(t("library:confirm.clearPrepared"))) return; + setPreparedClearing(true); + try { + onPrintChanged(await deletePreparedPrint(item.id)); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.clearPreparedFailed")); + } finally { + setPreparedClearing(false); + } + }; + + const startEditing = () => { + setTagList(item.tags); + setEditingTags(true); + }; + + const cancelEditing = () => { + setEditingTags(false); + setTagList(item.tags); + }; + + const save = async () => { + await onSaveTags(item.id, tagList); + setEditingTags(false); + }; + + const saveNotes = async () => { + await onSaveDetails(item.id, { + notes: notesValue, + creator: creatorValue, + collection: collectionValue, + }); + setEditingNotes(false); + }; + + const cancelNotes = () => { + setEditingNotes(false); + setNotesValue(item.notes || ""); + setCreatorValue(item.creator || ""); + setCollectionValue(item.collection || ""); + }; + + const noteText = (item.notes || "").trim(); + + const commitRename = async () => { + const next = nameValue.trim(); + if (!next) { + setNameValue(item.name); + setRenaming(false); + return; + } + if (next === item.name) { + setRenaming(false); + return; + } + try { + await onRename(item.id, next); + } catch (err) { + console.error(err); + alert(t("library:errors.renameFailed")); + setNameValue(item.name); + } finally { + setRenaming(false); + } + }; + + const cancelRename = () => { + setNameValue(item.name); + setRenaming(false); + }; + + const handleRenameKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } + }; + + const primaryPlate = item.plates[0]; + const primaryExt = extOf(primaryPlate?.filename || ""); + const canOpenInSlicer = slicerEnabled && (Boolean(item.prepared_print) || MODEL_EXTS.has(primaryExt)); + const canOpenInEngraving = engravingEnabled && ENGRAVING_EXTS.has(primaryExt); + const isMultiPlate = item.plates.length > 1; + const currentFolderName = folderOptions.find(opt => (opt.id || null) === (item.folder_id || null))?.name + || t("common:unassigned"); + + const isFileDragCard = (e: React.DragEvent) => + Array.from(e.dataTransfer?.types || []).includes("Files"); + + const handleCardDragEnter = (e: React.DragEvent) => { + if (!isFileDragCard(e)) return; + e.preventDefault(); + e.stopPropagation(); + plateDragDepth.current += 1; + setPlateDropActive(true); + }; + + const handleCardDragOver = (e: React.DragEvent) => { + if (!isFileDragCard(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }; + + const handleCardDragLeave = (e: React.DragEvent) => { + if (!isFileDragCard(e)) return; + e.preventDefault(); + e.stopPropagation(); + plateDragDepth.current -= 1; + if (plateDragDepth.current <= 0) { + plateDragDepth.current = 0; + setPlateDropActive(false); + } + }; + + const handleCardDrop = async (e: React.DragEvent) => { + if (!isFileDragCard(e)) return; + e.preventDefault(); + // Stop this from also bubbling up to the grid's "drop anywhere uploads a + // new item" handler -- dropping onto an existing print card always means + // "add these as plate(s) of this print". + e.stopPropagation(); + plateDragDepth.current = 0; + setPlateDropActive(false); + const entries = await entriesFromDataTransfer(e.dataTransfer); + const files = entries.map(entry => entry.file); + if (!files.length) return; + setAddingPlates(true); + try { + const { print: updated } = await addPlates(item.id, files); + onPrintChanged(updated); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.addPlatesFailed")); + } finally { + setAddingPlates(false); + } + }; + + return ( + + + onPreview(item)} + onClick={e => e.stopPropagation()} + > + {renderPreviewContent(item, "card", theme, t, previewMode)} + {isMultiPlate && ( + } + label={t("library:card.plateCountBadge", { count: item.plates.length })} + size="small" + sx={{ + position: "absolute", + bottom: 6, + right: 6, + bgcolor: "rgba(0, 0, 0, 0.7)", + color: "common.white", + fontSize: 11, + height: 22, + }} + /> + )} + {(plateDropActive || addingPlates) && ( + + + + {addingPlates ? t("library:card.addingPlates") : t("library:card.dropToAddPlate")} + + + + )} + + + + {item.prepared_print && ( + + )} + + } + label={{t("library:card.select")}} + /> + + + setDownloadMenuAnchor(null)}> + { setDownloadMenuAnchor(null); onDownloadSingle(item); }}> + {isMultiPlate ? t("library:card.downloadAllPlatesZip") : t("library:card.downloadFile")} + + { setDownloadMenuAnchor(null); onDownloadSelected(); }} + > + {t("library:card.downloadSelected")} + + {item.tags.map(tg => ( + { setDownloadMenuAnchor(null); onDownloadByTag(tg); }}> + {t("library:card.downloadTag", { tag: tg })} + + ))} + + + + {(canOpenInSlicer || canOpenInEngraving) && ( + + {canOpenInSlicer && ( + + )} + {canOpenInEngraving && ( + + )} + + )} + + + {moving ? t("library:card.folderUpdating") : t("library:card.folderLabel")} + + + setFolderMenuAnchor(null)}> + {folderOptions.map(opt => ( + { + setFolderMenuAnchor(null); + if ((opt.id || null) !== (item.folder_id || null)) onMoveFolder(item.id, opt.id); + }} + > + {opt.name} + + ))} + + + { e.stopPropagation(); setRenaming(true); }} + onClick={e => renaming && e.stopPropagation()} + > + {renaming ? ( + setNameValue(e.target.value)} + onBlur={commitRename} + onKeyDown={handleRenameKey} + onClick={e => e.stopPropagation()} + size="small" + fullWidth + autoFocus + /> + ) : ( + {item.name} + )} + + + {isMultiPlate + ? t("library:card.morePlates", { filename: primaryPlate?.filename || "", count: item.plates.length - 1 }) + : (primaryPlate?.filename || "")} + + {(item.creator || item.collection) && ( + + {item.creator && ( + + {t("library:card.byCreator", { creator: item.creator })} + + )} + {item.collection && ( + + {t("library:card.collectionLabel", { collection: item.collection })} + + )} + + )} + + {item.tags.length ? ( + item.tags.map(tg => ) + ) : ( + {t("library:card.noTags")} + )} + + + + + + {t("library:card.modelDetails")} + + + + {!notesCollapsed && ( + <> + {editingNotes ? ( + <> + setCreatorValue(e.target.value)} + placeholder={t("library:card.creatorPlaceholder")} + size="small" + fullWidth + /> + setCollectionValue(e.target.value)} + placeholder={t("library:card.collectionPlaceholder")} + size="small" + fullWidth + /> + setNotesValue(e.target.value)} + placeholder={t("library:card.notesPlaceholder")} + size="small" + fullWidth + multiline + minRows={3} + /> + + + + + + ) : ( + <> + + + + {t("common:creator")} + + {item.creator || t("library:card.notSet")} + + + + {t("common:collection")} + + {item.collection || t("library:card.notSet")} + + + + {noteText || t("library:card.addNotes")} + + + + )} + + )} + {notesCollapsed && ( + + {noteText + ? `${noteText.slice(0, 60)}${noteText.length > 60 ? "..." : ""}` + : [item.creator, item.collection].filter(Boolean).join(" · ") || t("library:card.noDetails")} + + )} + + + {editingTags ? ( + + + + + + + + ) : ( + + + + + )} + + + ); +} diff --git a/frontend/src/routes/library/components/PrintPreviewModal/components/PlateSwitcher.tsx b/frontend/src/routes/library/components/PrintPreviewModal/components/PlateSwitcher.tsx new file mode 100644 index 0000000..b2aad63 --- /dev/null +++ b/frontend/src/routes/library/components/PrintPreviewModal/components/PlateSwitcher.tsx @@ -0,0 +1,131 @@ +import { useRef } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import ButtonBase from "@mui/material/ButtonBase"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import CloseIcon from "@mui/icons-material/Close"; +import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate"; +import { useTranslation } from "react-i18next"; +import { type Plate, fileUrl } from "../../../../../services/api"; + +// The plate-switcher thumbnail strip in PrintPreviewModal: a row of clickable plate thumbnails +// (defaulting to whichever plate is passed as active -- callers are responsible for defaulting to +// plate 0), each with an overlaid remove button, plus a trailing "add plate" tile. Owns its own +// hidden file input so the parent modal only deals with File[] via onAddPlateFiles. +export default function PlateSwitcher({ + plates, + activePlateId, + onSelectPlate, + onRemovePlate, + removingPlateId, + canRemove, + addingPlate, + onAddPlateFiles, +}: { + plates: Plate[]; + activePlateId: string | null; + onSelectPlate: (id: string) => void; + onRemovePlate: (plate: Plate) => void; + removingPlateId: string | null; + canRemove: boolean; + addingPlate: boolean; + onAddPlateFiles: (files: File[]) => void; +}) { + const { t } = useTranslation(["library"]); + const addPlateInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length) onAddPlateFiles(files); + }; + + return ( + + {plates.map(plate => { + const isActive = plate.id === activePlateId || (!activePlateId && plate.id === plates[0]?.id); + return ( + + onSelectPlate(plate.id)} + title={plate.filename} + sx={{ + height: 64, + width: 64, + overflow: "hidden", + borderRadius: 1, + border: "2px solid", + borderColor: isActive ? "primary.main" : "divider", + }} + > + {plate.thumb_url ? ( + + ) : ( + + {plate.position + 1} + + )} + + {canRemove && ( + onRemovePlate(plate)} + disabled={removingPlateId === plate.id} + title={t("library:previewModal.removePlateTooltip")} + sx={{ + position: "absolute", + top: -8, + right: -8, + width: 20, + height: 20, + bgcolor: "rgba(0, 0, 0, 0.7)", + color: "common.white", + "&:hover": { bgcolor: "rgba(0, 0, 0, 0.85)" }, + }} + > + + + )} + + ); + })} + addPlateInputRef.current?.click()} + disabled={addingPlate} + title={t("library:previewModal.addPlateTooltip")} + sx={{ + height: 64, + width: 64, + flexShrink: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 0.25, + borderRadius: 1, + border: "1px dashed", + borderColor: "divider", + }} + > + + + {addingPlate ? t("library:previewModal.addingPlate") : t("library:previewModal.addPlate")} + + + + + ); +} diff --git a/frontend/src/routes/library/components/PrintPreviewModal/index.tsx b/frontend/src/routes/library/components/PrintPreviewModal/index.tsx new file mode 100644 index 0000000..b7b8c37 --- /dev/null +++ b/frontend/src/routes/library/components/PrintPreviewModal/index.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import CloseIcon from "@mui/icons-material/Close"; +import DownloadIcon from "@mui/icons-material/Download"; +import { + type Print, + type Plate, + UnauthorizedError, + LastPlateError, + fileUrl, + addPlates, + deletePlate, +} from "../../../../services/api"; +import { type ResolvedTheme } from "../../../../constants/settingsOptions"; +import { MODEL_EXTS, ENGRAVING_EXTS } from "../../../../constants/fileTypes"; +import { SLICER_BRIDGE_ENABLED } from "../../../../constants/featureFlags"; +import { extOf } from "../../../../utils/fileExtensions"; +import { formatFileSize } from "../../../../utils/fileSize"; +import TagBadge from "../../../../common/TagBadge"; +import PreparedPrintSummary from "../PreparedPrintSummary"; +import { renderPreviewContent } from "../renderPreviewContent"; +import PlateSwitcher from "./components/PlateSwitcher"; + +export default function PrintPreviewModal({ + print, + theme, + slicerEnabled, + slicerLabel, + slicerSelected, + engravingEnabled, + engraverLabel, + engravingSelected, + onClose, + onPrintChanged, + onUnauthorized, +}: { + print: Print; + theme: ResolvedTheme; + slicerEnabled: boolean; + slicerLabel: string; + slicerSelected?: string; + engravingEnabled: boolean; + engraverLabel: string; + engravingSelected?: string; + onClose: () => void; + onPrintChanged: (print: Print) => void; + onUnauthorized?: () => void; +}) { + const { t } = useTranslation(["library", "common"]); + const [activePlateId, setActivePlateId] = useState(print.plates[0]?.id || null); + const [addingPlate, setAddingPlate] = useState(false); + const [removingPlateId, setRemovingPlateId] = useState(null); + + useEffect(() => { + setActivePlateId(print.plates[0]?.id || null); + // Only reset the active plate when a *different* print is opened -- an + // in-place plate list change (add/remove) should not jump back to + // plate[0] unless the active plate itself disappeared. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [print.id]); + + const sortedPlates = useMemo( + () => [...print.plates].sort((a, b) => a.position - b.position), + [print.plates] + ); + const activePlate = sortedPlates.find(p => p.id === activePlateId) || sortedPlates[0]; + + const handleAddPlateFiles = async (files: File[]) => { + if (!files.length) return; + setAddingPlate(true); + try { + const { print: updated } = await addPlates(print.id, files); + onPrintChanged(updated); + const newest = [...updated.plates].sort((a, b) => a.position - b.position).slice(-1)[0]; + if (newest) setActivePlateId(newest.id); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.addPlateFailed")); + } finally { + setAddingPlate(false); + } + }; + + const handleRemovePlate = async (plate: Plate) => { + if (print.plates.length <= 1) return; + if (!window.confirm(t("library:confirm.removePlate", { filename: plate.filename }))) return; + setRemovingPlateId(plate.id); + try { + const { print: updated } = await deletePlate(print.id, plate.id); + onPrintChanged(updated); + setActivePlateId(current => { + if (current !== plate.id) return current; + const ordered = [...updated.plates].sort((a, b) => a.position - b.position); + return ordered[0]?.id || null; + }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + if (err instanceof LastPlateError) { + alert(err.message); + return; + } + console.error(err); + alert(err instanceof Error ? err.message : t("library:errors.removePlateFailed")); + } finally { + setRemovingPlateId(null); + } + }; + + const primaryExt = activePlate ? extOf(activePlate.filename) : ""; + const isFirstPlate = activePlate ? activePlate.position === 0 : false; + const canOpenActiveInSlicer = Boolean(activePlate) && slicerEnabled && + ((isFirstPlate && Boolean(print.prepared_print)) || MODEL_EXTS.has(primaryExt)); + const canOpenActiveInEngraving = Boolean(activePlate) && engravingEnabled && ENGRAVING_EXTS.has(primaryExt); + + const openActiveInSlicer = () => { + if (!SLICER_BRIDGE_ENABLED || !activePlate) return; + const url = fileUrl(isFirstPlate ? (print.slicer_url || activePlate.url) : activePlate.url); + const filename = isFirstPlate ? (print.slicer_filename || activePlate.filename) : activePlate.filename; + const params = new URLSearchParams({ url, slicer: slicerSelected || "orca", filename: filename || "model" }); + window.location.href = `printstash-slicer://open?${params.toString()}`; + }; + + const openActiveInEngraving = () => { + if (!activePlate) return; + const url = fileUrl(activePlate.url); + const params = new URLSearchParams({ url, engraver: engravingSelected || "lightburn", filename: activePlate.filename || "design" }); + window.location.href = `printstash-engrave://open?${params.toString()}`; + }; + + return ( + + + + {print.name} + + {activePlate ? `${activePlate.filename} · ${formatFileSize(activePlate.size)}` : ""} + {print.plates.length > 1 ? ` · ${t("library:previewModal.plateCount", { count: print.plates.length })}` : ""} + + {(print.creator || print.collection) && ( + + {[ + print.creator ? t("library:card.byCreator", { creator: print.creator }) : "", + print.collection ? t("library:card.collectionLabel", { collection: print.collection }) : "", + ].filter(Boolean).join(" · ")} + + )} + {print.prepared_print && ( + + )} + + + + + + + + 1} + addingPlate={addingPlate} + onAddPlateFiles={handleAddPlateFiles} + /> + + + {renderPreviewContent(print, "modal", theme, t, "automatic", activePlate)} + + + {(canOpenActiveInSlicer || canOpenActiveInEngraving) && ( + + {canOpenActiveInSlicer && ( + + )} + {canOpenActiveInEngraving && ( + + )} + + )} + + {print.tags.length ? ( + print.tags.map(tag => ) + ) : ( + {t("library:previewModal.noTags")} + )} + + {print.notes && ( + + {print.notes} + + )} + + + + {activePlate && ( + + )} + + + + ); +} diff --git a/frontend/src/routes/library/components/Sidebar/components/FolderActionMenu.tsx b/frontend/src/routes/library/components/Sidebar/components/FolderActionMenu.tsx new file mode 100644 index 0000000..9330ac5 --- /dev/null +++ b/frontend/src/routes/library/components/Sidebar/components/FolderActionMenu.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Divider from "@mui/material/Divider"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import EditIcon from "@mui/icons-material/Edit"; +import DownloadIcon from "@mui/icons-material/Download"; +import DeleteIcon from "@mui/icons-material/Delete"; +import type { Folder } from "../../../../../services/api"; + +type Props = { + folder: Folder | null; + anchorEl: HTMLElement | null; + busy: boolean; + labels: { + subfolder: string; + renameEdit: string; + downloadZip: string; + delete: string; + }; + onClose: () => void; + onSubfolder: (folder: Folder) => void; + onRename: (folder: Folder) => void; + onDownload: (folder: Folder) => void; + onDelete: (folder: Folder) => void; +}; + +/** The "..." row action menu (subfolder / rename / download / delete) -- a single shared + * Menu instance anchored to whichever row's trigger was last clicked, rather than one + * Menu per row. */ +export default function FolderActionMenu({ folder, anchorEl, busy, labels, onClose, onSubfolder, onRename, onDownload, onDelete }: Props) { + return ( + + {folder && [ + { onClose(); onSubfolder(folder); }}> + + {labels.subfolder} + , + { onClose(); onRename(folder); }}> + + {labels.renameEdit} + , + { onClose(); onDownload(folder); }}> + + {labels.downloadZip} + , + , + { onClose(); onDelete(folder); }} + sx={{ color: "error.main" }} + > + + {labels.delete} + , + ]} + + ); +} diff --git a/frontend/src/routes/library/components/Sidebar/components/FolderEditorPanel.tsx b/frontend/src/routes/library/components/Sidebar/components/FolderEditorPanel.tsx new file mode 100644 index 0000000..c254275 --- /dev/null +++ b/frontend/src/routes/library/components/Sidebar/components/FolderEditorPanel.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import CloseIcon from "@mui/icons-material/Close"; +import CheckIcon from "@mui/icons-material/Check"; +import TagInput from "../../../../../common/TagInput"; + +export type FolderOption = { id: string | null; name: string }; + +type Props = { + /** "create" omits the tags field (new folders start untagged); "edit" includes it. */ + mode: "create" | "edit"; + icon: React.ReactNode; + title: string; + subtitle: string; + name: string; + onNameChange: (name: string) => void; + namePlaceholder?: string; + parentId: string | null; + onParentChange: (id: string | null) => void; + folderOptions: FolderOption[]; + tags?: string[]; + onTagsChange?: (tags: string[]) => void; + tagsPlaceholder?: string; + busy: boolean; + submitLabel: string; + submitDisabled?: boolean; + onSubmit: () => void; + onCancel: () => void; +}; + +/** The inline "New folder" / "Edit folder" panel shown below the tree -- one shared shape for + * both modes, since they differ only in heading copy, the presence of a tags field, and the + * submit action. */ +export default function FolderEditorPanel({ + mode, + icon, + title, + subtitle, + name, + onNameChange, + namePlaceholder, + parentId, + onParentChange, + folderOptions, + tags, + onTagsChange, + tagsPlaceholder, + busy, + submitLabel, + submitDisabled, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation(["app", "common"]); + const locationLabelId = `${mode}-folder-location-label`; + return ( + + + {icon} + + {title} + {subtitle} + + + + + + + onNameChange(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") onSubmit(); + if (e.key === "Escape") onCancel(); + }} + placeholder={namePlaceholder} + fullWidth + /> + + {t("sidebar.locationLabel")} + + + {mode === "edit" && ( + {})} placeholder={tagsPlaceholder} /> + )} + + + + + + + ); +} diff --git a/frontend/src/routes/library/components/Sidebar/components/FolderTreeRow.tsx b/frontend/src/routes/library/components/Sidebar/components/FolderTreeRow.tsx new file mode 100644 index 0000000..019f801 --- /dev/null +++ b/frontend/src/routes/library/components/Sidebar/components/FolderTreeRow.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List"; +import Collapse from "@mui/material/Collapse"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import FolderIcon from "@mui/icons-material/Folder"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import type { Folder } from "../../../../../services/api"; + +/** Shared, tree-wide config a row needs to render itself and recurse into its children -- + * passed down explicitly rather than read from closures, so each row stays a plain + * function of its props. */ +export type FolderTreeContext = { + childrenMap: Record; + visibleFolderIds: Set | null; + expanded: Set; + dropTargetId: string | null; + selectedId: string | null | undefined; + query: string; + untitledLabel: string; + collapseLabel: string; + expandLabel: string; + actionsForLabel: (name: string) => string; + folderPath: (folder: Folder) => string; + onSelect: (id: string) => void; + onToggleExpand: (id: string) => void; + onDragOverTarget: (id: string) => (e: React.DragEvent) => void; + onDropFiles: (id: string) => (e: React.DragEvent) => void; + onOpenMenu: (folderId: string, anchorEl: HTMLElement) => void; +}; + +type Props = { + folder: Folder; + depth: number; + ctx: FolderTreeContext; +}; + +/** One row of the recursive folder tree, plus (via a nested Collapse + List) its own + * children rendered as more FolderTreeRow instances. */ +export default function FolderTreeRow({ folder, depth, ctx }: Props) { + const { visibleFolderIds, childrenMap, expanded, dropTargetId, selectedId, query, untitledLabel } = ctx; + if (visibleFolderIds && !visibleFolderIds.has(folder.id)) return null; + const children = (childrenMap[folder.id] || []).filter( + child => !visibleFolderIds || visibleFolderIds.has(child.id) + ); + const isSelected = selectedId === folder.id; + const isOpen = !!query.trim() || expanded.has(folder.id); + const isDropTarget = dropTargetId === folder.id; + + return ( + + ctx.onSelect(folder.id)} + onDragOver={ctx.onDragOverTarget(folder.id)} + onDrop={ctx.onDropFiles(folder.id)} + title={ctx.folderPath(folder)} + aria-current={isSelected ? "page" : undefined} + sx={{ + pl: 1 + depth * 2, + borderRadius: 1, + mb: 0.25, + ...(isDropTarget && { + outline: "2px dashed", + outlineColor: "primary.main", + outlineOffset: -2, + }), + }} + > + {children.length ? ( + { e.stopPropagation(); ctx.onToggleExpand(folder.id); }} + aria-label={isOpen ? ctx.collapseLabel : ctx.expandLabel} + aria-expanded={isOpen} + sx={{ mr: 0.5, p: 0.25 }} + > + + + ) : ( + + {children.length > 0 && ( + + + {children.map(child => ( + + ))} + + + )} + + ); +} diff --git a/frontend/src/routes/library/components/Sidebar/index.tsx b/frontend/src/routes/library/components/Sidebar/index.tsx new file mode 100644 index 0000000..73d50e3 --- /dev/null +++ b/frontend/src/routes/library/components/Sidebar/index.tsx @@ -0,0 +1,675 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Chip from "@mui/material/Chip"; +import AddIcon from "@mui/icons-material/Add"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import EditIcon from "@mui/icons-material/Edit"; +import SettingsIcon from "@mui/icons-material/Settings"; +import LayersIcon from "@mui/icons-material/Layers"; +import { Folder, UnauthorizedError, createFolder, deleteFolder, downloadFolderZip, listFolders, updateFolder, uploadPrints } from "../../../../services/api"; +import { entriesFromDataTransfer, uploadEntriesToFolder } from "../../../../services/uploadTree"; +import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../../../services/zipUtils"; +import { useZipImportPrompt } from "../ZipImportModal"; +import { useImportModePrompt, type ImportMode } from "../ImportModeModal"; +import FolderTreeRow, { type FolderTreeContext } from "./components/FolderTreeRow"; +import FolderActionMenu from "./components/FolderActionMenu"; +import FolderEditorPanel, { type FolderOption } from "./components/FolderEditorPanel"; + +// Every dropped entry's relativePath equals its bare filename when the +// selection has no folder structure -- that's the signal for the +// separate-vs-multiplate prompt (a dropped folder always carries nested +// relativePaths and skips this prompt). +function isFlatFileSet(entries: { file: File; relativePath: string }[]) { + return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name); +} + +type Props = { + selectedId?: string | null; + onSelect: (id: string | null) => void; + onFoldersChanged?: () => void; + foldersVersion?: number; + onUnauthorized?: () => void; + onOpenSettings?: () => void; + activeView?: "library" | "settings"; + onAssetsChanged?: () => void; +}; + +const DROP_ALL_ID = "__all"; + +export default function Sidebar({ + selectedId, + onSelect, + onFoldersChanged, + foldersVersion, + onUnauthorized, + onOpenSettings, + activeView = "library", + onAssetsChanged, +}: Props) { + const { t } = useTranslation(["app", "common"]); + const [folders, setFolders] = useState([]); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newParent, setNewParent] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(null); + const [editName, setEditName] = useState(""); + const [editTags, setEditTags] = useState([]); + const [editParent, setEditParent] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [dropUploading, setDropUploading] = useState(false); + const [query, setQuery] = useState(""); + const [menuState, setMenuState] = useState<{ folderId: string; anchorEl: HTMLElement } | null>(null); + const zipPrompt = useZipImportPrompt(); + const importModePrompt = useImportModePrompt(); + + const untitledLabel = t("sidebar.untitled"); + + const uploadFlatAsMultiplate = async (files: File[], folderId: string | null) => { + try { + const result = await uploadPrints(files, { folder_id: folderId || undefined, mode: "multiplate" }); + return { uploaded: result.prints.length, failed: [] as string[] }; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return { uploaded: 0, failed: [] as string[] }; + } + const message = err instanceof Error ? err.message.trim() : ""; + return { + uploaded: 0, + failed: [ + message + ? t("sidebar.multiplateImportFailedWithMessage", { message }) + : t("sidebar.multiplateImportFailed"), + ], + }; + } + }; + + const uploadWithZipPrompt = async (entries: Awaited>, folderId: string | null) => { + const normalEntries = entries.filter(entry => !isZipFile(entry.file.name)); + const zipEntries = entries.filter(entry => isZipFile(entry.file.name)); + let uploaded = 0; + const failed: string[] = []; + const applyResult = (result: { uploaded: number; failed: string[] }) => { + uploaded += result.uploaded; + failed.push(...result.failed); + }; + if (normalEntries.length) { + if (isFlatFileSet(normalEntries)) { + await importModePrompt.prompt({ + label: normalEntries.map(entry => entry.file.name).join(", "), + count: normalEntries.length, + onChoose: async (mode: ImportMode) => { + if (mode === "multiplate") { + applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file), folderId)); + } else { + applyResult(await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized)); + } + }, + }); + } else { + const result = await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized); + applyResult(result); + } + } + for (const entry of zipEntries) { + let zipData: Record | null = null; + const baseParts = entry.relativePath.split("/").filter(Boolean); + baseParts.pop(); + const basePath = baseParts.join("/"); + await zipPrompt.prompt({ + label: entry.file.name, + onImportAsZip: async () => { + const result = await uploadEntriesToFolder([entry], folderId, onUnauthorized); + applyResult(result); + }, + loadEntries: async () => { + const result = await readZipEntries(entry.file); + zipData = result.data; + return result.entries; + }, + onImportSelected: async (selectedPaths: string[]) => { + if (!zipData) { + const result = await readZipEntries(entry.file); + zipData = result.data; + } + const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath); + const result = await uploadEntriesToFolder(unzipEntries, folderId, onUnauthorized); + applyResult(result); + }, + }); + } + if (uploaded) { + onAssetsChanged?.(); + } + if (failed.length) { + alert(t("sidebar.errors.uploadFailed", { files: failed.join(", ") })); + } + }; + + const isFileDrag = (e: React.DragEvent) => { + const types = Array.from(e.dataTransfer?.types || []); + return types.includes("Files"); + }; + + const handleDragOverTarget = (targetId: string) => (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + if (!dropUploading) { + setDropTargetId(targetId); + } + e.dataTransfer.dropEffect = "copy"; + }; + + const handleDropFiles = (folderId: string | null) => async ( + e: React.DragEvent + ) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + if (dropUploading) return; + setDropTargetId(null); + setDropUploading(true); + const entries = await entriesFromDataTransfer(e.dataTransfer); + if (!entries.length) { + setDropUploading(false); + return; + } + await uploadWithZipPrompt(entries, folderId); + setDropUploading(false); + }; + + const handleSidebarDragOver = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + }; + + const handleSidebarDragLeave = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + if (e.currentTarget !== e.target) return; + e.preventDefault(); + setDropTargetId(null); + }; + + const handleSidebarDrop = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + if (dropUploading) return; + const targetFolderId = + dropTargetId === DROP_ALL_ID ? null : dropTargetId; + setDropTargetId(null); + if (!targetFolderId && dropTargetId !== DROP_ALL_ID) return; + setDropUploading(true); + void (async () => { + const entries = await entriesFromDataTransfer(e.dataTransfer); + if (!entries.length) { + setDropUploading(false); + return; + } + await uploadWithZipPrompt(entries, targetFolderId); + setDropUploading(false); + })(); + }; + + const filenameFromDisposition = (res: Response, fallback: string) => { + const dispo = res.headers.get("content-disposition") || ""; + const match = dispo.match(/filename="?([^";]+)"?/i); + return (match && match[1]) || fallback; + }; + + const saveResponseToDisk = async (res: Response, fallback: string) => { + const filename = filenameFromDisposition(res, fallback); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleError = (err: unknown, message: string) => { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + console.error(err); + alert(message); + }; + + const refresh = async () => { + try { + setFolders(await listFolders()); + } catch (err) { + handleError(err, t("sidebar.errors.loadFolders")); + } + }; + useEffect(() => { refresh(); }, [foldersVersion]); + + const startCreate = (parentId: string | null = null) => { + setEditing(null); + setCreating(true); + setNewName(""); + setNewParent(parentId ?? (selectedId || null)); + }; + const create = async () => { + if (!newName.trim()) return; + setBusy(true); + try { + await createFolder(newName.trim(), [], newParent || undefined); + await refresh(); + if (newParent) { + setExpanded(prev => new Set(prev).add(newParent)); + } + onFoldersChanged?.(); + } catch (err) { + handleError(err, t("sidebar.errors.createFolder")); + } finally { setBusy(false); setCreating(false); } + }; + + const startEdit = (f: Folder) => { + setCreating(false); + setEditing(f.id); + setEditName(f.name); + setEditTags(f.tags); + setEditParent(f.parent_id || null); + }; + const saveEdit = async () => { + if (!editing) return; + setBusy(true); + try { + await updateFolder(editing, editName.trim() || untitledLabel, editTags, editParent || undefined); + await refresh(); + onFoldersChanged?.(); + } catch (err) { + handleError(err, t("sidebar.errors.updateFolder")); + } finally { setBusy(false); setEditing(null); setEditTags([]); setEditParent(null); } + }; + + const remove = async (id: string) => { + if (!confirm(t("sidebar.confirmDeleteFolder"))) return; + setBusy(true); + try { + await deleteFolder(id); + await refresh(); + onFoldersChanged?.(); + if (selectedId === id) onSelect(null); + } + catch (err) { + handleError(err, t("sidebar.errors.deleteFolder")); + } + finally { setBusy(false); } + }; + + const downloadFolder = async (folder: Folder) => { + setBusy(true); + try { + const res = await downloadFolderZip(folder.id); + const safe = (folder.name || "folder").replace(/\s+/g, "_") || "folder"; + await saveResponseToDisk(res, `${safe}.zip`); + } catch (err) { + handleError(err, t("sidebar.errors.downloadFolder")); + } finally { + setBusy(false); + } + }; + + const folderById = React.useMemo(() => { + const map: Record = {}; + folders.forEach(f => { map[f.id] = f; }); + return map; + }, [folders]); + + useEffect(() => { + // Ensure selected folder path is expanded + if (!selectedId) return; + const next = new Set(expanded); + let current = folderById[selectedId]; + const guard = new Set(); + while (current?.parent_id && !guard.has(current.parent_id)) { + next.add(current.parent_id); + guard.add(current.parent_id); + current = folderById[current.parent_id]; + } + setExpanded(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId, folderById]); + + const isDescendant = React.useCallback( + (candidateId: string, targetId: string) => { + let current = folderById[candidateId]; + const guard = new Set(); + while (current) { + if (!current.parent_id) return false; + if (current.parent_id === targetId) return true; + if (guard.has(current.parent_id)) break; + guard.add(current.parent_id); + current = folderById[current.parent_id]; + } + return false; + }, + [folderById] + ); + + const folderPath = React.useCallback( + (folder: Folder) => { + const segments = [folder.name || untitledLabel]; + let current = folder; + const guard = new Set([folder.id]); + while (current.parent_id) { + const parent = folderById[current.parent_id]; + if (!parent || guard.has(parent.id)) break; + segments.unshift(parent.name || untitledLabel); + guard.add(parent.id); + current = parent; + } + return segments.join(" / "); + }, + [folderById, untitledLabel] + ); + + const folderOptions: FolderOption[] = React.useMemo(() => { + const opts: FolderOption[] = [{ id: null, name: t("sidebar.rootOption") }]; + const sorted = [...folders].sort((a, b) => folderPath(a).localeCompare(folderPath(b))); + sorted.forEach(f => opts.push({ id: f.id, name: folderPath(f) })); + return opts; + }, [folders, folderPath, t]); + + const childrenMap = React.useMemo(() => { + const map: Record = {}; + const push = (key: string, f: Folder) => { + if (!map[key]) map[key] = []; + map[key].push(f); + }; + folders.forEach(f => push(f.parent_id || "__root", f)); + Object.values(map).forEach(list => list.sort((a, b) => a.name.localeCompare(b.name))); + return map; + }, [folders]); + + const visibleFolderIds = React.useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return null; + + const visible = new Set(); + folders.forEach(folder => { + if (!folderPath(folder).toLocaleLowerCase().includes(normalizedQuery)) return; + let current: Folder | undefined = folder; + const guard = new Set(); + while (current && !guard.has(current.id)) { + visible.add(current.id); + guard.add(current.id); + current = current.parent_id ? folderById[current.parent_id] : undefined; + } + }); + return visible; + }, [folderById, folderPath, folders, query]); + + // Default-expand root folders so they are visible + useEffect(() => { + const roots = childrenMap["__root"] || []; + if (!roots.length) return; + setExpanded(prev => { + const next = new Set(prev); + roots.forEach(r => next.add(r.id)); + return next; + }); + }, [childrenMap]); + + const toggleExpand = (id: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const activeMenuFolder = menuState ? folderById[menuState.folderId] ?? null : null; + + const treeCtx: FolderTreeContext = { + childrenMap, + visibleFolderIds, + expanded, + dropTargetId, + selectedId, + query, + untitledLabel, + collapseLabel: t("sidebar.collapse"), + expandLabel: t("sidebar.expand"), + actionsForLabel: name => t("sidebar.actionsFor", { name }), + folderPath, + onSelect: id => onSelect(id), + onToggleExpand: toggleExpand, + onDragOverTarget: handleDragOverTarget, + onDropFiles: handleDropFiles, + onOpenMenu: (folderId, anchorEl) => setMenuState({ folderId, anchorEl }), + }; + + const editFolderOptions = React.useMemo( + () => + folderOptions.filter( + opt => !editing || (opt.id !== editing && !(opt.id && isDescendant(opt.id, editing))) + ), + [folderOptions, editing, isDescendant] + ); + + return ( + + + + + {t("sidebar.brand")} + + + {t("sidebar.libraryTitle")} + + + + + + + + setQuery(event.target.value)} + placeholder={t("sidebar.searchPlaceholder") ?? undefined} + inputProps={{ "aria-label": t("sidebar.searchAriaLabel") ?? undefined }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: query ? ( + + setQuery("")} + aria-label={t("sidebar.clearSearchAria") ?? undefined} + > + + + + ) : undefined, + }} + /> + + + + + onSelect(null)} + onDragOver={handleDragOverTarget(DROP_ALL_ID)} + onDrop={handleDropFiles(null)} + sx={{ + borderRadius: 1, + mb: 0.5, + ...(dropTargetId === DROP_ALL_ID && { + outline: "2px dashed", + outlineColor: "primary.main", + outlineOffset: -2, + }), + }} + > + + + + + + + + + + + {t("sidebar.foldersHeading")} + + + {visibleFolderIds ? visibleFolderIds.size : folders.length} + + + + + {(childrenMap["__root"] || []).map(f => ( + + ))} + + {folders.length === 0 && ( + + + {t("sidebar.noFoldersYet")} + + + )} + {!!folders.length && visibleFolderIds?.size === 0 && ( + + + {t("sidebar.noMatchingFolders")} + + + )} + + + setMenuState(null)} + onSubfolder={folder => startCreate(folder.id)} + onRename={startEdit} + onDownload={downloadFolder} + onDelete={folder => remove(folder.id)} + /> + + {creating && ( + } + title={t("sidebar.createFolderTitle")} + subtitle={t("sidebar.createFolderSubtitle")} + name={newName} + onNameChange={setNewName} + namePlaceholder={t("sidebar.folderNamePlaceholder") ?? undefined} + parentId={newParent} + onParentChange={setNewParent} + folderOptions={folderOptions} + busy={busy} + submitLabel={t("sidebar.create")} + submitDisabled={!newName.trim()} + onSubmit={create} + onCancel={() => { setCreating(false); setNewParent(null); }} + /> + )} + + {editing && ( + } + title={t("sidebar.editFolderTitle")} + subtitle={t("sidebar.editFolderSubtitle")} + name={editName} + onNameChange={setEditName} + parentId={editParent} + onParentChange={setEditParent} + folderOptions={editFolderOptions} + tags={editTags} + onTagsChange={setEditTags} + tagsPlaceholder={t("sidebar.addFolderTagsPlaceholder") ?? undefined} + busy={busy} + submitLabel={t("sidebar.saveChanges")} + onSubmit={saveEdit} + onCancel={() => { setEditing(null); setEditTags([]); setEditParent(null); }} + /> + )} + + + + + + + + + + {zipPrompt.modal} + {importModePrompt.modal} + + ); +} diff --git a/frontend/src/routes/library/components/UploadBar.tsx b/frontend/src/routes/library/components/UploadBar.tsx new file mode 100644 index 0000000..cf185fb --- /dev/null +++ b/frontend/src/routes/library/components/UploadBar.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload"; +import LinkIcon from "@mui/icons-material/Link"; +import { + UnauthorizedError, + importFromLink, + importZipFromLink, + inspectImportLink, + listImportZipEntries, + uploadPrints, +} from "../../../services/api"; +import { entriesFromFileList, uploadEntriesToFolder } from "../../../services/uploadTree"; +import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../../services/zipUtils"; +import { useZipImportPrompt } from "./ZipImportModal"; +import { useImportModePrompt, type ImportMode } from "./ImportModeModal"; + +// Every dropped/picked entry's relativePath equals its bare filename when the +// selection has no folder structure. A webkitdirectory folder pick always +// prefixes relativePath with the folder name, so this only ever fires for a +// flat multi-file picker selection. +function isFlatFileSet(entries: { file: File; relativePath: string }[]) { + return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name); +} + +type Props = { + onUploaded: () => void; + folderId?: string | null; + makerworldCookie?: string | null; + thingiverseCookie?: string | null; + onUnauthorized?: () => void; +}; + +export default function UploadBar({ onUploaded, folderId, makerworldCookie, thingiverseCookie, onUnauthorized }: Props) { + const { t } = useTranslation("app"); + const inputRef = useRef(null); + const folderInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [importing, setImporting] = useState(false); + const [linkValue, setLinkValue] = useState(""); + const zipPrompt = useZipImportPrompt(); + const importModePrompt = useImportModePrompt(); + const isBusy = uploading || importing || zipPrompt.isOpen || importModePrompt.isOpen; + + const uploadFlatAsMultiplate = async (files: File[]) => { + try { + const result = await uploadPrints(files, { folder_id: folderId || undefined, mode: "multiplate" }); + return { uploaded: result.prints.length, failed: [] as string[] }; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return { uploaded: 0, failed: [] as string[] }; + } + const message = err instanceof Error ? err.message.trim() : ""; + return { + uploaded: 0, + failed: [ + message + ? t("uploadBar.multiplateImportFailedWithMessage", { message }) + : t("uploadBar.multiplateImportFailed"), + ], + }; + } + }; + + useEffect(() => { + if (!folderInputRef.current) return; + folderInputRef.current.setAttribute("webkitdirectory", ""); + folderInputRef.current.setAttribute("directory", ""); + }, []); + + const uploadEntries = async (entries: ReturnType) => { + if (!entries.length) return; + setUploading(true); + const normalEntries = entries.filter(entry => !isZipFile(entry.file.name)); + const zipEntries = entries.filter(entry => isZipFile(entry.file.name)); + let uploaded = 0; + const failed: string[] = []; + const applyResult = (result: { uploaded: number; failed: string[] }) => { + uploaded += result.uploaded; + failed.push(...result.failed); + }; + if (normalEntries.length) { + if (isFlatFileSet(normalEntries)) { + await importModePrompt.prompt({ + label: normalEntries.map(entry => entry.file.name).join(", "), + count: normalEntries.length, + onChoose: async (mode: ImportMode) => { + if (mode === "multiplate") { + applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file))); + } else { + applyResult(await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized)); + } + }, + }); + } else { + const result = await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized); + applyResult(result); + } + } + for (const entry of zipEntries) { + let zipData: Record | null = null; + const baseParts = entry.relativePath.split("/").filter(Boolean); + baseParts.pop(); + const basePath = baseParts.join("/"); + await zipPrompt.prompt({ + label: entry.file.name, + onImportAsZip: async () => { + const result = await uploadEntriesToFolder([entry], folderId || null, onUnauthorized); + applyResult(result); + }, + loadEntries: async () => { + const result = await readZipEntries(entry.file); + zipData = result.data; + return result.entries; + }, + onImportSelected: async (selectedPaths: string[]) => { + if (!zipData) { + const result = await readZipEntries(entry.file); + zipData = result.data; + } + const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath); + const result = await uploadEntriesToFolder(unzipEntries, folderId || null, onUnauthorized); + applyResult(result); + }, + }); + } + if (uploaded) onUploaded(); + if (failed.length) { + alert(t("uploadBar.uploadFailed", { files: failed.join(", ") })); + } + setUploading(false); + }; + + const onPick = async (e: React.ChangeEvent) => { + const entries = entriesFromFileList(e.target.files || []); + if (!entries.length) return; + await uploadEntries(entries); + if (inputRef.current) inputRef.current.value = ""; + }; + + const onPickFolder = async (e: React.ChangeEvent) => { + const entries = entriesFromFileList(e.target.files || []); + if (!entries.length) return; + await uploadEntries(entries); + if (folderInputRef.current) folderInputRef.current.value = ""; + }; + + const onImport = async () => { + const url = linkValue.trim(); + if (!url) return; + setImporting(true); + try { + const cookie = (makerworldCookie || "").trim(); + const thingiverse = (thingiverseCookie || "").trim(); + const payload = { + url, + folder_id: folderId || undefined, + makerworld_cookie: cookie || undefined, + thingiverse_cookie: thingiverse || undefined, + }; + const inspect = await inspectImportLink(payload); + if (!inspect.is_zip) { + await importFromLink(payload); + setLinkValue(""); + onUploaded(); + return; + } + setImporting(false); + await zipPrompt.prompt({ + label: inspect.filename, + onImportAsZip: async () => { + try { + await importFromLink(payload); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + throw err; + } + setLinkValue(""); + onUploaded(); + }, + loadEntries: async () => { + try { + const result = await listImportZipEntries(payload); + return result.entries; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + } + throw err; + } + }, + onImportSelected: async (entries: string[]) => { + try { + const result = await importZipFromLink({ ...payload, entries }); + if (result.failed.length) { + alert(t("uploadBar.importFailedList", { files: result.failed.join(", ") })); + } + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + throw err; + } + setLinkValue(""); + onUploaded(); + }, + }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + console.error("Import failed for", url, err); + const message = err instanceof Error ? err.message : t("uploadBar.importFailed"); + alert(message); + } finally { + setImporting(false); + } + }; + + return ( + <> + + + + + + + setLinkValue(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + onImport(); + } + }} + placeholder={t("uploadBar.linkPlaceholder") ?? undefined} + disabled={isBusy} + sx={{ width: 320 }} + /> + + + + {zipPrompt.modal} + {importModePrompt.modal} + + ); +} diff --git a/frontend/src/routes/library/components/ZipImportModal/components/ZipEntryList.tsx b/frontend/src/routes/library/components/ZipImportModal/components/ZipEntryList.tsx new file mode 100644 index 0000000..9eb8a4b --- /dev/null +++ b/frontend/src/routes/library/components/ZipImportModal/components/ZipEntryList.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Stack from "@mui/material/Stack"; +import { formatFileSize } from "../../../../../utils/fileSize"; +import type { ZipEntry } from "../../../../../services/zipUtils"; + +type Props = { + entries: ZipEntry[]; + selected: Set; + busy: boolean; + noFilesLabel: string; + onToggleEntry: (path: string) => void; +}; + +/** The scrollable checkbox list shown in the zip import wizard's "select files" step. */ +export default function ZipEntryList({ entries, selected, busy, noFilesLabel, onToggleEntry }: Props) { + return ( + + {entries.map(entry => ( + onToggleEntry(entry.path)} + disabled={busy} + size="small" + /> + } + label={ + + + {entry.path} + + + {formatFileSize(entry.size)} + + + } + /> + ))} + {entries.length === 0 && ( + + + {noFilesLabel} + + + )} + + ); +} diff --git a/frontend/src/routes/library/components/ZipImportModal/index.tsx b/frontend/src/routes/library/components/ZipImportModal/index.tsx new file mode 100644 index 0000000..e36d963 --- /dev/null +++ b/frontend/src/routes/library/components/ZipImportModal/index.tsx @@ -0,0 +1,273 @@ +import React, { useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Alert from "@mui/material/Alert"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; +import type { ZipEntry } from "../../../../services/zipUtils"; +import ZipEntryList from "./components/ZipEntryList"; + +type ZipPromptConfig = { + label: string; + onImportAsZip: () => Promise; + loadEntries: () => Promise; + onImportSelected: (entries: string[]) => Promise; +}; + +type ZipPromptState = { + config: ZipPromptConfig; +}; + +type PromptStage = "choice" | "select"; + +function errorMessage(err: unknown, fallback: string) { + if (err instanceof Error && err.message.trim()) return err.message; + return fallback; +} + +export function useZipImportPrompt() { + const { t } = useTranslation("app"); + const [state, setState] = useState(null); + const [stage, setStage] = useState("choice"); + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const resolveRef = useRef<(() => void) | null>(null); + + const reset = () => { + setStage("choice"); + setEntries([]); + setSelected(new Set()); + setBusy(false); + setError(null); + }; + + const close = () => { + setState(null); + reset(); + if (resolveRef.current) { + resolveRef.current(); + resolveRef.current = null; + } + }; + + const prompt = (config: ZipPromptConfig) => { + if (state) { + return Promise.resolve(); + } + reset(); + setState({ config }); + return new Promise(resolve => { + resolveRef.current = resolve; + }); + }; + + const loadEntries = async () => { + if (!state) return; + setBusy(true); + setError(null); + try { + const list = await state.config.loadEntries(); + setEntries(list); + setSelected(new Set(list.map(entry => entry.path))); + setStage("select"); + } catch (err) { + setError(errorMessage(err, t("zipImport.readError"))); + } finally { + setBusy(false); + } + }; + + const importAsZip = async () => { + if (!state) return; + setBusy(true); + setError(null); + try { + await state.config.onImportAsZip(); + close(); + } catch (err) { + setError(errorMessage(err, t("zipImport.importError"))); + setBusy(false); + } + }; + + const importSelected = async () => { + if (!state) return; + const selectedEntries = Array.from(selected); + if (!selectedEntries.length) { + setError(t("zipImport.selectAtLeastOne")); + return; + } + setBusy(true); + setError(null); + try { + await state.config.onImportSelected(selectedEntries); + close(); + } catch (err) { + setError(errorMessage(err, t("zipImport.importError"))); + setBusy(false); + } + }; + + const toggleEntry = (path: string) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const selectAll = () => { + setSelected(new Set(entries.map(entry => entry.path))); + }; + + const clearAll = () => { + setSelected(new Set()); + }; + + const selectedCount = selected.size; + const allSelected = entries.length > 0 && selectedCount === entries.length; + + const modal = state ? ( + + ) : null; + + return { prompt, modal, isOpen: Boolean(state) }; +} + +function ZipImportModal({ + label, + stage, + entries, + selected, + selectedCount, + allSelected, + busy, + error, + onClose, + onImportAsZip, + onLoadEntries, + onImportSelected, + onToggleEntry, + onSelectAll, + onClearAll, +}: { + label: string; + stage: PromptStage; + entries: ZipEntry[]; + selected: Set; + selectedCount: number; + allSelected: boolean; + busy: boolean; + error: string | null; + onClose: () => void; + onImportAsZip: () => void; + onLoadEntries: () => void; + onImportSelected: () => void; + onToggleEntry: (path: string) => void; + onSelectAll: () => void; + onClearAll: () => void; +}) { + const { t } = useTranslation("app"); + const entryCount = entries.length; + const title = useMemo( + () => (stage === "choice" ? t("zipImport.title") : t("zipImport.chooseFilesTitle")), + [stage, t] + ); + + return ( + + + + {title} + {label} + + + + + + + + {stage === "choice" && ( + + {t("zipImport.prompt")} + + + + + + )} + + {stage === "select" && ( + + + + {t("zipImport.selectedCount", { selected: selectedCount, total: entryCount })} + + + + + + + + + + + + + + + )} + + {error && {error}} + + + + ); +} diff --git a/frontend/src/routes/library/components/renderPreviewContent.tsx b/frontend/src/routes/library/components/renderPreviewContent.tsx new file mode 100644 index 0000000..7213db7 --- /dev/null +++ b/frontend/src/routes/library/components/renderPreviewContent.tsx @@ -0,0 +1,103 @@ +import type { TFunction } from "i18next"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { type Print, type Plate, fileUrl } from "../../../services/api"; +import { type PreviewSettings } from "../../../services/settings"; +import { type ResolvedTheme } from "../../../constants/settingsOptions"; +import { MODEL_EXTS, LIGHTBURN_EXTS } from "../../../constants/fileTypes"; +import { extOf } from "../../../utils/fileExtensions"; +import ModelViewer from "./ModelViewer"; +import { ModelSnapshot } from "./ModelViewer/components/ModelSnapshot"; +import LightBurnPreview from "./LightBurnPreview"; + +export type PreviewVariant = "card" | "modal"; + +function PreviewPlaceholder({ text }: { text: string }) { + return ( + + {text} + + ); +} + +// Shared by PrintCard (card thumbnail, variant "card") and PrintPreviewModal (full detail view, +// variant "modal") -- picks the right preview widget (raster image, ModelViewer/ModelSnapshot for +// 3D, LightBurnPreview, or a placeholder) for a print's active plate. +export function renderPreviewContent( + print: Print, + variant: PreviewVariant, + theme: ResolvedTheme, + t: TFunction, + previewMode: PreviewSettings["mode"] = "automatic", + activePlate?: Plate +) { + const plate = variant === "modal" ? (activePlate || print.plates[0]) : print.plates[0]; + const imgSx = variant === "card" + ? { width: "100%", height: "100%", objectFit: "cover" as const } + : { width: "100%", height: "100%", objectFit: "contain" as const, bgcolor: "action.hover" }; + + if (!plate) { + return ; + } + + const ext = extOf(plate.filename); + const plateUrl = fileUrl(plate.url); + const thumbUrl = variant === "card" + ? (print.thumb_url ? fileUrl(print.thumb_url) : null) + : (plate.thumb_url ? fileUrl(plate.thumb_url) : null); + const is3d = MODEL_EXTS.has(ext); + const isLightBurn = LIGHTBURN_EXTS.has(ext); + + if (variant === "card") { + if (thumbUrl) { + return ; + } + if (ext === "svg") { + return ; + } + if (is3d) { + if (previewMode === "disabled") { + return ; + } + return ; + } + if (isLightBurn) { + return ( + + ); + } + return ; + } + + if (is3d) { + return ( + + ); + } + if (thumbUrl || ext === "svg") { + const src = thumbUrl || plateUrl; + return ; + } + if (isLightBurn) { + return ( + + ); + } + return ; +} diff --git a/frontend/src/routes/library/index.tsx b/frontend/src/routes/library/index.tsx new file mode 100644 index 0000000..6f84ddc --- /dev/null +++ b/frontend/src/routes/library/index.tsx @@ -0,0 +1,953 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import FormControl from "@mui/material/FormControl"; +import Select, { type SelectChangeEvent } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import CircularProgress from "@mui/material/CircularProgress"; +import ButtonBase from "@mui/material/ButtonBase"; +import SearchIcon from "@mui/icons-material/Search"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { + Print, + Folder, + UnauthorizedError, + deletePrint, + fileUrl, + listPrints, + listTags, + listFolders, + setTags, + updatePrintFolder, + updatePrintMeta, + downloadZip, + uploadPrints, +} from "../../services/api"; +import PrintCard from "./components/PrintCard"; +import PrintPreviewModal from "./components/PrintPreviewModal"; +import { colorForTag } from "../../utils/tagColors"; +import { EngravingSettings, PreviewSettings, SlicerSettings } from "../../services/settings"; +import { type ResolvedTheme } from "../../constants/settingsOptions"; +import { engraverLabelFor, slicerLabelFor } from "../../utils/settingsHelpers"; +import { SLICER_BRIDGE_ENABLED } from "../../constants/featureFlags"; +import { entriesFromDataTransfer, uploadEntriesToFolder } from "../../services/uploadTree"; +import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../services/zipUtils"; +import { useZipImportPrompt } from "./components/ZipImportModal"; +import { useImportModePrompt, type ImportMode } from "./components/ImportModeModal"; +import { extOf } from "../../utils/fileExtensions"; + +const ROWS_PER_BATCH = 5; +const CARD_MIN_WIDTH_PX = 260; +const GRID_GAP_PX = 16; + +function rowsBatchSizeForWidth(width: number) { + const cols = Math.max(1, Math.floor((width + GRID_GAP_PX) / (CARD_MIN_WIDTH_PX + GRID_GAP_PX))); + return cols * ROWS_PER_BATCH; +} + +// Every dropped/picked entry's relativePath equals its bare filename when the +// selection has no folder structure -- that's the signal for the +// separate-vs-multiplate prompt (a dropped folder, or the webkitdirectory +// picker, always carries nested relativePaths and skips this prompt). +function isFlatFileSet(entries: { file: File; relativePath: string }[]) { + return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name); +} + +type Props = { + folderId?: string | null; + foldersVersion?: number; + onUnauthorized?: () => void; + slicerSettings?: SlicerSettings; + engravingSettings?: EngravingSettings; + previewSettings?: PreviewSettings; + theme: ResolvedTheme; +}; + +type RefreshOpts = { tags?: string[]; search?: string }; +type GroupBucket = { id: string; title: string; items: Print[] }; + +export default function PrintGrid({ + folderId, + foldersVersion = 0, + onUnauthorized, + slicerSettings, + engravingSettings, + previewSettings, + theme, +}: Props) { + const { t, i18n } = useTranslation(["library", "common"]); + const [items, setItems] = useState([]); + const [folders, setFolders] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [q, setQ] = useState(""); + const [activeTags, setActiveTags] = useState([]); + const [allTags, setAllTags] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [previewItem, setPreviewItem] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [movingId, setMovingId] = useState(null); + const [sortKey, setSortKey] = useState<"name" | "size" | "type" | "folder">("name"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkDownloading, setBulkDownloading] = useState(null); + const [pageSize] = useState(() => { + if (typeof window === "undefined") return ROWS_PER_BATCH; + return rowsBatchSizeForWidth(window.innerWidth); + }); + const dragDepth = useRef(0); + const [dragActive, setDragActive] = useState(false); + const [dropUploading, setDropUploading] = useState(false); + const slicerEnabled = SLICER_BRIDGE_ENABLED && Boolean(slicerSettings?.enabled); + const slicerLabel = slicerLabelFor(slicerSettings?.selected); + const engravingEnabled = Boolean(engravingSettings?.enabled); + const engraverLabel = engraverLabelFor(engravingSettings?.selected); + const zipPrompt = useZipImportPrompt(); + const importModePrompt = useImportModePrompt(); + + const handleApiError = (err: unknown, message?: string) => { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return true; + } + console.error(err); + if (message) alert(message); + return false; + }; + + const replacePrint = (updated: Print) => { + setItems(current => current.map(item => item.id === updated.id ? updated : item)); + setPreviewItem(current => current?.id === updated.id ? updated : current); + }; + + const refresh = async (opts: RefreshOpts = {}) => { + setLoading(true); + setHasMore(false); + setOffset(0); + const search = opts.search ?? q; + const tags = opts.tags ?? activeTags; + const targetFolderId = folderId || undefined; + const [printsResult, tagsResult] = await Promise.allSettled([ + listPrints({ + q: search, + tags, + folder_id: targetFolderId, + limit: pageSize, + offset: 0, + }), + listTags({ + q: search, + folder_id: targetFolderId, + }), + ]); + + if (printsResult.status === "fulfilled") { + setItems(printsResult.value.items); + setOffset(printsResult.value.items.length); + setHasMore(printsResult.value.hasMore); + } else { + handleApiError(printsResult.reason, t("library:errors.loadPrintsFailed")); + } + + if (tagsResult.status === "fulfilled") { + setAllTags(tagsResult.value); + } else { + setAllTags([]); + handleApiError(tagsResult.reason); + } + + setLoading(false); + }; + + const loadMore = async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const data = await listPrints({ + q, + tags: activeTags, + folder_id: folderId || undefined, + limit: pageSize, + offset, + }); + setItems(prev => [...prev, ...data.items]); + setOffset(offset + data.items.length); + setHasMore(data.hasMore); + } catch (err) { + handleApiError(err, t("library:errors.loadMoreFailed")); + } finally { + setLoadingMore(false); + } + }; + + const isFileDrag = (e: React.DragEvent) => { + const types = Array.from(e.dataTransfer?.types || []); + return types.includes("Files"); + }; + + const handleDragEnter = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragDepth.current += 1; + setDragActive(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragDepth.current -= 1; + if (dragDepth.current <= 0) { + dragDepth.current = 0; + setDragActive(false); + } + }; + + const uploadFlatAsMultiplate = async (files: File[], targetFolderId: string | null) => { + try { + const result = await uploadPrints(files, { folder_id: targetFolderId || undefined, mode: "multiplate" }); + return { uploaded: result.prints.length, failed: [] as string[] }; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return { uploaded: 0, failed: [] as string[] }; + } + const message = err instanceof Error ? err.message.trim() : ""; + const failure = message + ? t("library:errors.multiplateImportFailedWithMessage", { message }) + : t("library:errors.multiplateImportFailed"); + return { uploaded: 0, failed: [failure] }; + } + }; + + const handleDrop = async (e: React.DragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragDepth.current = 0; + setDragActive(false); + setDropUploading(true); + const entries = await entriesFromDataTransfer(e.dataTransfer); + if (!entries.length) { + setDropUploading(false); + return; + } + const normalEntries = entries.filter(entry => !isZipFile(entry.file.name)); + const zipEntries = entries.filter(entry => isZipFile(entry.file.name)); + let uploaded = 0; + const failed: string[] = []; + const applyResult = (result: { uploaded: number; failed: string[] }) => { + uploaded += result.uploaded; + failed.push(...result.failed); + }; + if (normalEntries.length) { + if (isFlatFileSet(normalEntries)) { + await importModePrompt.prompt({ + label: normalEntries.map(entry => entry.file.name).join(", "), + count: normalEntries.length, + onChoose: async (mode: ImportMode) => { + if (mode === "multiplate") { + applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file), folderId || null)); + } else { + applyResult(await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized)); + } + }, + }); + } else { + const result = await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized); + applyResult(result); + } + } + for (const entry of zipEntries) { + let zipData: Record | null = null; + const baseParts = entry.relativePath.split("/").filter(Boolean); + baseParts.pop(); + const basePath = baseParts.join("/"); + await zipPrompt.prompt({ + label: entry.file.name, + onImportAsZip: async () => { + const result = await uploadEntriesToFolder([entry], folderId || null, onUnauthorized); + applyResult(result); + }, + loadEntries: async () => { + const result = await readZipEntries(entry.file); + zipData = result.data; + return result.entries; + }, + onImportSelected: async (selectedPaths: string[]) => { + if (!zipData) { + const result = await readZipEntries(entry.file); + zipData = result.data; + } + const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath); + const result = await uploadEntriesToFolder(unzipEntries, folderId || null, onUnauthorized); + applyResult(result); + }, + }); + } + if (uploaded) { + await refresh(); + } + if (failed.length) { + alert(t("library:errors.uploadFailed", { items: failed.join(", ") })); + } + setDropUploading(false); + }; + + useEffect(() => { refresh(); }, [folderId]); + useEffect(() => { + (async () => { + try { + setFolders(await listFolders()); + } catch (err) { + handleApiError(err, t("library:errors.loadFoldersFailed")); + } + })(); + }, [foldersVersion]); + + useEffect(() => { + const present = new Set(items.map(i => i.id)); + setSelectedIds(prev => new Set([...prev].filter(id => present.has(id)))); + }, [items]); + + const itemById = useMemo(() => { + const map: Record = {}; + items.forEach(it => { map[it.id] = it; }); + return map; + }, [items]); + + const folderById = useMemo(() => { + const map: Record = {}; + folders.forEach(f => { map[f.id] = f; }); + return map; + }, [folders]); + + const dropTargetName = useMemo(() => { + if (!folderId) return t("library:grid.allItems"); + return folderById[folderId]?.name || t("library:grid.thisFolder"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folderId, folderById, i18n.language]); + + const folderNames = useMemo(() => { + const m: Record = {}; + const untitled = t("library:grid.groupUntitled"); + const pathFor = (f: Folder): string => { + const parts = [f.name || untitled]; + let current = f; + const guard = new Set([f.id]); + while (current.parent_id) { + const parent = folderById[current.parent_id]; + if (!parent || guard.has(parent.id)) break; + parts.unshift(parent.name || untitled); + guard.add(parent.id); + current = parent; + } + return parts.join(" / "); + }; + for (const f of folders) { + m[f.id] = pathFor(f); + } + return m; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folders, folderById, i18n.language]); + + const folderOptions = useMemo(() => { + return [ + { id: null as string | null, name: t("common:unassigned") }, + ...folders + .map(f => ({ id: f.id, name: folderNames[f.id] || f.name || t("library:grid.groupUntitled") })) + .sort((a, b) => a.name.localeCompare(b.name)), + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folders, folderNames, i18n.language]); + + const totalSize = (print: Print) => print.plates.reduce((sum, plate) => sum + (plate.size || 0), 0); + const primaryExt = (print: Print) => extOf(print.plates[0]?.filename || ""); + + const sortedItems = useMemo(() => { + const copy = [...items]; + const dir = sortDir === "asc" ? 1 : -1; + const nameForFolder = (a: Print) => (a.folder_id ? folderNames[a.folder_id] || "" : ""); + copy.sort((a, b) => { + if (sortKey === "size") { + return (totalSize(a) - totalSize(b)) * dir; + } + if (sortKey === "type") { + return primaryExt(a).localeCompare(primaryExt(b)) * dir; + } + if (sortKey === "folder") { + return nameForFolder(a).localeCompare(nameForFolder(b)) * dir; + } + // default name + return a.name.localeCompare(b.name) * dir; + }); + return copy; + }, [items, sortKey, sortDir, folderNames]); + + const folderGroups = useMemo(() => { + if (sortKey !== "folder") return []; + const grouping: Record = {}; + for (const item of items) { + const key = item.folder_id || "__ungrouped"; + if (!grouping[key]) { + grouping[key] = { + id: `folder:${key}`, + title: item.folder_id ? folderNames[item.folder_id] || t("library:grid.groupUntitled") : t("library:grid.groupUngrouped"), + items: [], + }; + } + grouping[key].items.push(item); + } + const dir = sortDir === "asc" ? 1 : -1; + return Object.values(grouping) + .sort((a, b) => a.title.localeCompare(b.title) * dir) + .map(group => ({ + ...group, + items: group.items.sort((a, b) => a.name.localeCompare(b.name)), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, folderNames, sortKey, sortDir, i18n.language]); + + const typeGroups = useMemo(() => { + if (sortKey !== "type") return []; + const grouping: Record = {}; + for (const item of items) { + const ext = primaryExt(item) || "other"; + if (!grouping[ext]) { + grouping[ext] = { + id: `type:${ext}`, + title: ext === "other" ? t("library:grid.groupOther") : ext.toUpperCase(), + items: [], + }; + } + grouping[ext].items.push(item); + } + const dir = sortDir === "asc" ? 1 : -1; + return Object.values(grouping) + .sort((a, b) => a.title.localeCompare(b.title) * dir) + .map(group => ({ + ...group, + items: group.items.sort((a, b) => a.name.localeCompare(b.name)), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, sortKey, sortDir, i18n.language]); + + const toggleGroup = (id: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleTag = (tag: string) => { + setActiveTags(prev => { + const next = prev.includes(tag) ? prev.filter(x => x !== tag) : [...prev, tag]; + refresh({ tags: next }); + return next; + }); + }; + + const toggleSelected = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const filenameFromDisposition = (res: Response, fallback: string) => { + const dispo = res.headers.get("content-disposition") || ""; + const match = dispo.match(/filename="?([^";]+)"?/i); + return (match && match[1]) || fallback || "download"; + }; + + const saveResponseToDisk = async (res: Response, fallback: string) => { + const filename = filenameFromDisposition(res, fallback); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + }; + + const onSaveTags = async (id: string, tags: string[]) => { + const bulkEdit = selectedIds.has(id) && selectedIds.size > 1; + const targets = bulkEdit ? Array.from(selectedIds) : [id]; + const failed: string[] = []; + let aborted = false; + for (const targetId of targets) { + try { + await setTags(targetId, tags); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + aborted = true; + break; + } + console.error(err); + failed.push(itemById[targetId]?.name || targetId); + } + } + if (!aborted) { + await refresh(); + if (bulkEdit) { + setSelectedIds(new Set()); + } + } + if (failed.length) { + alert(t("library:errors.tagUpdateFailed", { items: failed.join(", ") })); + } + }; + + const onSaveDetails = async (id: string, payload: { notes: string; creator: string; collection: string }) => { + try { + await updatePrintMeta(id, payload); + await refresh(); + } catch (err) { + handleApiError(err, err instanceof Error ? err.message : t("library:errors.saveDetailsFailed")); + } + }; + + const onRename = async (id: string, name: string) => { + try { + await updatePrintMeta(id, { name }); + await refresh(); + } catch (err) { + handleApiError(err, err instanceof Error ? err.message : t("library:errors.renameFailed")); + throw err; + } + }; + + const onMoveFolder = async (id: string, folder_id: string | null) => { + try { + setMovingId(id); + await updatePrintFolder(id, folder_id); + await refresh(); + } catch (err) { + if (!handleApiError(err)) { + alert(err instanceof Error ? err.message : t("library:errors.folderUpdateFailed")); + } + } finally { + setMovingId(null); + } + }; + + const downloadPrint = async (print: Print) => { + try { + setDownloadingId(print.id); + if (print.plates.length > 1) { + const res = await downloadZip({ print_ids: [print.id] }); + await saveResponseToDisk(res, `${print.name || "print"}.zip`); + return; + } + const plate = print.plates[0]; + if (!plate) throw new Error("Download failed"); + const res = await fetch(fileUrl(plate.url)); + if (res.status === 401) { + onUnauthorized?.(); + throw new Error("Unauthorized"); + } + if (!res.ok) throw new Error("Download failed"); + await saveResponseToDisk(res, plate.filename || "download"); + } catch (err) { + if (!handleApiError(err)) { + console.error(err); + alert(t("library:errors.downloadFailed")); + } + } finally { + setDownloadingId(null); + } + }; + + const openInSlicer = (print: Print) => { + if (!SLICER_BRIDGE_ENABLED || !slicerEnabled) return; + const plate = print.plates[0]; + const url = fileUrl(print.slicer_url || plate?.url || ""); + const params = new URLSearchParams({ + url, + slicer: slicerSettings?.selected || "orca", + filename: print.slicer_filename || plate?.filename || "model", + }); + const target = `printstash-slicer://open?${params.toString()}`; + window.location.href = target; + }; + + const openInEngraving = (print: Print) => { + if (!engravingEnabled) return; + const plate = print.plates[0]; + if (!plate) return; + const url = fileUrl(plate.url); + const params = new URLSearchParams({ + url, + engraver: engravingSettings?.selected || "lightburn", + filename: plate.filename || "design", + }); + const target = `printstash-engrave://open?${params.toString()}`; + window.location.href = target; + }; + + const downloadSelected = async () => { + if (!selectedIds.size) { + alert(t("library:errors.selectAtLeastOne")); + return; + } + try { + setBulkDownloading("selected"); + const res = await downloadZip({ print_ids: Array.from(selectedIds) }); + await saveResponseToDisk(res, "printstash-selected.zip"); + } catch (err) { + if (!handleApiError(err, t("library:errors.bulkDownloadFailed"))) { + console.error(err); + } + } finally { + setBulkDownloading(null); + } + }; + + const downloadByTag = async (tag: string) => { + if (!tag) return; + try { + setBulkDownloading(`tag:${tag}`); + const res = await downloadZip({ tag }); + await saveResponseToDisk(res, `${tag}.zip`); + } catch (err) { + if (!handleApiError(err, t("library:errors.downloadByTagFailed"))) { + console.error(err); + } + } finally { + setBulkDownloading(null); + } + }; + + const removePrint = async (print: Print) => { + const targets = selectedIds.has(print.id) && selectedIds.size > 1 + ? Array.from(selectedIds) + : [print.id]; + const confirmLabel = targets.length > 1 + ? t("library:confirm.deleteMultiple", { count: targets.length }) + : t("library:confirm.deleteSingle", { name: print.title || print.name }); + if (!confirm(confirmLabel)) { + return; + } + setDeletingId(print.id); + const failed: string[] = []; + let aborted = false; + for (const targetId of targets) { + try { + await deletePrint(targetId); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + aborted = true; + break; + } + console.error(err); + failed.push(itemById[targetId]?.name || targetId); + } + } + if (!aborted) { + await refresh(); + } + if (failed.length) { + alert(t("library:errors.deleteFailed", { items: failed.join(", ") })); + } + setDeletingId(null); + }; + + const showDropOverlay = dragActive || dropUploading; + const dropMessage = dropUploading + ? t("library:dropOverlay.uploading", { target: dropTargetName }) + : t("library:dropOverlay.prompt", { target: dropTargetName }); + + const sortOptions: { value: typeof sortKey; label: string }[] = [ + { value: "name", label: t("library:grid.sortOptions.name") }, + { value: "size", label: t("library:grid.sortOptions.size") }, + { value: "type", label: t("library:grid.sortOptions.type") }, + { value: "folder", label: t("library:grid.sortOptions.folder") }, + ]; + + return ( + + {showDropOverlay && ( + + + {dropMessage} + + + )} + + + setQ(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + const val = (e.currentTarget as HTMLInputElement).value; + refresh({ search: val }); + } + }} + placeholder={t("library:grid.searchPlaceholder")} + sx={{ width: 320 }} + /> + + {loading && ( + + + {t("library:grid.loading")} + + )} + + {t("library:grid.sortLabel")} + + + + + + + + {!!allTags.length && ( + + {allTags.map(tg => { + const colors = colorForTag(tg); + const active = activeTags.includes(tg); + return ( + toggleTag(tg)} + sx={{ + bgcolor: active ? colors.bg : "transparent", + color: colors.text, + border: "1px solid", + borderColor: colors.border, + fontWeight: active ? 600 : 400, + }} + /> + ); + })} + {activeTags.length > 0 && ( + { setActiveTags([]); refresh({ tags: [] }); }} + /> + )} + + )} + + {["folder", "type"].includes(sortKey) ? ( + + {(sortKey === "folder" ? folderGroups : typeGroups).map(group => { + const collapsed = collapsedGroups.has(group.id); + return ( + + toggleGroup(group.id)} + sx={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "space-between", px: 2, py: 1.5, textAlign: "left" }} + > + + {group.title} + + {t("library:grid.groupItemCount", { count: group.items.length })} + + + {collapsed ? : } + + {!collapsed && ( + + + {group.items.map(it => ( + + toggleSelected(it.id)} + hasSelection={selectedIds.size > 0} + bulkDownloading={Boolean(bulkDownloading)} + slicerEnabled={slicerEnabled} + slicerLabel={slicerLabel} + onOpenInSlicer={openInSlicer} + engravingEnabled={engravingEnabled} + engraverLabel={engraverLabel} + onOpenInEngraving={openInEngraving} + theme={theme} + previewMode={previewSettings?.mode || "automatic"} + onPrintChanged={replacePrint} + onUnauthorized={onUnauthorized} + /> + + ))} + {!group.items.length && ( + + {t("library:grid.groupEmpty")} + + )} + + + )} + + ); + })} + + ) : ( + + {sortedItems.map(it => ( + toggleSelected(it.id)} + hasSelection={selectedIds.size > 0} + bulkDownloading={Boolean(bulkDownloading)} + slicerEnabled={slicerEnabled} + slicerLabel={slicerLabel} + onOpenInSlicer={openInSlicer} + engravingEnabled={engravingEnabled} + engraverLabel={engraverLabel} + onOpenInEngraving={openInEngraving} + theme={theme} + previewMode={previewSettings?.mode || "automatic"} + onPrintChanged={replacePrint} + onUnauthorized={onUnauthorized} + /> + ))} + + )} + {hasMore && ( + + + + )} + {previewItem && ( + setPreviewItem(null)} + onPrintChanged={replacePrint} + onUnauthorized={onUnauthorized} + /> + )} + {zipPrompt.modal} + {importModePrompt.modal} + + + ); +} diff --git a/frontend/src/routes/login/index.tsx b/frontend/src/routes/login/index.tsx new file mode 100644 index 0000000..5ae3bc4 --- /dev/null +++ b/frontend/src/routes/login/index.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Alert from "@mui/material/Alert"; +import { login } from "../../services/api"; +import { type ResolvedTheme } from "../../constants/settingsOptions"; +import Wordmark from "../../common/Wordmark"; + +type Props = { + onSuccess: (token: string, expires_in: number) => void; + apiUp: boolean | null; + theme: ResolvedTheme; +}; + +export default function Login({ onSuccess, apiUp, theme }: Props) { + const { t } = useTranslation("app"); + const [username, setUsername] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await login(username, password); + onSuccess(res.token, res.expires_in); + } catch (err) { + console.error(err); + if (err instanceof Error) { + setError(err.message); + } else { + setError(t("login.loginFailed")); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + {t("login.subtitle")} + + + {apiUp === false && ( + + {t("login.apiOffline")} + + )} + {error && ( + + {error} + + )} + + + setUsername(e.target.value)} + autoComplete="username" + required + fullWidth + size="small" + /> + setPassword(e.target.value)} + autoComplete="current-password" + required + fullWidth + size="small" + /> + + + + + ); +} diff --git a/frontend/src/routes/settings/components/EngravingSection.tsx b/frontend/src/routes/settings/components/EngravingSection.tsx new file mode 100644 index 0000000..2bb0952 --- /dev/null +++ b/frontend/src/routes/settings/components/EngravingSection.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Link from "@mui/material/Link"; +import { ENGRAVER_OPTIONS } from "../../../constants/settingsOptions"; +import type { EngravingSettings } from "../../../services/settings"; +import SectionHeader from "./SectionHeader"; + +type Props = { + engraving: EngravingSettings; + onUpdate: (patch: Partial) => void; + onBack: () => void; +}; + +export default function EngravingSection({ engraving, onUpdate, onBack }: Props) { + const { t } = useTranslation("app"); + return ( + + + + + + + {t("settings.engraving.helperPrefix")}{" "} + + {t("settings.engraving.helperLinkText")} + + {t("settings.engraving.helperSuffix")} + + onUpdate({ enabled: e.target.checked })} + /> + } + label={t("settings.engraving.enableLabel")} + /> + + + {t("settings.engraving.preferredLabel")} + + + + + {t("settings.engraving.footerNote")} + + + + + ); +} diff --git a/frontend/src/routes/settings/components/ImportsSection/components/CookiePanel.tsx b/frontend/src/routes/settings/components/ImportsSection/components/CookiePanel.tsx new file mode 100644 index 0000000..338e4fd --- /dev/null +++ b/frontend/src/routes/settings/components/ImportsSection/components/CookiePanel.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; + +type Props = { + cookie: string; + onSave: (cookie: string) => void; + headingText: string; + descText: string; + helpText: string; + placeholderText?: string; +}; + +/** A labeled session-cookie textarea with an edit/save toggle -- shared by the MakerWorld and + * Thingiverse import-auth panels, which are otherwise identical apart from their copy. */ +export default function CookiePanel({ cookie, onSave, headingText, descText, helpText, placeholderText }: Props) { + const { t } = useTranslation(["app", "common"]); + const [draft, setDraft] = React.useState(cookie || ""); + const [editing, setEditing] = React.useState(!cookie); + + React.useEffect(() => { + if (!editing) setDraft(cookie || ""); + }, [cookie, editing]); + + return ( + + + + {headingText} + {descText} + + + {helpText} + + + + {t("settings.imports.sessionCookieLabel")} + + {editing ? ( + + ) : ( + + )} + + setDraft(e.target.value)} + placeholder={placeholderText} + InputProps={{ readOnly: !editing }} + sx={{ opacity: editing ? 1 : 0.7 }} + /> + + + ); +} diff --git a/frontend/src/routes/settings/components/ImportsSection/components/FolderScanPanel.tsx b/frontend/src/routes/settings/components/ImportsSection/components/FolderScanPanel.tsx new file mode 100644 index 0000000..14fc8f9 --- /dev/null +++ b/frontend/src/routes/settings/components/ImportsSection/components/FolderScanPanel.tsx @@ -0,0 +1,325 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Button from "@mui/material/Button"; +import { SCAN_EXTS } from "../../../../../constants/fileTypes"; +import { entryKey, findRootSegment, prepareScanEntries } from "../../../../../utils/directoryScan"; +import { UnauthorizedError, createFolder } from "../../../../../services/api"; +import { + entriesFromDirectoryHandle, + entriesFromFileList, + uploadEntriesToFolder, + type DirectoryPicker, + type FileSystemAccessDirectoryHandle, + type UploadEntry, +} from "../../../../../services/uploadTree"; + +type Props = { + onAssetsChanged?: () => void; + onFoldersChanged?: () => void; + onUnauthorized?: () => void; + onSelectFolder?: (id: string | null) => void; +}; + +/** Client-side "pick a local folder (USB included) and import its supported files" scan + * feature, via either the File System Access API directory picker (preferred, allows a + * one-click "Rescan now") or a plain fallback. */ +export default function FolderScanPanel({ onAssetsChanged, onFoldersChanged, onUnauthorized, onSelectFolder }: Props) { + const { t } = useTranslation("app"); + const [scanRawEntries, setScanRawEntries] = React.useState([]); + const [scanEntries, setScanEntries] = React.useState([]); + const [scanRoot, setScanRoot] = React.useState(""); + const [scanSkipped, setScanSkipped] = React.useState(0); + const [scanStatus, setScanStatus] = React.useState(null); + const [scanBusy, setScanBusy] = React.useState(false); + const [scanStripRoot, setScanStripRoot] = React.useState(false); + const [scanHandle, setScanHandle] = React.useState(null); + const [scanFolderId, setScanFolderId] = React.useState(null); + const folderInputRef = React.useRef(null); + const uploadedKeysRef = React.useRef>(new Set()); + + React.useEffect(() => { + if (!folderInputRef.current) return; + folderInputRef.current.setAttribute("webkitdirectory", ""); + folderInputRef.current.setAttribute("directory", ""); + }, []); + + React.useEffect(() => { + if (!scanRawEntries.length) { + setScanEntries([]); + setScanSkipped(0); + return; + } + const stripRoot = Boolean(scanRoot); + const { entries, skipped } = prepareScanEntries(scanRawEntries, scanRoot, stripRoot); + setScanEntries(entries); + setScanSkipped(skipped); + }, [scanRawEntries, scanRoot]); + + const recordUploadedEntries = (entries: UploadEntry[]) => { + for (const entry of entries) { + uploadedKeysRef.current.add(entryKey(entry)); + } + }; + + const filterNewEntries = (entries: UploadEntry[]) => { + return entries.filter(entry => !uploadedKeysRef.current.has(entryKey(entry))); + }; + + const updateScanSelection = ( + rawEntries: UploadEntry[], + rootLabel: string, + stripRoot: boolean, + resetUploaded: boolean + ) => { + const { entries, skipped } = prepareScanEntries(rawEntries, rootLabel, stripRoot); + if (resetUploaded) { + uploadedKeysRef.current = new Set(); + } + setScanRawEntries(rawEntries); + setScanRoot(rootLabel); + setScanEntries(entries); + setScanSkipped(skipped); + return entries; + }; + + const uploadScanEntries = async ( + entries: UploadEntry[], + note?: string, + parentFolderId?: string | null + ) => { + const fresh = filterNewEntries(entries); + if (!fresh.length) { + setScanStatus(note || t("settings.imports.noNewFiles")); + return; + } + setScanBusy(true); + setScanStatus(t("settings.imports.uploadingStatus")); + try { + const result = await uploadEntriesToFolder(fresh, parentFolderId || null, onUnauthorized); + if (result.uploadedEntries?.length) { + recordUploadedEntries(result.uploadedEntries); + } else if (result.uploaded) { + recordUploadedEntries(fresh); + } + if (result.uploaded) { + onAssetsChanged?.(); + onFoldersChanged?.(); + } + if (result.failed.length) { + setScanStatus(t("settings.imports.uploadedWithFailures", { count: result.uploaded, failed: result.failed.length })); + } else { + setScanStatus(t("settings.imports.uploadedStatus", { count: result.uploaded })); + } + } catch (err) { + console.error("Folder scan upload failed", err); + setScanStatus(t("settings.imports.uploadFailed")); + } finally { + setScanBusy(false); + } + }; + + const ensureRootFolder = async (name: string) => { + if (!name || scanStripRoot) { + setScanFolderId(null); + return null; + } + if (scanFolderId && scanRoot === name) return scanFolderId; + try { + const created = await createFolder(name, [], undefined); + const id = (created as { id: string }).id; + setScanFolderId(id); + return id; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return null; + } + console.error("Root folder creation failed", err); + setScanStatus(t("settings.imports.rootFolderCreateFailed")); + return null; + } + }; + + const totalSelected = scanRawEntries.length; + const supportedSelected = scanEntries.length; + const newSelected = scanEntries.length ? filterNewEntries(scanEntries).length : 0; + + return ( + + + + {t("settings.imports.scanHeading")} + + {t("settings.imports.scanDesc")} + + + + {t("settings.imports.scanExtensions", { extensions: SCAN_EXTS.join(", ") })} + + setScanStripRoot(e.target.checked)} + /> + } + label={t("settings.imports.scanSkipRootLabel")} + /> + + { + const entries = entriesFromFileList(e.target.files || []); + if (!entries.length) { + setScanRawEntries([]); + setScanRoot(""); + setScanStatus(null); + setScanHandle(null); + setScanFolderId(null); + uploadedKeysRef.current = new Set(); + return; + } + const root = findRootSegment(entries); + setScanHandle(null); + setScanFolderId(null); + const stripRoot = Boolean(root); + const prepared = updateScanSelection(entries, root, stripRoot, true); + setScanStatus(null); + void (async () => { + const parentFolderId = await ensureRootFolder(root); + await uploadScanEntries(prepared, undefined, parentFolderId); + onSelectFolder?.(parentFolderId ?? null); + })(); + if (folderInputRef.current) folderInputRef.current.value = ""; + }} + multiple + hidden + /> + + + + + + {totalSelected + ? t("settings.imports.selectedSummary", { + total: totalSelected, + fromSuffix: scanRoot ? t("settings.imports.fromSuffix", { root: scanRoot }) : "", + ready: supportedSelected, + fresh: newSelected, + skipped: scanSkipped, + }) + : t("settings.imports.noFolderSelected")} + + {scanStatus && ( + {scanStatus} + )} + + + ); +} diff --git a/frontend/src/routes/settings/components/ImportsSection/components/MountImportPanel.tsx b/frontend/src/routes/settings/components/ImportsSection/components/MountImportPanel.tsx new file mode 100644 index 0000000..9948d5b --- /dev/null +++ b/frontend/src/routes/settings/components/ImportsSection/components/MountImportPanel.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Button from "@mui/material/Button"; +import { UnauthorizedError, getMountImportSettings, updateMountImportSettings } from "../../../../../services/api"; + +type Props = { + onUnauthorized?: () => void; +}; + +/** Server-side "index files already on the mounted volume" import settings -- fetched and + * saved independently of the rest of AppSettings, since they live on the server, not + * localStorage. */ +export default function MountImportPanel({ onUnauthorized }: Props) { + const { t } = useTranslation("app"); + const [mountEnabled, setMountEnabled] = React.useState(false); + const [mountCopyFiles, setMountCopyFiles] = React.useState(true); + const [mountPath, setMountPath] = React.useState(null); + const [mountLoading, setMountLoading] = React.useState(false); + const [mountSaving, setMountSaving] = React.useState(false); + const [mountInitial, setMountInitial] = React.useState({ enabled: false, copy: true }); + + React.useEffect(() => { + let active = true; + setMountLoading(true); + void (async () => { + try { + const data = await getMountImportSettings(); + if (!active) return; + setMountEnabled(Boolean(data.enabled)); + setMountCopyFiles(Boolean(data.copy_files)); + setMountPath(data.path || null); + setMountInitial({ enabled: Boolean(data.enabled), copy: Boolean(data.copy_files) }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + } else { + console.error(err); + } + } finally { + if (active) setMountLoading(false); + } + })(); + return () => { + active = false; + }; + }, [onUnauthorized]); + + const mountDirty = mountEnabled !== mountInitial.enabled || mountCopyFiles !== mountInitial.copy; + + const saveMountSettings = async () => { + setMountSaving(true); + try { + const data = await updateMountImportSettings({ + enabled: mountEnabled, + copy_files: mountCopyFiles, + }); + setMountEnabled(Boolean(data.enabled)); + setMountCopyFiles(Boolean(data.copy_files)); + setMountPath(data.path || null); + setMountInitial({ enabled: Boolean(data.enabled), copy: Boolean(data.copy_files) }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + } else { + console.error(err); + } + } finally { + setMountSaving(false); + } + }; + + return ( + + + + {t("settings.imports.mountHeading")} + + {t("settings.imports.mountDesc")} + + + + {t("settings.imports.mountPath", { path: mountPath || t("settings.imports.mountPathNotConfigured") })} + + + {t("settings.imports.mountNote")} + + setMountEnabled(e.target.checked)} + disabled={mountLoading || mountSaving} + /> + } + label={t("settings.imports.mountEnableLabel")} + /> + setMountCopyFiles(e.target.checked)} + disabled={mountLoading || mountSaving} + /> + } + label={t("settings.imports.mountCopyLabel")} + /> + + + {mountLoading && ( + {t("settings.imports.loadingSettings")} + )} + + + + ); +} diff --git a/frontend/src/routes/settings/components/ImportsSection/index.tsx b/frontend/src/routes/settings/components/ImportsSection/index.tsx new file mode 100644 index 0000000..93ba982 --- /dev/null +++ b/frontend/src/routes/settings/components/ImportsSection/index.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import type { MakerWorldSettings, ThingiverseSettings } from "../../../../services/settings"; +import SectionHeader from "../SectionHeader"; +import MountImportPanel from "./components/MountImportPanel"; +import FolderScanPanel from "./components/FolderScanPanel"; +import CookiePanel from "./components/CookiePanel"; + +type Props = { + makerworldCookie: string; + thingiverseCookie: string; + onUpdateMakerWorld: (patch: Partial) => void; + onUpdateThingiverse: (patch: Partial) => void; + onAssetsChanged?: () => void; + onFoldersChanged?: () => void; + onUnauthorized?: () => void; + onSelectFolder?: (id: string | null) => void; + onBack: () => void; +}; + +/** Composes the three independent Imports sub-panels: server-side mount import, the local + * folder-scan feature, and the MakerWorld/Thingiverse session-cookie editors. */ +export default function ImportsSection({ + makerworldCookie, + thingiverseCookie, + onUpdateMakerWorld, + onUpdateThingiverse, + onAssetsChanged, + onFoldersChanged, + onUnauthorized, + onSelectFolder, + onBack, +}: Props) { + const { t } = useTranslation("app"); + return ( + + + + + + + + onUpdateMakerWorld({ cookie })} + headingText={t("settings.imports.makerworldHeading")} + descText={t("settings.imports.makerworldDesc")} + helpText={t("settings.imports.makerworldHelp")} + placeholderText={t("settings.imports.makerworldPlaceholder") ?? undefined} + /> + + onUpdateThingiverse({ cookie })} + headingText={t("settings.imports.thingiverseHeading")} + descText={t("settings.imports.thingiverseDesc")} + helpText={t("settings.imports.thingiverseHelp")} + placeholderText={t("settings.imports.thingiversePlaceholder") ?? undefined} + /> + + ); +} diff --git a/frontend/src/routes/settings/components/LanguageSection.tsx b/frontend/src/routes/settings/components/LanguageSection.tsx new file mode 100644 index 0000000..305fbba --- /dev/null +++ b/frontend/src/routes/settings/components/LanguageSection.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import ButtonBase from "@mui/material/ButtonBase"; +import CheckIcon from "@mui/icons-material/Check"; +import { SUPPORTED_LANGUAGES } from "../../../constants/languages"; +import SectionHeader from "./SectionHeader"; + +type Props = { + onBack: () => void; +}; + +export default function LanguageSection({ onBack }: Props) { + const { t, i18n } = useTranslation("app"); + return ( + + + + + + {SUPPORTED_LANGUAGES.map(lang => { + const selected = (i18n.resolvedLanguage || i18n.language || "").startsWith(lang.code); + return ( + { void i18n.changeLanguage(lang.code); }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + textAlign: "left", + borderRadius: 2, + border: "1px solid", + borderColor: selected ? "primary.main" : "divider", + bgcolor: selected ? "action.selected" : "transparent", + p: 1.5, + }} + > + + {lang.label} + + {selected && } + + ); + })} + + + + ); +} diff --git a/frontend/src/routes/settings/components/NetworkSection.tsx b/frontend/src/routes/settings/components/NetworkSection.tsx new file mode 100644 index 0000000..12c4060 --- /dev/null +++ b/frontend/src/routes/settings/components/NetworkSection.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import type { NetworkSettings } from "../../../services/settings"; +import SectionHeader from "./SectionHeader"; + +type Props = { + publicUrl: string; + onUpdate: (patch: Partial) => void; + onBack: () => void; +}; + +export default function NetworkSection({ publicUrl, onUpdate, onBack }: Props) { + const { t } = useTranslation("app"); + const [networkDraft, setNetworkDraft] = React.useState(publicUrl); + + React.useEffect(() => { + setNetworkDraft(publicUrl || ""); + }, [publicUrl]); + + const trimmedUrl = (networkDraft || "").trim().replace(/\/+$/, ""); + const apiPreview = trimmedUrl || t("settings.network.apiBaseAuto"); + const isDirty = trimmedUrl !== (publicUrl || ""); + + return ( + + + + + + + {t("settings.network.publicUrlHeading")} + + {t("settings.network.publicUrlDesc")} + + + + {t("settings.network.storedNote")} + + setNetworkDraft(e.target.value)} + placeholder={t("settings.network.urlPlaceholder") ?? undefined} + sx={{ maxWidth: 420 }} + /> + + {t("settings.network.apiBase", { url: apiPreview })} + + + + + + + + + ); +} diff --git a/frontend/src/routes/settings/components/PreviewsSection.tsx b/frontend/src/routes/settings/components/PreviewsSection.tsx new file mode 100644 index 0000000..7dc50b7 --- /dev/null +++ b/frontend/src/routes/settings/components/PreviewsSection.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import type { PreviewMode } from "../../../services/settings"; +import SectionHeader from "./SectionHeader"; + +type Props = { + mode: PreviewMode; + onSelect: (mode: PreviewMode) => void; + onBack: () => void; +}; + +export default function PreviewsSection({ mode, onSelect, onBack }: Props) { + const { t } = useTranslation("app"); + const options: Array<{ id: PreviewMode; label: string; description: string }> = [ + { + id: "automatic", + label: t("settings.previews.automaticLabel"), + description: t("settings.previews.automaticDesc"), + }, + { + id: "on-demand", + label: t("settings.previews.onDemandLabel"), + description: t("settings.previews.onDemandDesc"), + }, + { + id: "disabled", + label: t("settings.previews.disabledLabel"), + description: t("settings.previews.disabledDesc"), + }, + ]; + return ( + + + onSelect(e.target.value as PreviewMode)}> + + {options.map(option => { + const selected = mode === option.id; + return ( + } + sx={{ + alignItems: "flex-start", + m: 0, + borderRadius: 2, + border: "1px solid", + borderColor: selected ? "primary.main" : "divider", + bgcolor: selected ? "action.selected" : "transparent", + p: 2, + }} + label={ + + {option.label} + {option.description} + + } + /> + ); + })} + + + + {t("settings.previews.footerNote")} + + + ); +} diff --git a/frontend/src/routes/settings/components/SectionHeader.tsx b/frontend/src/routes/settings/components/SectionHeader.tsx new file mode 100644 index 0000000..7674456 --- /dev/null +++ b/frontend/src/routes/settings/components/SectionHeader.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; + +type Props = { + title: string; + subtitle: string; + onBack: () => void; + backLabel: string; +}; + +/** Shared heading row for every Settings section: title/subtitle on the left, a "Back" button + * on the right that returns to the root menu grid. */ +export default function SectionHeader({ title, subtitle, onBack, backLabel }: Props) { + return ( + + + {title} + {subtitle} + + + + ); +} diff --git a/frontend/src/routes/settings/components/SlicerSection.tsx b/frontend/src/routes/settings/components/SlicerSection.tsx new file mode 100644 index 0000000..a04c75b --- /dev/null +++ b/frontend/src/routes/settings/components/SlicerSection.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Link from "@mui/material/Link"; +import { SLICER_OPTIONS } from "../../../constants/settingsOptions"; +import type { SlicerSettings } from "../../../services/settings"; +import SectionHeader from "./SectionHeader"; + +type Props = { + slicer: SlicerSettings; + onUpdate: (patch: Partial) => void; + onBack: () => void; +}; + +export default function SlicerSection({ slicer, onUpdate, onBack }: Props) { + const { t } = useTranslation("app"); + return ( + + + + + + + {t("settings.slicer.helperPrefix")}{" "} + + {t("settings.slicer.helperLinkText")} + + {t("settings.slicer.helperSuffix")} + + onUpdate({ enabled: e.target.checked })} + /> + } + label={t("settings.slicer.enableLabel")} + /> + + + {t("settings.slicer.preferredLabel")} + + + + + {t("settings.slicer.footerNote")} + + + + + ); +} diff --git a/frontend/src/routes/settings/components/StorageSection.tsx b/frontend/src/routes/settings/components/StorageSection.tsx new file mode 100644 index 0000000..3b5447f --- /dev/null +++ b/frontend/src/routes/settings/components/StorageSection.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { UnauthorizedError, getStorageSettings, updateStorageSettings } from "../../../services/api"; +import SectionHeader from "./SectionHeader"; + +type Props = { + onUnauthorized?: () => void; + onBack: () => void; +}; + +// Two example plates of the same print, to show that sibling plates +// share the {model} directory under the default template. +const PLATE_PREVIEW_VALUES: Record[] = [ + { + folder: "Props/Workshop", + collection: "Tabletop", + tags: "Print in place + Useful", + creator: "Example creator", + model: "Cable clip", + name: "Cable clip", + filename: "Cable clip.3mf", + id: "a1b2c3d4", + plate: "1", + }, + { + folder: "Props/Workshop", + collection: "Tabletop", + tags: "Print in place + Useful", + creator: "Example creator", + model: "Cable clip", + name: "Cable clip", + filename: "Cable clip-2.3mf", + id: "a1b2c3d4", + plate: "2", + }, +]; + +const DEFAULT_TEMPLATE = "{folder}/{model}/{filename}"; + +export default function StorageSection({ onUnauthorized, onBack }: Props) { + const { t } = useTranslation("app"); + const [storageTemplate, setStorageTemplate] = React.useState(DEFAULT_TEMPLATE); + const [storageInitial, setStorageInitial] = React.useState(DEFAULT_TEMPLATE); + const [storageTokens, setStorageTokens] = React.useState([]); + const [storagePlatePaths, setStoragePlatePaths] = React.useState([]); + const [storageApplyExisting, setStorageApplyExisting] = React.useState(false); + const [storageLoading, setStorageLoading] = React.useState(false); + const [storageSaving, setStorageSaving] = React.useState(false); + const [storageStatus, setStorageStatus] = React.useState(null); + + React.useEffect(() => { + let active = true; + setStorageLoading(true); + setStorageStatus(null); + void (async () => { + try { + const data = await getStorageSettings(); + if (!active) return; + setStorageTemplate(data.template); + setStorageInitial(data.template); + setStorageTokens(data.allowed_tokens || []); + setStoragePlatePaths(data.plate_paths || []); + } catch (err) { + if (err instanceof UnauthorizedError) onUnauthorized?.(); + else setStorageStatus(t("settings.storage.failed")); + } finally { + if (active) setStorageLoading(false); + } + })(); + return () => { active = false; }; + }, [onUnauthorized, t]); + + const saveStorageSettings = async () => { + setStorageSaving(true); + setStorageStatus(null); + try { + const data = await updateStorageSettings({ + template: storageTemplate, + apply_existing: storageApplyExisting, + }); + setStorageTemplate(data.template); + setStorageInitial(data.template); + setStorageTokens(data.allowed_tokens || []); + setStoragePlatePaths(data.plate_paths || []); + setStorageApplyExisting(false); + setStorageStatus( + storageApplyExisting + ? t("settings.storage.savedApplied", { + moved: data.moved, + skippedSuffix: data.skipped ? t("settings.storage.skippedSuffix", { skipped: data.skipped }) : "", + }) + : t("settings.storage.savedNoApply") + ); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + } else { + setStorageStatus(err instanceof Error ? err.message : t("settings.storage.failed")); + } + } finally { + setStorageSaving(false); + } + }; + + const isDirty = storageTemplate.trim() !== storageInitial; + const templateTrimmed = storageTemplate.trim(); + const draftSamples = templateTrimmed + ? PLATE_PREVIEW_VALUES.map(values => + Object.entries(values).reduce( + (value, [token, replacement]) => value.split(`{${token}}`).join(replacement), + templateTrimmed + ) + ) + : []; + const examplePaths = draftSamples.length ? draftSamples : storagePlatePaths; + + return ( + + + + + + + {t("settings.storage.templateHeading")} + + {t("settings.storage.templateDesc")} + + + { setStorageTemplate(e.target.value); setStorageStatus(null); }} + placeholder={t("settings.storage.templatePlaceholder") ?? undefined} + disabled={storageLoading || storageSaving} + InputProps={{ sx: { fontFamily: "monospace" } }} + /> + + {storageTokens.map(token => ( + + ))} + + + + {t("settings.storage.examplePathsHeading")} + + {examplePaths.length ? ( + + {examplePaths.map((path, idx) => ( + + {path} + + ))} + + ) : ( + + {t("settings.storage.examplePathsEmpty")} + + )} + + setStorageApplyExisting(e.target.checked)} + disabled={storageLoading || storageSaving} + /> + } + label={ + + {t("settings.storage.reorganizeLabel")} + {t("settings.storage.reorganizeHint")} + + } + /> + + + {storageStatus && ( + {storageStatus} + )} + + + + + ); +} diff --git a/frontend/src/routes/settings/components/ThemeSection.tsx b/frontend/src/routes/settings/components/ThemeSection.tsx new file mode 100644 index 0000000..cd5760c --- /dev/null +++ b/frontend/src/routes/settings/components/ThemeSection.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { THEME_OPTIONS, type ThemeSelection } from "../../../constants/settingsOptions"; +import { THEME_SWATCHES } from "../../../constants/themeSwatches"; +import SectionHeader from "./SectionHeader"; + +type Props = { + selected: ThemeSelection; + onSelect: (id: ThemeSelection) => void; + onBack: () => void; +}; + +export default function ThemeSection({ selected: selectedTheme, onSelect, onBack }: Props) { + const { t } = useTranslation("app"); + return ( + + + + + onSelect(e.target.value as ThemeSelection)} + > + + {THEME_OPTIONS.map(option => { + const selected = selectedTheme === option.id; + return ( + } + sx={{ + alignItems: "flex-start", + m: 0, + borderRadius: 2, + border: "1px solid", + borderColor: selected ? "primary.main" : "divider", + bgcolor: selected ? "action.selected" : "transparent", + p: 1.5, + }} + label={ + + + } + /> + ); + })} + + + + + ); +} diff --git a/frontend/src/routes/settings/index.tsx b/frontend/src/routes/settings/index.tsx new file mode 100644 index 0000000..e8d26e9 --- /dev/null +++ b/frontend/src/routes/settings/index.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Grid from "@mui/material/Grid"; +import Card from "@mui/material/Card"; +import CardActionArea from "@mui/material/CardActionArea"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import type { AppSettings } from "../../services/settings"; +import { SLICER_BRIDGE_ENABLED } from "../../constants/featureFlags"; +import SlicerSection from "./components/SlicerSection"; +import EngravingSection from "./components/EngravingSection"; +import ThemeSection from "./components/ThemeSection"; +import NetworkSection from "./components/NetworkSection"; +import PreviewsSection from "./components/PreviewsSection"; +import StorageSection from "./components/StorageSection"; +import ImportsSection from "./components/ImportsSection"; +import LanguageSection from "./components/LanguageSection"; + +type Props = { + settings: AppSettings; + onChange: (next: AppSettings) => void; + onAssetsChanged?: () => void; + onFoldersChanged?: () => void; + onUnauthorized?: () => void; + onSelectFolder?: (id: string | null) => void; +}; + +type Section = "root" | "slicer" | "engraving" | "theme" | "network" | "previews" | "storage" | "imports" | "language"; + +export default function Settings({ + settings, + onChange, + onAssetsChanged, + onFoldersChanged, + onUnauthorized, + onSelectFolder, +}: Props) { + const { t } = useTranslation("app"); + const [section, setSection] = React.useState
("root"); + const backToRoot = () => setSection("root"); + + if (section === "slicer") { + return ( + onChange({ ...settings, slicer: { ...settings.slicer, ...patch } })} + onBack={backToRoot} + /> + ); + } + + if (section === "engraving") { + return ( + onChange({ ...settings, engraving: { ...settings.engraving, ...patch } })} + onBack={backToRoot} + /> + ); + } + + if (section === "theme") { + return ( + onChange({ ...settings, theme: { selected } })} + onBack={backToRoot} + /> + ); + } + + if (section === "language") { + return ; + } + + if (section === "network") { + return ( + onChange({ ...settings, network: { ...settings.network, ...patch } })} + onBack={backToRoot} + /> + ); + } + + if (section === "storage") { + return ; + } + + if (section === "previews") { + return ( + onChange({ ...settings, previews: { mode } })} + onBack={backToRoot} + /> + ); + } + + if (section === "imports") { + return ( + onChange({ ...settings, makerworld: { ...settings.makerworld, ...patch } })} + onUpdateThingiverse={patch => onChange({ ...settings, thingiverse: { ...settings.thingiverse, ...patch } })} + onAssetsChanged={onAssetsChanged} + onFoldersChanged={onFoldersChanged} + onUnauthorized={onUnauthorized} + onSelectFolder={onSelectFolder} + onBack={backToRoot} + /> + ); + } + + const rootCards: Array<{ eyebrow: string; heading: string; desc: string; onClick: () => void }> = [ + ...(SLICER_BRIDGE_ENABLED + ? [{ + eyebrow: t("settings.root.slicerTitle"), + heading: t("settings.root.slicerHeading"), + desc: t("settings.root.slicerDesc"), + onClick: () => setSection("slicer"), + }] + : []), + { + eyebrow: t("settings.root.engravingTitle"), + heading: t("settings.root.engravingHeading"), + desc: t("settings.root.engravingDesc"), + onClick: () => setSection("engraving"), + }, + { + eyebrow: t("settings.root.themeTitle"), + heading: t("settings.root.themeHeading"), + desc: t("settings.root.themeDesc"), + onClick: () => setSection("theme"), + }, + { + eyebrow: t("settings.root.networkTitle"), + heading: t("settings.root.networkHeading"), + desc: t("settings.root.networkDesc"), + onClick: () => setSection("network"), + }, + { + eyebrow: t("settings.root.previewsTitle"), + heading: t("settings.root.previewsHeading"), + desc: t("settings.root.previewsDesc"), + onClick: () => setSection("previews"), + }, + { + eyebrow: t("settings.root.storageTitle"), + heading: t("settings.root.storageHeading"), + desc: t("settings.root.storageDesc"), + onClick: () => setSection("storage"), + }, + { + eyebrow: t("settings.root.importsTitle"), + heading: t("settings.root.importsHeading"), + desc: t("settings.root.importsDesc"), + onClick: () => setSection("imports"), + }, + { + eyebrow: t("settings.root.languageTitle"), + heading: t("settings.root.languageHeading"), + desc: t("settings.root.languageDesc"), + onClick: () => setSection("language"), + }, + ]; + + return ( + + {rootCards.map(card => ( + + + + + + {card.eyebrow} + + {card.heading} + {card.desc} + + + + + ))} + + ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..f86c84d --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,695 @@ +import { appendTokenToUrl, authHeaders } from "./auth"; +import { loadSettings } from "./settings"; + +export class UnauthorizedError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = "UnauthorizedError"; + } +} + +// Thrown specifically for the "delete the only remaining plate" 409 so callers +// can show a clear message instead of a generic failure toast. +export class LastPlateError extends Error { + constructor(message = "Cannot remove the only remaining plate. Delete the print instead.") { + super(message); + this.name = "LastPlateError"; + } +} + +function assertOk(res: Response, message: string) { + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + throw new Error(message); + } +} + +async function readErrorMessage(res: Response, fallback: string) { + let body = ""; + try { + body = await res.text(); + } catch { + return fallback; + } + const trimmed = body.trim(); + if (!trimmed) return fallback; + try { + const data = JSON.parse(trimmed); + if (typeof data?.detail === "string" && data.detail.trim()) { + return data.detail.trim(); + } + } catch { + // ignore JSON parse errors + } + return trimmed; +} + +export type PreparedPrint = { + printer?: string | null; + material?: string | null; + nozzle_mm?: number | null; + layer_height_mm?: number | null; + estimated_seconds?: number | null; + format?: string | null; // "gcode" | "bgcode" | "gcode_3mf" + removable: boolean; +}; + +export type Plate = { + id: string; + print_id: string; + position: number; // dense, 0-based, ordered + filename: string; + mime: string; + size: number; + url: string; // /print/{printId}/plate/{plateId}/file/{filename} + thumb_url?: string | null; // /plate/{plateId}/thumb.jpg?v=... if a thumb exists +}; + +export type PrintFile = { + id: string; + filename: string; + mime: string; + size: number; + url: string; // /print/{printId}/files/{fileId} +}; + +export type Print = { + id: string; + name: string; + title?: string | null; + notes?: string | null; + creator?: string | null; + collection?: string | null; + tags: string[]; + folder_id?: string | null; + storage_path?: string | null; + plates: Plate[]; // ordered by position, length >= 1 + thumb_url?: string | null; // denormalized = plates[0].thumb_url + supporting_file_count: number; + prepared_print?: PreparedPrint | null; + slicer_url?: string | null; + slicer_filename?: string | null; +}; + +export type Folder = { id: string; name: string; tags: string[]; parent_id?: string | null }; + +export type ImportInspectInfo = { + filename: string; + mime: string; + is_zip: boolean; +}; + +export type ZipEntryInfo = { + path: string; + size: number; +}; + +export type ImportZipResult = { + prints: Print[]; + failed: string[]; +}; + +export type ListPrintsResult = { + items: Print[]; + hasMore: boolean; + nextOffset?: number; +}; + +export type UploadPrintsResult = { + prints: Print[]; +}; + +export type MountImportSettings = { + enabled: boolean; + copy_files: boolean; + path?: string | null; +}; + +export type StorageSettings = { + template: string; + default_template: string; + allowed_tokens: string[]; + plate_paths: string[]; + moved: number; + skipped: number; +}; + +function normalizePublicUrl(raw: string): string | null { + const trimmed = (raw || "").trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) { + return trimmed.replace(/\/+$/, ""); + } + const protocol = typeof window !== "undefined" ? window.location.protocol : "http:"; + return `${protocol}//${trimmed}`.replace(/\/+$/, ""); +} + +function apiBaseFromPublicUrl(raw: string): string | null { + return normalizePublicUrl(raw); +} + +function apiBaseFromSettings(): string | null { + if (typeof window === "undefined") return null; + try { + const settings = loadSettings(); + return apiBaseFromPublicUrl(settings.network?.publicUrl || ""); + } catch { + return null; + } +} + +function isLocalHostName(hostname: string) { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +// API base URL resolution (browser-reachable). PrintStash's backend serves the built frontend +// and the API from the same origin/port in production, so the default is a same-origin relative +// base ("") — fetch("" + "/prints") just resolves against the current page. The only cases that +// need an explicit base are local dev (Vite's dev server and the backend run on different ports) +// and hosting the frontend separately from the backend (the Settings > Network override). +function resolveApiBase(): string { + const settingsBase = apiBaseFromSettings(); + if (settingsBase) return settingsBase; + + const envUrl = (import.meta.env.VITE_API_URL as string | undefined) || ""; + const hasWindow = typeof window !== "undefined"; + const host = hasWindow ? window.location.hostname : "localhost"; + + if (envUrl) { + const isAbs = /^(https?:)?\/\//i.test(envUrl); + if (isAbs) { + const protocol = hasWindow ? window.location.protocol : "http:"; + const absolute = envUrl.startsWith("//") ? `${protocol}${envUrl}` : envUrl; + return absolute.replace(/\/$/, ""); + } + // A bare port like ":8000" or "8000". + const portOnly = envUrl.replace(/^:?/, ""); + return `http://${host}:${portOnly}`.replace(/\/$/, ""); + } + + // Local dev default: Vite's dev server and `npm run dev` in backend/ both run on localhost, + // the backend on :8000, with no configuration needed. + if (isLocalHostName(host)) { + return `http://${host}:8000`; + } + + // Otherwise, same origin as the page — the standard production deployment. + return ""; +} + +function apiBase(): string { + return resolveApiBase(); +} + +export async function listPrints(params: { + q?: string; + tags?: string[]; + folder_id?: string; + limit?: number; + offset?: number; +} = {}): Promise { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", params.q); + if (params.tags && params.tags.length) qs.set("tags", params.tags.join(",")); + if (params.folder_id) qs.set("folder_id", params.folder_id); + if (typeof params.limit === "number") qs.set("limit", String(params.limit)); + if (typeof params.offset === "number") qs.set("offset", String(params.offset)); + const res = await fetch(`${apiBase()}/prints?${qs.toString()}`, { + headers: authHeaders(), + }); + assertOk(res, "Failed to list prints"); + const items = await res.json(); + const hasMore = (res.headers.get("X-Has-More") || "").toLowerCase() === "true"; + const nextOffsetRaw = res.headers.get("X-Next-Offset"); + const nextOffset = nextOffsetRaw ? Number(nextOffsetRaw) : undefined; + return { items, hasMore, nextOffset }; +} + +export async function listTags(params: { + q?: string; + tags?: string[]; + folder_id?: string; +} = {}): Promise { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", params.q); + if (params.tags && params.tags.length) qs.set("tags", params.tags.join(",")); + if (params.folder_id) qs.set("folder_id", params.folder_id); + const res = await fetch(`${apiBase()}/tags?${qs.toString()}`, { + headers: authHeaders(), + }); + assertOk(res, "Failed to list tags"); + return res.json(); +} + +export async function uploadPrints( + files: File[], + opts: { + title?: string; + notes?: string; + tags?: string[]; + folder_id?: string; + mode?: "separate" | "multiplate"; + } = {} +): Promise { + const fd = new FormData(); + for (const file of files) { + fd.append("files", file); + } + if (opts.title) fd.set("title", opts.title); + if (opts.notes) fd.set("notes", opts.notes); + if (opts.tags && opts.tags.length) fd.set("tags", opts.tags.join(",")); + if (opts.folder_id) fd.set("folder_id", opts.folder_id); + if (opts.mode && files.length > 1) fd.set("mode", opts.mode); + const res = await fetch(`${apiBase()}/upload`, { + method: "POST", + body: fd, + headers: authHeaders(), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Upload failed"); + throw new Error(message); + } + return res.json(); +} + +export async function addPlates(printId: string, files: File[]): Promise<{ print: Print }> { + const fd = new FormData(); + for (const file of files) { + fd.append("files", file); + } + const res = await fetch(`${apiBase()}/print/${printId}/plates`, { + method: "POST", + body: fd, + headers: authHeaders(), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Failed to add plate"); + throw new Error(message); + } + return res.json(); +} + +export async function deletePlate(printId: string, plateId: string): Promise<{ print: Print }> { + const res = await fetch(`${apiBase()}/print/${printId}/plates/${plateId}`, { + method: "DELETE", + headers: authHeaders(), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (res.status === 409) { + throw new LastPlateError(await readErrorMessage(res, "Cannot remove the only remaining plate.")); + } + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to remove plate")); + } + return res.json(); +} + +export async function reorderPlates(printId: string, plateIds: string[]): Promise<{ print: Print }> { + const res = await fetch(`${apiBase()}/print/${printId}/plates/reorder`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ plate_ids: plateIds }), + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to reorder plates")); + } + return res.json(); +} + +export async function renamePlate(printId: string, plateId: string, filename: string): Promise<{ print: Print }> { + const res = await fetch(`${apiBase()}/print/${printId}/plate/${plateId}/rename`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ filename }), + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Rename failed")); + } + return res.json(); +} + +export async function importFromLink(payload: { + url: string; + title?: string; + notes?: string; + tags?: string[]; + folder_id?: string; + filename?: string; + makerworld_cookie?: string; + thingiverse_cookie?: string; +}) { + const res = await fetch(`${apiBase()}/import`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + let message = "Import failed"; + try { + const data = await res.json(); + if (typeof data?.detail === "string") message = data.detail; + } catch { + // ignore parse errors + } + throw new Error(message); + } + return res.json(); +} + +export async function inspectImportLink(payload: { + url: string; + title?: string; + notes?: string; + tags?: string[]; + folder_id?: string; + filename?: string; + makerworld_cookie?: string; + thingiverse_cookie?: string; +}): Promise { + const res = await fetch(`${apiBase()}/import/inspect`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Inspect failed"); + throw new Error(message); + } + return res.json(); +} + +export async function listImportZipEntries(payload: { + url: string; + title?: string; + notes?: string; + tags?: string[]; + folder_id?: string; + filename?: string; + makerworld_cookie?: string; + thingiverse_cookie?: string; +}): Promise<{ filename: string; entries: ZipEntryInfo[] }> { + const res = await fetch(`${apiBase()}/import/zip/entries`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Zip listing failed"); + throw new Error(message); + } + return res.json(); +} + +export async function importZipFromLink(payload: { + url: string; + entries: string[]; + title?: string; + notes?: string; + tags?: string[]; + folder_id?: string; + filename?: string; + makerworld_cookie?: string; + thingiverse_cookie?: string; +}): Promise { + const res = await fetch(`${apiBase()}/import/zip`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Zip import failed"); + throw new Error(message); + } + return res.json(); +} + +export async function setTags(id: string, tags: string[]) { + const res = await fetch(`${apiBase()}/print/${id}/tags`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ tags }), + }); + assertOk(res, "Tag update failed"); + return res.json(); +} + +export async function uploadGeneratedThumbnail(plateId: string, image: Blob): Promise { + const body = new FormData(); + body.set("file", image, "thumbnail.png"); + const res = await fetch(`${apiBase()}/plate/${plateId}/thumbnail-generated`, { + method: "POST", + headers: authHeaders(), + body, + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to save generated thumbnail")); + } + return res.json(); +} + +export async function listPrintFiles(id: string): Promise { + const res = await fetch(`${apiBase()}/print/${id}/files`, { headers: authHeaders() }); + assertOk(res, "Failed to load supporting files"); + return res.json(); +} + +export async function uploadPrintFile(id: string, file: File): Promise { + const body = new FormData(); + body.set("file", file, file.name); + const res = await fetch(`${apiBase()}/print/${id}/files`, { + method: "POST", + headers: authHeaders(), + body, + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to add file")); + } + return res.json(); +} + +export async function deletePrintFile(printId: string, fileId: string): Promise { + const res = await fetch(`${apiBase()}/print/${printId}/files/${fileId}`, { + method: "DELETE", + headers: authHeaders(), + }); + assertOk(res, "Failed to remove supporting file"); + return res.json(); +} + +export async function deletePreparedPrint(printId: string): Promise { + const res = await fetch(`${apiBase()}/print/${printId}/prepared-print`, { + method: "DELETE", + headers: authHeaders(), + }); + assertOk(res, "Failed to clear prepared print"); + return res.json(); +} + +export async function updatePrintMeta(id: string, payload: { + name?: string | null; + title?: string | null; + notes?: string | null; + creator?: string | null; + collection?: string | null; +}) { + const res = await fetch(`${apiBase()}/print/${id}/meta`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Metadata update failed")); + } + return res.json(); +} + +export async function deletePrint(id: string) { + const res = await fetch(`${apiBase()}/print/${id}`, { method: "DELETE", headers: authHeaders() }); + assertOk(res, "Delete print failed"); + return res.json(); +} + +export async function updatePrintFolder(id: string, folder_id: string | null) { + const res = await fetch(`${apiBase()}/print/${id}/folder`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ folder_id }), + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Folder update failed")); + } + return res.json(); +} + +export function fileUrl(rel: string) { + // API returns relative URLs. Join with API base. + if (!rel) return rel; + return appendTokenToUrl(`${apiBase()}${rel}`); +} + +// Folders ------------------------------------------------------- + +export async function listFolders(): Promise { + const res = await fetch(`${apiBase()}/folders`, { headers: authHeaders() }); + assertOk(res, "Failed to list folders"); + return res.json(); +} + +export async function createFolder(name: string, tags: string[] = [], parent_id?: string | null) { + const res = await fetch(`${apiBase()}/folders`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name, tags, parent_id }), + }); + assertOk(res, "Create folder failed"); + return res.json(); +} + +export async function updateFolder(id: string, name: string, tags: string[], parent_id?: string | null) { + const res = await fetch(`${apiBase()}/folder/${id}`, { + method: "PATCH", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name, tags, parent_id }), + }); + assertOk(res, "Update folder failed"); + return res.json(); +} + +export async function deleteFolder(id: string) { + const res = await fetch(`${apiBase()}/folder/${id}`, { method: "DELETE", headers: authHeaders() }); + assertOk(res, "Delete folder failed"); + return res.json(); +} + +export async function downloadZip(opts: { print_ids?: string[]; tag?: string; folder_id?: string; filename?: string }) { + const res = await fetch(`${apiBase()}/download/zip`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(opts), + }); + assertOk(res, "Download failed"); + return res; +} + +export async function downloadFolderZip(folder_id: string) { + const res = await fetch(`${apiBase()}/folder/${folder_id}/download`, { headers: authHeaders() }); + assertOk(res, "Folder download failed"); + return res; +} + +export type HealthInfo = { ok: boolean; auth_required: boolean }; + +export async function apiHealth(): Promise { + try { + const res = await fetch(`${apiBase()}/health`, { cache: "no-store" }); + if (!res.ok) return null; + const data = await res.json(); + return { + ok: Boolean(data?.ok), + auth_required: Boolean(data?.auth_required), + }; + } catch { + return null; + } +} + +export function getApiBase() { + return apiBase(); +} + +export async function login(username: string, password: string): Promise<{ token: string; expires_in: number }> { + const res = await fetch(`${apiBase()}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + let message = "Login failed"; + try { + const data = await res.json(); + if (typeof data?.detail === "string") message = data.detail; + } catch { + // ignore + } + throw new Error(message); + } + return res.json(); +} + +export async function refreshToken(): Promise<{ token: string; expires_in: number }> { + const res = await fetch(`${apiBase()}/refresh`, { + method: "POST", + headers: authHeaders(), + }); + assertOk(res, "Token refresh failed"); + return res.json(); +} + +export async function getMountImportSettings(): Promise { + const res = await fetch(`${apiBase()}/settings/mount-import`, { headers: authHeaders() }); + assertOk(res, "Failed to load mount import settings"); + return res.json(); +} + +export async function updateMountImportSettings(payload: { + enabled: boolean; + copy_files: boolean; +}): Promise { + const res = await fetch(`${apiBase()}/settings/mount-import`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + assertOk(res, "Failed to update mount import settings"); + return res.json(); +} + +export async function getStorageSettings(): Promise { + const res = await fetch(`${apiBase()}/settings/storage`, { headers: authHeaders() }); + assertOk(res, "Failed to load storage settings"); + return res.json(); +} + +export async function updateStorageSettings(payload: { + template: string; + apply_existing: boolean; +}): Promise { + const res = await fetch(`${apiBase()}/settings/storage`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) throw new UnauthorizedError(); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to update storage settings")); + } + return res.json(); +} diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts new file mode 100644 index 0000000..fb974ae --- /dev/null +++ b/frontend/src/services/auth.ts @@ -0,0 +1,48 @@ +const TOKEN_KEY = "printstash_auth_token"; + +function getStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readToken(): string | null { + const storage = getStorage(); + if (!storage) return null; + return storage.getItem(TOKEN_KEY); +} + +export function storeToken(token: string) { + const storage = getStorage(); + if (!storage) return; + storage.setItem(TOKEN_KEY, token); +} + +export function clearToken() { + const storage = getStorage(); + if (!storage) return; + storage.removeItem(TOKEN_KEY); +} + +export function authHeaders(init?: HeadersInit): Headers { + const headers = new Headers(init || {}); + const token = readToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + return headers; +} + +export function appendTokenToUrl(url: string): string { + const token = readToken(); + if (!token) return url; + try { + const parsed = new URL(url, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + parsed.searchParams.set("token", token); + return parsed.toString(); + } catch { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}token=${encodeURIComponent(token)}`; + } +} diff --git a/frontend/src/services/lightburnExtract.ts b/frontend/src/services/lightburnExtract.ts new file mode 100644 index 0000000..acdf957 --- /dev/null +++ b/frontend/src/services/lightburnExtract.ts @@ -0,0 +1,368 @@ +// Byte-sniffing extraction of an embedded preview image from a .lbrn/.lbrn2 LightBurn project +// file: the format is either a zip archive (in which case we unzip via fflate and pick the best +// image entry) or a flat XML/binary file with an image embedded as raw bytes or base64 text. No +// React dependency, and it does real I/O-adjacent work (unzip, byte scanning of a fetched file), +// so it lives in services/ rather than utils/ alongside the other loader/extraction logic. +import { extOf } from "../utils/fileExtensions"; + +export type PreviewPayload = { + data: Uint8Array; + mime: string; +}; + +const ZIP_HEADER = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); +const ZIP_EOCD_HEADER = new Uint8Array([0x50, 0x4b, 0x05, 0x06]); +const ZIP_SCAN_LIMIT = 64 * 1024 * 1024; +const ZIP_EOCD_SEARCH_LIMIT = 1024 * 1024; +const PNG_HEADER = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_HEADER = new Uint8Array([0xff, 0xd8]); +const JPEG_FOOTER = new Uint8Array([0xff, 0xd9]); +const BMP_HEADER = new Uint8Array([0x42, 0x4d]); +const RIFF_HEADER = new Uint8Array([0x52, 0x49, 0x46, 0x46]); +const WEBP_HEADER = new Uint8Array([0x57, 0x45, 0x42, 0x50]); +const GIF_87A_HEADER = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]); +const GIF_89A_HEADER = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); +const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "webp", "bmp", "gif"]); +const MAX_TEXT_SCAN = 16 * 1024 * 1024; + +const MIME_BY_EXT: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + bmp: "image/bmp", + gif: "image/gif", +}; + +export async function extractLightBurnPreview(bytes: Uint8Array): Promise { + const fromZip = await extractFromZip(bytes); + if (fromZip) return fromZip; + const embedded = extractEmbeddedImage(bytes); + if (embedded) return embedded; + return extractEmbeddedBase64(bytes); +} + +async function extractFromZip(bytes: Uint8Array): Promise { + const zipSlice = findZipSlice(bytes); + if (!zipSlice) return null; + const { unzipSync } = await import("fflate"); + let entries: Record; + try { + entries = unzipSync(zipSlice); + } catch { + return null; + } + const candidates = Object.entries(entries) + .map(([name, data]) => { + const ext = extOf(name); + const detected = detectImageMime(data); + return { name, data, ext, detected }; + }) + .filter(entry => entry.data?.length && ((entry.ext && IMAGE_EXTS.has(entry.ext)) || entry.detected)); + if (!candidates.length) { + return extractBase64FromZipEntries(entries); + } + const preferred = candidates.filter(entry => /preview|thumb|thumbnail/i.test(entry.name)); + const selection = pickLargest(preferred.length ? preferred : candidates); + if (!selection) return null; + const mime = selection.detected || MIME_BY_EXT[selection.ext] || "image/png"; + return { data: selection.data, mime }; +} + +function extractBase64FromZipEntries(entries: Record): PreviewPayload | null { + const values = Object.values(entries); + for (const data of values) { + if (!data?.length) continue; + if (!isLikelyText(data)) continue; + const payload = extractBase64FromBytes(data); + if (payload) return payload; + } + return null; +} + +function extractEmbeddedBase64(bytes: Uint8Array): PreviewPayload | null { + const payload = extractBase64FromBytes(bytes); + if (payload) return payload; + return null; +} + +function extractEmbeddedImage(bytes: Uint8Array): PreviewPayload | null { + const png = extractPng(bytes); + if (png) return { data: png, mime: "image/png" }; + const jpg = extractJpeg(bytes); + if (jpg) return { data: jpg, mime: "image/jpeg" }; + const webp = extractWebp(bytes); + if (webp) return { data: webp, mime: "image/webp" }; + const bmp = extractBmp(bytes); + if (bmp) return { data: bmp, mime: "image/bmp" }; + return null; +} + +function extractPng(bytes: Uint8Array): Uint8Array | null { + const start = findSequence(bytes, PNG_HEADER); + if (start === -1) return null; + let offset = start + PNG_HEADER.length; + while (offset + 8 <= bytes.length) { + const length = readUint32BE(bytes, offset); + const type = readAscii(bytes, offset + 4, 4); + const next = offset + 8 + length + 4; + if (next > bytes.length) return null; + offset = next; + if (type === "IEND") { + return bytes.slice(start, offset); + } + } + return null; +} + +function extractJpeg(bytes: Uint8Array): Uint8Array | null { + const start = findSequence(bytes, JPEG_HEADER); + if (start === -1) return null; + const end = findSequence(bytes, JPEG_FOOTER, start + JPEG_HEADER.length); + if (end === -1) return null; + return bytes.slice(start, end + JPEG_FOOTER.length); +} + +function extractWebp(bytes: Uint8Array): Uint8Array | null { + let start = 0; + while (start < bytes.length) { + const idx = findSequence(bytes, RIFF_HEADER, start); + if (idx === -1) return null; + if (isWebpAt(bytes, idx)) { + if (idx + 12 > bytes.length) return null; + const size = readUint32LE(bytes, idx + 4); + const total = size + 8; + if (total > 0 && idx + total <= bytes.length) { + return bytes.slice(idx, idx + total); + } + } + start = idx + 4; + } + return null; +} + +function extractBmp(bytes: Uint8Array): Uint8Array | null { + const start = findSequence(bytes, BMP_HEADER); + if (start === -1 || start + 6 > bytes.length) return null; + const size = readUint32LE(bytes, start + 2); + if (!size || start + size > bytes.length) return null; + return bytes.slice(start, start + size); +} + +function extractBase64FromBytes(bytes: Uint8Array): PreviewPayload | null { + if (!isLikelyText(bytes)) return null; + const view = bytes.length > MAX_TEXT_SCAN ? bytes.slice(0, MAX_TEXT_SCAN) : bytes; + const text = decodeText(view); + if (!text) return null; + const dataUrl = extractDataUrlImage(text); + if (dataUrl) return dataUrl; + return extractBase64SignatureImage(text); +} + +function decodeText(bytes: Uint8Array): string { + try { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch { + return ""; + } +} + +function isLikelyText(bytes: Uint8Array): boolean { + const sample = bytes.length > 2048 ? bytes.slice(0, 2048) : bytes; + if (!sample.length) return false; + let printable = 0; + for (let i = 0; i < sample.length; i += 1) { + const b = sample[i]; + if (b === 0) return false; + if (b === 9 || b === 10 || b === 13) { + printable += 1; + continue; + } + if (b >= 32 && b <= 126) printable += 1; + } + return printable / sample.length > 0.7; +} + +function extractDataUrlImage(text: string): PreviewPayload | null { + const needle = "data:image/"; + const start = text.indexOf(needle); + if (start === -1) return null; + const metaStart = start + "data:".length; + const metaEnd = text.indexOf("base64,", metaStart); + if (metaEnd === -1) return null; + const mime = text.slice(metaStart, metaEnd - 1).trim().toLowerCase(); + const dataStart = metaEnd + "base64,".length; + const dataEnd = readBase64End(text, dataStart); + if (dataEnd <= dataStart) return null; + const chunk = text.slice(dataStart, dataEnd); + const bytes = decodeBase64(chunk); + if (!bytes) return null; + return { data: bytes, mime: normalizeMime(mime) }; +} + +function extractBase64SignatureImage(text: string): PreviewPayload | null { + const signatures = [ + { prefix: "iVBORw0KGgo", mime: "image/png" }, + { prefix: "/9j/", mime: "image/jpeg" }, + { prefix: "UklGR", mime: "image/webp" }, + { prefix: "Qk", mime: "image/bmp" }, + { prefix: "R0lGOD", mime: "image/gif" }, + ]; + for (const sig of signatures) { + const idx = text.indexOf(sig.prefix); + if (idx === -1) continue; + const end = readBase64End(text, idx); + if (end <= idx) continue; + const chunk = text.slice(idx, end); + const bytes = decodeBase64(chunk); + if (!bytes) continue; + return { data: bytes, mime: sig.mime }; + } + return null; +} + +function readBase64End(text: string, start: number): number { + let end = start; + for (; end < text.length; end += 1) { + const code = text.charCodeAt(end); + const isAlphaNum = + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122); + if (isAlphaNum || code === 43 || code === 47 || code === 61) { + continue; + } + break; + } + return end; +} + +function decodeBase64(chunk: string): Uint8Array | null { + try { + const binary = atob(chunk); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } catch { + return null; + } +} + +function normalizeMime(mime: string): string { + if (mime === "image/jpg") return "image/jpeg"; + return mime || "image/png"; +} + +function pickLargest(entries: T[]): T | null { + if (!entries.length) return null; + return entries.reduce((best, entry) => (entry.data.length > best.data.length ? entry : best), entries[0]); +} + +function detectImageMime(data: Uint8Array): string { + if (matchesHeader(data, PNG_HEADER)) return "image/png"; + if (matchesHeader(data, JPEG_HEADER)) return "image/jpeg"; + if (matchesHeader(data, BMP_HEADER)) return "image/bmp"; + if (isWebp(data)) return "image/webp"; + if (matchesHeader(data, GIF_89A_HEADER) || matchesHeader(data, GIF_87A_HEADER)) return "image/gif"; + return ""; +} + +function matchesHeader(bytes: Uint8Array, header: Uint8Array): boolean { + return matchesHeaderAt(bytes, header, 0); +} + +function matchesHeaderAt(bytes: Uint8Array, header: Uint8Array, offset: number): boolean { + if (offset < 0 || bytes.length < offset + header.length) return false; + for (let i = 0; i < header.length; i += 1) { + if (bytes[offset + i] !== header[i]) return false; + } + return true; +} + +function isWebp(bytes: Uint8Array): boolean { + return isWebpAt(bytes, 0); +} + +function isWebpAt(bytes: Uint8Array, offset: number): boolean { + return matchesHeaderAt(bytes, RIFF_HEADER, offset) && matchesHeaderAt(bytes, WEBP_HEADER, offset + 8); +} + +function findZipSlice(bytes: Uint8Array): Uint8Array | null { + if (matchesHeader(bytes, ZIP_HEADER)) return bytes; + const eocdIndex = findSequenceReverse(bytes, ZIP_EOCD_HEADER, ZIP_EOCD_SEARCH_LIMIT); + if (eocdIndex !== -1 && eocdIndex + 22 <= bytes.length) { + const centralSize = readUint32LE(bytes, eocdIndex + 12); + const centralOffset = readUint32LE(bytes, eocdIndex + 16); + if (centralSize !== 0xffffffff && centralOffset !== 0xffffffff) { + const zipStart = eocdIndex - (centralOffset + centralSize); + if (zipStart >= 0 && matchesHeaderAt(bytes, ZIP_HEADER, zipStart)) { + return bytes.slice(zipStart); + } + } + } + const headerIndex = findSequence(bytes, ZIP_HEADER, 0, ZIP_SCAN_LIMIT); + if (headerIndex === -1) return null; + return bytes.slice(headerIndex); +} + +function readUint32LE(bytes: Uint8Array, offset: number) { + return ( + (bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24)) >>> 0 + ); +} + +function readUint32BE(bytes: Uint8Array, offset: number) { + return ( + ((bytes[offset] << 24) >>> 0) + + (bytes[offset + 1] << 16) + + (bytes[offset + 2] << 8) + + bytes[offset + 3] + ); +} + +function readAscii(bytes: Uint8Array, offset: number, length: number) { + let out = ""; + for (let i = 0; i < length; i += 1) { + out += String.fromCharCode(bytes[offset + i]); + } + return out; +} + +function findSequence(bytes: Uint8Array, pattern: Uint8Array, start = 0, limit?: number) { + const maxStart = bytes.length - pattern.length; + const cappedStart = Math.max(0, start); + const end = Math.min(maxStart, limit === undefined ? maxStart : cappedStart + limit); + for (let i = cappedStart; i <= end; i += 1) { + let match = true; + for (let j = 0; j < pattern.length; j += 1) { + if (bytes[i + j] !== pattern[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; +} + +function findSequenceReverse(bytes: Uint8Array, pattern: Uint8Array, limit?: number) { + const start = bytes.length - pattern.length; + if (start < 0) return -1; + const min = Math.max(0, limit === undefined ? 0 : start - limit); + for (let i = start; i >= min; i -= 1) { + let match = true; + for (let j = 0; j < pattern.length; j += 1) { + if (bytes[i + j] !== pattern[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; +} diff --git a/frontend/src/services/modelLoaders.ts b/frontend/src/services/modelLoaders.ts new file mode 100644 index 0000000..5516798 --- /dev/null +++ b/frontend/src/services/modelLoaders.ts @@ -0,0 +1,522 @@ +// Pure/async 3D-asset loading logic used by the library route's ModelViewer and ModelSnapshot +// components. Nothing here depends on React -- it's plain three.js scene-graph construction plus +// fetch/WASM work (STL/3MF/STEP/OBJ parsing), which is why it lives in services/ rather than +// utils/. paletteForTheme()/applyThemeToObject() (material coloring) also live here since both +// ModelViewer's live scene and generateModelSnapshot() (in modelSnapshotCache.ts) need them. +import * as THREE from "three"; +import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url"; +import occtWorkerUrl from "occt-import-js/dist/occt-import-js-worker.js?url"; +import type { ResolvedTheme } from "../constants/settingsOptions"; +import { buildTheme } from "../theme"; + +export type ModelPalette = { + color: THREE.Color; + emissive: THREE.Color; + emissiveIntensity: number; + metalness: number; + roughness: number; +}; + +// paletteForTheme is a plain module-level function (called both from ModelViewer's mount effect +// and from generateModelSnapshot(), which runs off the React tree entirely) so it reads its +// colors straight from theme.ts's buildTheme() rather than via useTheme() -- there's no guarantee +// a component-level ThemeProvider is mounted above every caller, and threading the resolved +// theme.printstash values through as extra params would ripple through every call site for no +// benefit since ResolvedTheme -> Theme is already a pure, cheap lookup. +export function paletteForTheme(theme: ResolvedTheme): ModelPalette { + const { modelColor, modelEmissive } = buildTheme(theme).printstash; + return { + color: new THREE.Color(modelColor), + emissive: new THREE.Color(modelEmissive), + emissiveIntensity: + theme === "neon" || theme === "purple" || theme === "blue" + ? 0.35 + : theme === "dark" + ? 0.08 + : 0.05, + metalness: 0.2, + roughness: theme === "neon" || theme === "purple" || theme === "blue" ? 0.6 : 0.8, + }; +} + +export function applyThemeToObject(obj: THREE.Object3D, palette: ModelPalette) { + obj.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach(mat => { + const typed = mat as THREE.MeshStandardMaterial; + if (typed.color) typed.color.copy(palette.color); + if (typed.emissive) { + typed.emissive.copy(palette.emissive); + typed.emissiveIntensity = palette.emissiveIntensity; + } + if (typeof typed.metalness === "number") typed.metalness = palette.metalness; + if (typeof typed.roughness === "number") typed.roughness = palette.roughness; + mat.needsUpdate = true; + }); + }); +} + +export async function loadObjectFromAsset(ext: string, url: string): Promise { + if (ext === "stl") { + const { STLLoader } = await import("three/examples/jsm/loaders/STLLoader.js"); + const geometry = await new Promise((resolve, reject) => { + new STLLoader().load(url, resolve, undefined, reject); + }); + geometry.computeBoundingBox(); + const box = geometry.boundingBox ?? new THREE.Box3(); + const center = box.getCenter(new THREE.Vector3()); + geometry.translate(-center.x, -center.y, -center.z); + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ metalness: 0.2, roughness: 0.8, color: 0xdddddd }) + ); + return mesh; + } + if (ext === "3mf") { + return await load3MFObject(url); + } + if (ext === "obj") { + const { OBJLoader } = await import("three/examples/jsm/loaders/OBJLoader.js"); + return await new Promise((resolve, reject) => { + new OBJLoader().load(url, resolve, undefined, reject); + }); + } + if (ext === "step" || ext === "stp") { + return await loadStepGroup(url); + } + return null; +} + +export function disposeObject3D(obj: THREE.Object3D) { + obj.traverse(child => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach(mat => mat.dispose()); + } else { + child.material?.dispose(); + } + } + }); +} + +type Simple3MFMesh = { + positions: Float32Array; + indices: Uint32Array; +}; + +type Simple3MFComponent = { + objectId: string; + transform?: THREE.Matrix4 | null; + sourcePath?: string | null; +}; + +type Simple3MFBuildItem = { + objectId: string; + transform?: THREE.Matrix4 | null; + sourcePath?: string | null; +}; + +type Simple3MFObject = { + id: string; + mesh?: Simple3MFMesh; + components?: Simple3MFComponent[]; +}; + +type Simple3MFDocument = { + path: string; + objects: Map; + buildItems: Simple3MFBuildItem[]; +}; + +type NamespacedNode = { + getElementsByTagNameNS: (namespaceURI: string, localName: string) => NodeListOf; +}; + +async function loadSimple3MFGroup(url: string): Promise { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`3MF fetch failed with status ${resp.status}`); + } + const raw = new Uint8Array(await resp.arrayBuffer()); + const decoder = new TextDecoder(); + const documents = new Map(); + + const enqueueDocument = (label: string, data: Uint8Array | string) => { + const xml = typeof data === "string" ? data : decoder.decode(data); + try { + const doc = buildSimple3MFDocument(xml, label); + if (doc.objects.size || doc.buildItems.length) { + documents.set(doc.path, doc); + } + } catch (err) { + console.warn(`3MF fallback parse failed for ${label}`, err); + } + }; + + let archiveParsed = false; + try { + const { unzipSync } = await import("fflate"); + const zipEntries = unzipSync(raw); + const modelEntries = Object.keys(zipEntries).filter(key => /\.model$/i.test(key)); + if (modelEntries.length) { + archiveParsed = true; + for (const entry of modelEntries) { + enqueueDocument(entry, zipEntries[entry]); + } + } + } catch (err) { + console.warn("3MF fallback unzip failed, attempting inline parse", err); + } + + if (!archiveParsed) { + enqueueDocument("/3D/3dmodel.model", raw); + } + + if (!documents.size) { + throw new Error("3MF fallback: no printable geometry found"); + } + + const root = buildSceneFromDocuments(documents); + if (!root.children.length) { + throw new Error("3MF fallback: no printable geometry found"); + } + return root; +} + +function buildSceneFromDocuments(documents: Map): THREE.Group { + const root = new THREE.Group(); + const cache = new Map(); + + const instantiateFromDoc = ( + docPath: string, + objectId: string, + stack: Set + ): THREE.Object3D | null => { + const normalizedPath = normalizeModelPath(docPath); + const cacheKey = `${normalizedPath}::${objectId}`; + const cached = cache.get(cacheKey); + if (cached) { + return cached.clone(true); + } + const doc = documents.get(normalizedPath); + if (!doc) { + return null; + } + if (stack.has(cacheKey)) { + console.warn("3MF fallback: detected recursive reference for %s", cacheKey); + return null; + } + stack.add(cacheKey); + const data = doc.objects.get(objectId); + if (!data) { + stack.delete(cacheKey); + return null; + } + + let created: THREE.Object3D | null = null; + if (data.mesh) { + created = meshToThreeObject(data.mesh); + } else if (data.components?.length) { + const group = new THREE.Group(); + for (const component of data.components) { + const child = instantiateFromDoc( + component.sourcePath ?? doc.path, + component.objectId, + stack + ); + if (!child) continue; + if (component.transform) { + child.applyMatrix4(component.transform.clone()); + } + group.add(child); + } + created = group; + } + stack.delete(cacheKey); + + if (!created) return null; + cache.set(cacheKey, created); + return created.clone(true); + }; + + for (const doc of documents.values()) { + if (!doc.buildItems.length) continue; + const docGroup = new THREE.Group(); + for (const item of doc.buildItems) { + const built = instantiateFromDoc( + item.sourcePath ?? doc.path, + item.objectId, + new Set() + ); + if (!built) continue; + if (item.transform) { + built.applyMatrix4(item.transform.clone()); + } + docGroup.add(built); + } + if (docGroup.children.length) { + root.add(docGroup); + } + } + + return root; +} + +function buildSimple3MFDocument(xml: string, label: string): Simple3MFDocument { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "application/xml"); + if (doc.querySelector("parsererror")) { + throw new Error("3MF fallback: invalid XML"); + } + + const path = normalizeModelPath(label); + const objects = new Map(); + for (const objectNode of findElements(doc, "object")) { + const id = objectNode.getAttribute("id"); + if (!id) continue; + const entry: Simple3MFObject = { id }; + const meshNode = findFirstElement(objectNode, "mesh"); + if (meshNode) { + const mesh = parseSimple3MFMesh(meshNode); + if (mesh) entry.mesh = mesh; + } + const componentsNode = findFirstElement(objectNode, "components"); + if (componentsNode) { + const components = parseSimple3MFComponents(componentsNode); + if (components.length) entry.components = components; + } + objects.set(id, entry); + } + + const buildNode = findFirstElement(doc, "build"); + const buildItems = buildNode ? parseSimple3MFBuildItems(buildNode) : []; + return { path, objects, buildItems }; +} + +function meshToThreeObject(mesh: Simple3MFMesh): THREE.Mesh { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(mesh.positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + return new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ color: 0xf1f5f9, metalness: 0.2, roughness: 0.8 }) + ); +} + +function parseSimple3MFMesh(meshNode: Element): Simple3MFMesh | null { + const vertexNodes = findElements(meshNode, "vertex"); + const triangleNodes = findElements(meshNode, "triangle"); + if (!vertexNodes.length || !triangleNodes.length) return null; + + const positions = new Float32Array(vertexNodes.length * 3); + vertexNodes.forEach((vertex, idx) => { + positions[idx * 3 + 0] = parseFloat(vertex.getAttribute("x") || "0"); + positions[idx * 3 + 1] = parseFloat(vertex.getAttribute("y") || "0"); + positions[idx * 3 + 2] = parseFloat(vertex.getAttribute("z") || "0"); + }); + + const indices = new Uint32Array(triangleNodes.length * 3); + triangleNodes.forEach((tri, idx) => { + indices[idx * 3 + 0] = parseInt(tri.getAttribute("v1") || "0", 10); + indices[idx * 3 + 1] = parseInt(tri.getAttribute("v2") || "0", 10); + indices[idx * 3 + 2] = parseInt(tri.getAttribute("v3") || "0", 10); + }); + + return { positions, indices }; +} + +function parseSimple3MFComponents(node: Element): Simple3MFComponent[] { + const components: Simple3MFComponent[] = []; + for (const componentNode of findElements(node, "component")) { + const objectId = componentNode.getAttribute("objectid"); + if (!objectId) continue; + const transformAttr = componentNode.getAttribute("transform"); + const pathAttr = getAttributeByLocalName(componentNode, "path"); + components.push({ + objectId, + transform: transformAttr ? parse3MFMatrix(transformAttr) : undefined, + sourcePath: pathAttr ? normalizeModelPath(pathAttr) : undefined, + }); + } + return components; +} + +function parseSimple3MFBuildItems(node: Element): Simple3MFBuildItem[] { + const items: Simple3MFBuildItem[] = []; + for (const itemNode of findElements(node, "item")) { + const objectId = itemNode.getAttribute("objectid"); + if (!objectId) continue; + const transformAttr = itemNode.getAttribute("transform"); + const pathAttr = getAttributeByLocalName(itemNode, "path"); + items.push({ + objectId, + transform: transformAttr ? parse3MFMatrix(transformAttr) : undefined, + sourcePath: pathAttr ? normalizeModelPath(pathAttr) : undefined, + }); + } + return items; +} + +function parse3MFMatrix(transform: string): THREE.Matrix4 | null { + const parts = transform + .trim() + .split(/\s+/) + .map(v => parseFloat(v)) + .filter(v => !Number.isNaN(v)); + if (parts.length !== 12) return null; + const matrix = new THREE.Matrix4(); + matrix.set( + parts[0], + parts[3], + parts[6], + parts[9], + parts[1], + parts[4], + parts[7], + parts[10], + parts[2], + parts[5], + parts[8], + parts[11], + 0, + 0, + 0, + 1 + ); + return matrix; +} + +function getAttributeByLocalName(node: Element, localName: string): string | null { + const direct = node.getAttribute(localName); + if (direct !== null) { + return direct; + } + if (typeof node.getAttributeNames === "function") { + for (const attrName of node.getAttributeNames()) { + const idx = attrName.indexOf(":"); + if (idx === -1) continue; + if (attrName.slice(idx + 1) === localName) { + const value = node.getAttribute(attrName); + if (value !== null) { + return value; + } + } + } + } else { + for (const prefix of ["p", "m", "s"]) { + const fallback = node.getAttribute(`${prefix}:${localName}`); + if (fallback !== null) { + return fallback; + } + } + } + return null; +} + +function normalizeModelPath(path: string): string { + const trimmed = (path || "").trim(); + if (!trimmed) return "/3D/3dmodel.model"; + let normalized = trimmed.replace(/\\/g, "/"); + normalized = normalized.replace(/^\/+/, "/"); + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + return normalized; +} + +function findElements(node: Document | Element, localName: string) { + const direct = Array.from(node.getElementsByTagName(localName)); + if (direct.length) return direct; + if ("getElementsByTagNameNS" in node) { + const nsMatches = (node as unknown as NamespacedNode).getElementsByTagNameNS("*", localName); + return Array.from(nsMatches ?? []); + } + return []; +} + +function findFirstElement(node: Document | Element, localName: string) { + return findElements(node, localName)[0] ?? null; +} + +async function loadStepGroup(url: string) { + type OcctMesh = { + color?: [number, number, number]; + attributes: { + position?: { array: ArrayLike }; + normal?: { array: ArrayLike }; + }; + index?: { array: ArrayLike }; + }; + + const initOcct = (await import("occt-import-js")).default; + const resp = await fetch(url); + const buf = new Uint8Array(await resp.arrayBuffer()); + const occt = await initOcct({ + locateFile: (file: string) => { + if (file.endsWith(".wasm")) return occtWasmUrl; + if (file.endsWith(".worker.js")) return occtWorkerUrl; + return file; + }, + }); + const res = await occt.ReadStepFile(buf, null); + + const group = new THREE.Group(); + for (const m of res.meshes as OcctMesh[]) { + const pos = m.attributes?.position?.array; + if (!pos || !pos.length) continue; + + const geom = new THREE.BufferGeometry(); + const toFloat32 = (data: ArrayLike) => Float32Array.from(data); + + geom.setAttribute("position", new THREE.Float32BufferAttribute(toFloat32(pos), 3)); + + const normals = m.attributes?.normal?.array; + if (normals && normals.length) { + geom.setAttribute("normal", new THREE.Float32BufferAttribute(toFloat32(normals), 3)); + } + + const indices = m.index?.array; + if (indices && indices.length) { + geom.setIndex( + Array.isArray(indices) ? indices : Array.from(indices as ArrayLike) + ); + } else { + geom.computeVertexNormals(); + } + geom.computeBoundingSphere(); + + const color = m.color + ? new THREE.Color(m.color[0] / 255, m.color[1] / 255, m.color[2] / 255) + : new THREE.Color(0xf1f5f9); + const mesh = new THREE.Mesh( + geom, + new THREE.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.8 }) + ); + group.add(mesh); + } + + if (!group.children.length) { + throw new Error("STEP preview produced no meshes"); + } + return group; +} + +async function load3MFObject(url: string) { + try { + return await loadSimple3MFGroup(url); + } catch (err) { + console.warn("Simple 3MF parse failed, falling back to ThreeMFLoader", err); + return await loadViaThreeMFLoader(url); + } +} + +async function loadViaThreeMFLoader(url: string) { + const { ThreeMFLoader } = await import("three/examples/jsm/loaders/3MFLoader.js"); + return await new Promise((resolve, reject) => { + new ThreeMFLoader().load(url, resolve, undefined, reject); + }); +} diff --git a/frontend/src/services/modelSnapshotCache.ts b/frontend/src/services/modelSnapshotCache.ts new file mode 100644 index 0000000..04cb0c9 --- /dev/null +++ b/frontend/src/services/modelSnapshotCache.ts @@ -0,0 +1,93 @@ +// Card-thumbnail snapshot generation for 3D models: a serialized job queue (only one WebGL +// snapshot renderer is ever live at a time, since spinning up many WebGL contexts concurrently is +// what caused the original flakiness this queue was written to avoid), a data-URL cache keyed by +// plate id (falling back to the asset URL), and the actual "render one frame of the model to a +// PNG" routine. Used by ModelSnapshot (routes/library/components/ModelViewer/components); no +// React dependency here, hence services/ rather than utils/. +import * as THREE from "three"; +import type { ResolvedTheme } from "../constants/settingsOptions"; +import { applyThemeToObject, disposeObject3D, loadObjectFromAsset, paletteForTheme } from "./modelLoaders"; + +export const snapshotCache = new Map(); + +let snapshotRenderer: THREE.WebGLRenderer | null = null; +let snapshotLock: Promise = Promise.resolve(); +let snapshotJobQueue: Promise = Promise.resolve(); + +/** Serializes snapshot jobs one-at-a-time across every ModelSnapshot instance on the page. */ +export function queueSnapshotJob(job: () => Promise): Promise { + const result = snapshotJobQueue.then(job, job); + snapshotJobQueue = result.then(() => undefined, () => undefined); + return result; +} + +function createSnapshotRenderer() { + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + return renderer; +} + +async function acquireSnapshotRenderer() { + let release!: () => void; + const wait = snapshotLock; + snapshotLock = snapshotLock.then(() => new Promise(resolve => (release = resolve))); + await wait; + snapshotRenderer = createSnapshotRenderer(); + const renderer = snapshotRenderer; + const unlock = () => { + try { + renderer.forceContextLoss?.(); + renderer.dispose(); + } catch {} + snapshotRenderer = null; + release(); + }; + return { renderer, release: unlock }; +} + +export async function generateModelSnapshot( + url: string, + ext: string, + width: number, + height: number, + theme: ResolvedTheme +) { + const normalized = (ext || "").toLowerCase(); + const object = await loadObjectFromAsset(normalized, url); + if (!object) { + throw new Error(`Unsupported snapshot extension: ${ext}`); + } + applyThemeToObject(object, paletteForTheme(theme)); + const scene = new THREE.Scene(); + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2)); + scene.add(new THREE.AmbientLight(0xffffff, 0.8)); + const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); + const { renderer, release } = await acquireSnapshotRenderer(); + renderer.setSize(width, height, false); + scene.add(object); + + const box = new THREE.Box3().setFromObject(object); + if (!box.isEmpty()) { + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z) || 1; + camera.position.copy(center).add(new THREE.Vector3(radius * 1.8, radius * 1.3, radius * 1.6)); + camera.lookAt(center); + } else { + camera.position.set(1, 1, 3); + camera.lookAt(new THREE.Vector3(0, 0, 0)); + } + + try { + renderer.render(scene, camera); + const dataUrl = renderer.domElement.toDataURL("image/png"); + disposeObject3D(object); + return dataUrl; + } finally { + release(); + } +} diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts new file mode 100644 index 0000000..2c5b6bb --- /dev/null +++ b/frontend/src/services/settings.ts @@ -0,0 +1,142 @@ +import { ENGRAVER_OPTIONS, SLICER_OPTIONS, THEME_OPTIONS, type ThemeSelection } from "../constants/settingsOptions"; + +export type SlicerSettings = { + enabled: boolean; + selected: string; +}; + +export type EngravingSettings = { + enabled: boolean; + selected: string; +}; + +export type ThemeSettings = { + selected: ThemeSelection; +}; + +export type MakerWorldSettings = { + cookie: string; +}; + +export type ThingiverseSettings = { + cookie: string; +}; + +export type NetworkSettings = { + publicUrl: string; +}; + +export type PreviewMode = "automatic" | "on-demand" | "disabled"; + +export type PreviewSettings = { + mode: PreviewMode; +}; + +export type AppSettings = { + slicer: SlicerSettings; + engraving: EngravingSettings; + theme: ThemeSettings; + makerworld: MakerWorldSettings; + thingiverse: ThingiverseSettings; + network: NetworkSettings; + previews: PreviewSettings; +}; + +const STORAGE_KEY = "printstash_settings"; + +const DEFAULT_SETTINGS: AppSettings = { + slicer: { + enabled: false, + selected: "orca", + }, + engraving: { + enabled: false, + selected: "lightburn", + }, + theme: { + selected: "system", + }, + makerworld: { + cookie: "", + }, + thingiverse: { + cookie: "", + }, + network: { + publicUrl: "", + }, + previews: { + mode: "automatic", + }, +}; + +export function loadSettings(): AppSettings { + if (typeof window === "undefined") return DEFAULT_SETTINGS; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) || {} : {}; + const slicer = parsed.slicer || {}; + const selected = typeof slicer.selected === "string" ? slicer.selected : DEFAULT_SETTINGS.slicer.selected; + const enabled = typeof slicer.enabled === "boolean" ? slicer.enabled : DEFAULT_SETTINGS.slicer.enabled; + const valid = SLICER_OPTIONS.some(opt => opt.id === selected); + const engraving = parsed.engraving || {}; + const engravingSelected = typeof engraving.selected === "string" + ? engraving.selected + : DEFAULT_SETTINGS.engraving.selected; + const engravingEnabled = typeof engraving.enabled === "boolean" + ? engraving.enabled + : DEFAULT_SETTINGS.engraving.enabled; + const engravingValid = ENGRAVER_OPTIONS.some(opt => opt.id === engravingSelected); + const theme = parsed.theme || {}; + const themeSelected = typeof theme.selected === "string" ? theme.selected : DEFAULT_SETTINGS.theme.selected; + const themeValid = THEME_OPTIONS.some(opt => opt.id === themeSelected); + const makerworld = parsed.makerworld || {}; + const cookie = typeof makerworld.cookie === "string" ? makerworld.cookie : DEFAULT_SETTINGS.makerworld.cookie; + const thingiverse = parsed.thingiverse || {}; + const thingiverseCookie = typeof thingiverse.cookie === "string" + ? thingiverse.cookie + : DEFAULT_SETTINGS.thingiverse.cookie; + const network = parsed.network || {}; + const publicUrl = typeof network.publicUrl === "string" ? network.publicUrl : DEFAULT_SETTINGS.network.publicUrl; + const previews = parsed.previews || {}; + const previewMode = ["automatic", "on-demand", "disabled"].includes(previews.mode) + ? previews.mode as PreviewMode + : DEFAULT_SETTINGS.previews.mode; + return { + slicer: { + enabled, + selected: valid ? selected : DEFAULT_SETTINGS.slicer.selected, + }, + engraving: { + enabled: engravingEnabled, + selected: engravingValid ? engravingSelected : DEFAULT_SETTINGS.engraving.selected, + }, + theme: { + selected: (themeValid ? themeSelected : DEFAULT_SETTINGS.theme.selected) as ThemeSelection, + }, + makerworld: { + cookie, + }, + thingiverse: { + cookie: thingiverseCookie, + }, + network: { + publicUrl, + }, + previews: { + mode: previewMode, + }, + }; + } catch { + return DEFAULT_SETTINGS; + } +} + +export function saveSettings(settings: AppSettings) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // ignore storage errors + } +} diff --git a/frontend/src/services/uploadTree.ts b/frontend/src/services/uploadTree.ts new file mode 100644 index 0000000..f3ff53d --- /dev/null +++ b/frontend/src/services/uploadTree.ts @@ -0,0 +1,182 @@ +import { UnauthorizedError, createFolder, uploadPrints } from "./api"; + +export type UploadEntry = { + file: File; + relativePath: string; +}; + +type FileSystemEntry = { + isFile: boolean; + isDirectory: boolean; + name: string; + file?: (success: (file: File) => void, error?: (err: unknown) => void) => void; + createReader?: () => FileSystemDirectoryReader; +}; + +type FileSystemDirectoryReader = { + readEntries: (success: (entries: FileSystemEntry[]) => void, error?: (err: unknown) => void) => void; +}; + +// File System Access API handles (window.showDirectoryPicker()) -- a distinct browser API from +// the drag-and-drop DataTransferItem.webkitGetAsEntry() one modeled above by FileSystemEntry. +export type FileSystemAccessHandle = { kind: "file" | "directory"; name: string }; +export type FileSystemAccessFileHandle = FileSystemAccessHandle & { getFile: () => Promise }; +export type FileSystemAccessDirectoryHandle = FileSystemAccessHandle & { + entries: () => AsyncIterableIterator<[string, FileSystemAccessHandle]>; +}; +export type DirectoryPicker = () => Promise; + +function normalizeRelativePath(path: string) { + const trimmed = (path || "").replace(/\\/g, "/").replace(/^\/+/, ""); + return trimmed || ""; +} + +export function entriesFromFileList(files: FileList | File[]): UploadEntry[] { + return Array.from(files || []).map(file => { + const anyFile = file as File & { webkitRelativePath?: string }; + const relativePath = normalizeRelativePath(anyFile.webkitRelativePath || file.name); + return { file, relativePath: relativePath || file.name }; + }); +} + +async function readAllEntries(reader: FileSystemDirectoryReader) { + const entries: FileSystemEntry[] = []; + while (true) { + const batch = await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + if (!batch.length) break; + entries.push(...batch); + } + return entries; +} + +async function traverseEntry(entry: FileSystemEntry, parentPath: string, output: UploadEntry[]) { + const entryPath = normalizeRelativePath(parentPath ? `${parentPath}/${entry.name}` : entry.name); + if (entry.isFile && entry.file) { + const file = await new Promise((resolve, reject) => entry.file?.(resolve, reject)); + output.push({ file, relativePath: entryPath || file.name }); + return; + } + if (entry.isDirectory && entry.createReader) { + const reader = entry.createReader(); + const entries = await readAllEntries(reader); + await Promise.all(entries.map(child => traverseEntry(child, entryPath, output))); + } +} + +export async function entriesFromDataTransfer(dataTransfer: DataTransfer): Promise { + const output: UploadEntry[] = []; + const items = Array.from(dataTransfer.items || []); + const entryItems = items + .map(item => (item as unknown as { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry?.()) + .filter(Boolean) as FileSystemEntry[]; + + if (entryItems.length) { + await Promise.all(entryItems.map(entry => traverseEntry(entry, "", output))); + return output; + } + + for (const item of items) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (!file) continue; + const anyFile = file as File & { webkitRelativePath?: string }; + const relativePath = normalizeRelativePath(anyFile.webkitRelativePath || file.name); + output.push({ file, relativePath: relativePath || file.name }); + } + + if (!output.length) { + return entriesFromFileList(dataTransfer.files || []); + } + return output; +} + +/** Recursively walks a File System Access API directory handle (from window.showDirectoryPicker()) + * into a flat UploadEntry[], with relativePath rooted at the picked directory's own name. */ +export async function entriesFromDirectoryHandle(root: FileSystemAccessDirectoryHandle): Promise { + const output: UploadEntry[] = []; + const walk = async (dir: FileSystemAccessDirectoryHandle, basePath: string) => { + for await (const [, handle] of dir.entries()) { + if (handle.kind === "file") { + const file = await (handle as FileSystemAccessFileHandle).getFile(); + const relativePath = basePath ? `${basePath}/${handle.name}` : handle.name; + output.push({ file, relativePath }); + } else if (handle.kind === "directory") { + const nextPath = basePath ? `${basePath}/${handle.name}` : handle.name; + await walk(handle as FileSystemAccessDirectoryHandle, nextPath); + } + } + }; + const rootPath = root.name || ""; + await walk(root, rootPath); + return output; +} + +export async function uploadEntriesToFolder( + entries: UploadEntry[], + parentFolderId: string | null, + onUnauthorized?: () => void +) { + const failed: string[] = []; + let uploaded = 0; + let aborted = false; + const uploadedEntries: UploadEntry[] = []; + const folderCache = new Map(); + const baseKey = parentFolderId || "root"; + + const getOrCreateFolder = async (parentId: string | null, parentKey: string, name: string) => { + const key = `${parentKey}/${name}`; + const existing = folderCache.get(key); + if (existing) return existing; + const created = await createFolder(name, [], parentId || undefined); + const folderId = (created as { id: string }).id; + folderCache.set(key, folderId); + return folderId; + }; + + for (const entry of entries) { + const normalized = normalizeRelativePath(entry.relativePath || entry.file.name); + const segments = normalized.split("/").filter(Boolean); + if (!segments.length) continue; + segments.pop(); + let targetFolderId = parentFolderId; + let parentKey = baseKey; + for (const segment of segments) { + try { + targetFolderId = await getOrCreateFolder(targetFolderId, parentKey, segment); + parentKey = `${parentKey}/${segment}`; + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + aborted = true; + break; + } + console.error("Folder creation failed for", segment, err); + failed.push(entry.file.name); + targetFolderId = null; + break; + } + } + if (aborted) break; + if (targetFolderId === null && segments.length) continue; + try { + // Every leaf of a folder tree (dropped folder or webkitdirectory picker) + // is always its own single-plate print, so this is always a one-file + // upload -- the separate/multiplate mode never applies here. + await uploadPrints([entry.file], { folder_id: targetFolderId || undefined }); + uploaded += 1; + uploadedEntries.push(entry); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + aborted = true; + break; + } + console.error("Upload failed for", entry.file.name, err); + const message = err instanceof Error ? err.message.trim() : ""; + failed.push(message && message !== "Upload failed" ? `${entry.file.name} (${message})` : entry.file.name); + } + } + return { uploaded, failed, uploadedEntries }; +} diff --git a/frontend/src/services/zipUtils.ts b/frontend/src/services/zipUtils.ts new file mode 100644 index 0000000..0aebb42 --- /dev/null +++ b/frontend/src/services/zipUtils.ts @@ -0,0 +1,86 @@ +import type { UploadEntry } from "./uploadTree"; + +export type ZipEntry = { + path: string; + size: number; +}; + +const MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".stl": "model/stl", + ".3mf": "model/3mf", + ".step": "model/step", + ".stp": "model/step", + ".obj": "model/obj", +}; + +function normalizePath(path: string) { + return (path || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); +} + +export function normalizeZipPath(path: string): string | null { + const raw = (path || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!raw || raw.endsWith("/")) return null; + const cleaned = normalizePath(raw); + if (!cleaned) return null; + const parts = cleaned.split("/").filter(Boolean); + if (!parts.length || parts.some(part => part === "..")) return null; + return parts.join("/"); +} + +export function guessMimeType(name: string) { + const lower = (name || "").toLowerCase(); + const dot = lower.lastIndexOf("."); + if (dot === -1) return ""; + const ext = lower.slice(dot); + return MIME_BY_EXT[ext] || ""; +} + +export function isZipFile(name: string) { + return (name || "").toLowerCase().endsWith(".zip"); +} + +export async function readZipEntries(file: File): Promise<{ entries: ZipEntry[]; data: Record }> { + const { unzipSync } = await import("fflate"); + const raw = new Uint8Array(await file.arrayBuffer()); + const zipEntries = unzipSync(raw); + const data: Record = {}; + for (const [rawPath, content] of Object.entries(zipEntries)) { + const normalized = normalizeZipPath(rawPath); + if (!normalized) continue; + data[normalized] = content; + } + const entries = Object.keys(data) + .sort((a, b) => a.localeCompare(b)) + .map(path => ({ path, size: data[path].length })); + return { entries, data }; +} + +export function buildUploadEntriesFromZip( + data: Record, + selected: string[], + basePath = "" +): UploadEntry[] { + const entries: UploadEntry[] = []; + const normalizedBase = normalizePath(basePath); + const seen = new Set(); + for (const raw of selected) { + const path = normalizeZipPath(raw); + if (!path || seen.has(path)) continue; + seen.add(path); + const content = data[path]; + if (!content) continue; + const parts = path.split("/"); + const filename = parts[parts.length - 1]; + const type = guessMimeType(filename); + const file = new File([content], filename, type ? { type } : undefined); + const relativePath = normalizedBase ? `${normalizedBase}/${path}` : path; + entries.push({ file, relativePath }); + } + return entries; +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..bcd108a --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,184 @@ +import { createTheme, type Theme, type ThemeOptions } from "@mui/material/styles"; +import type { ResolvedTheme } from "./constants/settingsOptions"; + +export type { ResolvedTheme }; + +export const THEME_IDS: ResolvedTheme[] = ["light", "dark", "neon", "purple", "blue"]; + +/** + * Extra design tokens MUI's Theme doesn't model natively: the page background (a plain color + * for light/dark, a layered radial-gradient CSS value for the neon/purple/blue themes) and the + * three.js model material colors used by ModelViewer's paletteForTheme(). + */ +declare module "@mui/material/styles" { + interface Theme { + printstash: { + pageBackground: string; + modelColor: string; + modelEmissive: string; + }; + } + interface ThemeOptions { + printstash: { + pageBackground: string; + modelColor: string; + modelEmissive: string; + }; + } +} + +type ThemeDef = { + mode: "light" | "dark"; + pageBackground: string; + panel: string; + panelStrong: string; + border: string; + borderStrong: string; + text: string; + textMuted: string; + textSubtle: string; + accent: string; + accentStrong: string; + accentSoft: string; + accentContrast: string; + modelColor: string; + modelEmissive: string; +}; + +// Values ported 1:1 from the original CSS custom properties so the visual identity of each +// theme carries over even though the implementation is now an MUI theme object. +const THEME_DEFS: Record = { + light: { + mode: "light", + pageBackground: "#f8fafc", + panel: "rgba(255, 255, 255, 0.92)", + panelStrong: "#ffffff", + border: "#e5e7eb", + borderStrong: "#d1d5db", + text: "#0f172a", + textMuted: "#64748b", + textSubtle: "#94a3b8", + accent: "#10b981", + accentStrong: "#059669", + accentSoft: "rgba(16, 185, 129, 0.16)", + accentContrast: "#ffffff", + modelColor: "#cbd5e1", + modelEmissive: "#94a3b8", + }, + dark: { + mode: "dark", + pageBackground: "#0a0b0c", + panel: "rgba(15, 23, 42, 0.86)", + panelStrong: "#0f172a", + border: "#1f2937", + borderStrong: "#334155", + text: "#f8fafc", + textMuted: "#94a3b8", + textSubtle: "#64748b", + accent: "#34d399", + accentStrong: "#10b981", + accentSoft: "rgba(16, 185, 129, 0.2)", + accentContrast: "#ffffff", + modelColor: "#e2e8f0", + modelEmissive: "#475569", + }, + neon: { + mode: "dark", + pageBackground: + "radial-gradient(1200px circle at 15% 10%, rgba(182, 255, 43, 0.2), transparent 60%)," + + "radial-gradient(900px circle at 85% 0%, rgba(120, 255, 66, 0.14), transparent 55%), #050b05", + panel: "rgba(8, 18, 9, 0.9)", + panelStrong: "#0a1508", + border: "#1d2f1a", + borderStrong: "#2f4a25", + text: "#e5ffd2", + textMuted: "#9ad48a", + textSubtle: "#6fa662", + accent: "#b6ff2b", + accentStrong: "#d7ff6f", + accentSoft: "rgba(182, 255, 43, 0.18)", + accentContrast: "#071308", + modelColor: "#b6ff2b", + modelEmissive: "#3a7a1a", + }, + purple: { + mode: "dark", + pageBackground: + "radial-gradient(1200px circle at 18% 10%, rgba(199, 125, 255, 0.22), transparent 60%)," + + "radial-gradient(900px circle at 82% 0%, rgba(146, 88, 255, 0.16), transparent 55%), #080510", + panel: "rgba(16, 10, 27, 0.9)", + panelStrong: "#12091f", + border: "#2b1e44", + borderStrong: "#3b2961", + text: "#f3e8ff", + textMuted: "#c4b5fd", + textSubtle: "#8b79b8", + accent: "#c77dff", + accentStrong: "#e4c8ff", + accentSoft: "rgba(199, 125, 255, 0.18)", + accentContrast: "#12071a", + modelColor: "#c77dff", + modelEmissive: "#6a2da8", + }, + blue: { + mode: "dark", + pageBackground: + "radial-gradient(1200px circle at 20% 8%, rgba(116, 212, 255, 0.2), transparent 60%)," + + "radial-gradient(900px circle at 80% 0%, rgba(66, 145, 255, 0.16), transparent 55%), #050b12", + panel: "rgba(8, 16, 28, 0.9)", + panelStrong: "#0a1324", + border: "#1b2a42", + borderStrong: "#274166", + text: "#e0f2ff", + textMuted: "#93c5fd", + textSubtle: "#6b8dbb", + accent: "#74d4ff", + accentStrong: "#b6ecff", + accentSoft: "rgba(116, 212, 255, 0.18)", + accentContrast: "#061019", + modelColor: "#74d4ff", + modelEmissive: "#1f5c9a", + }, +}; + +export function buildTheme(id: ResolvedTheme): Theme { + const d = THEME_DEFS[id]; + const options: ThemeOptions = { + palette: { + mode: d.mode, + background: { default: d.mode === "light" ? d.pageBackground : d.panelStrong, paper: d.panel }, + primary: { main: d.accent, dark: d.accentStrong, contrastText: d.accentContrast }, + text: { primary: d.text, secondary: d.textMuted }, + divider: d.border, + }, + shape: { borderRadius: 10 }, + typography: { + fontSize: 13, + button: { textTransform: "none", fontWeight: 600 }, + }, + components: { + MuiPaper: { styleOverrides: { root: { backgroundImage: "none", backgroundColor: d.panel } } }, + MuiAppBar: { styleOverrides: { root: { backgroundColor: d.panelStrong, color: d.text } } }, + MuiDrawer: { styleOverrides: { paper: { backgroundColor: d.panelStrong, borderColor: d.border } } }, + MuiButton: { styleOverrides: { root: { borderRadius: 8 } } }, + MuiChip: { styleOverrides: { root: { borderRadius: 6 } } }, + MuiTooltip: { styleOverrides: { tooltip: { backgroundColor: d.panelStrong, color: d.text } } }, + }, + printstash: { + pageBackground: d.pageBackground, + modelColor: d.modelColor, + modelEmissive: d.modelEmissive, + }, + }; + return createTheme(options); +} + +/** textSubtle isn't part of MUI's palette shape; components that need it read this directly. */ +export function subtleTextColor(id: ResolvedTheme): string { + return THEME_DEFS[id].textSubtle; +} + +/** accentSoft (a translucent tint of the accent color) isn't part of MUI's palette shape either. */ +export function accentSoftColor(id: ResolvedTheme): string { + return THEME_DEFS[id].accentSoft; +} diff --git a/frontend/src/types/occt-import-js.d.ts b/frontend/src/types/occt-import-js.d.ts new file mode 100644 index 0000000..ca3d3e4 --- /dev/null +++ b/frontend/src/types/occt-import-js.d.ts @@ -0,0 +1 @@ +declare module "occt-import-js"; diff --git a/frontend/src/utils/directoryScan.ts b/frontend/src/utils/directoryScan.ts new file mode 100644 index 0000000..f4f95d5 --- /dev/null +++ b/frontend/src/utils/directoryScan.ts @@ -0,0 +1,64 @@ +import { SCAN_EXT_SET } from "../constants/fileTypes"; +import { extOf } from "./fileExtensions"; +import type { UploadEntry } from "../services/uploadTree"; + +function normalizeRelativePath(path: string) { + return (path || "").replace(/\\/g, "/").replace(/^\/+/, ""); +} + +/** + * The single top-level path segment shared by every entry (e.g. "MyModels" for + * "MyModels/a.stl" and "MyModels/sub/b.stl"), or "" if the entries don't share one -- + * which is always the case for a flat file-picker selection with no folder structure. + */ +export function findRootSegment(entries: UploadEntry[]): string { + if (!entries.length) return ""; + const first = normalizeRelativePath(entries[0].relativePath || entries[0].file.name); + const parts = first.split("/").filter(Boolean); + if (parts.length < 2) return ""; + const root = parts[0]; + if (!root) return ""; + for (const entry of entries) { + const normalized = normalizeRelativePath(entry.relativePath || entry.file.name); + if (!normalized.startsWith(`${root}/`)) { + return ""; + } + } + return root; +} + +/** + * Filters a raw folder-scan selection down to extensions the scan feature supports, and + * optionally strips a shared leading root segment from each entry's relative path. + * Returns the filtered entries plus how many were skipped for having an unsupported extension. + */ +export function prepareScanEntries(entries: UploadEntry[], rootSegment: string, stripRoot: boolean) { + const filtered: UploadEntry[] = []; + let skipped = 0; + for (const entry of entries) { + const ext = extOf(entry.file.name); + if (!SCAN_EXT_SET.has(ext)) { + skipped += 1; + continue; + } + let relativePath = normalizeRelativePath(entry.relativePath || entry.file.name); + if (stripRoot && rootSegment) { + const parts = relativePath.split("/").filter(Boolean); + if (parts[0] === rootSegment) { + parts.shift(); + relativePath = parts.join("/"); + } + } + if (!relativePath) { + relativePath = entry.file.name; + } + filtered.push({ ...entry, relativePath }); + } + return { entries: filtered, skipped }; +} + +/** Stable dedup key for an upload entry, used to avoid re-uploading the same scanned file twice. */ +export function entryKey(entry: UploadEntry): string { + const relative = normalizeRelativePath(entry.relativePath || entry.file.name); + return `${relative}::${entry.file.size}::${entry.file.lastModified}`; +} diff --git a/frontend/src/utils/duration.ts b/frontend/src/utils/duration.ts new file mode 100644 index 0000000..9eef98c --- /dev/null +++ b/frontend/src/utils/duration.ts @@ -0,0 +1,15 @@ +import type { TFunction } from "i18next"; + +/** Formats a prepared-print's estimated duration as "1 d. 2 val. 3 min." style text. */ +export function formatPreparedDuration(t: TFunction, totalSeconds?: number | null): string | null { + if (!totalSeconds || totalSeconds < 1) return null; + const roundedMinutes = Math.max(1, Math.round(totalSeconds / 60)); + const days = Math.floor(roundedMinutes / 1440); + const hours = Math.floor((roundedMinutes % 1440) / 60); + const minutes = roundedMinutes % 60; + return [ + days ? t("library:duration.days", { count: days }) : "", + hours ? t("library:duration.hours", { count: hours }) : "", + minutes ? t("library:duration.minutes", { count: minutes }) : "", + ].filter(Boolean).join(" "); +} diff --git a/frontend/src/utils/fileExtensions.ts b/frontend/src/utils/fileExtensions.ts new file mode 100644 index 0000000..51f2f67 --- /dev/null +++ b/frontend/src/utils/fileExtensions.ts @@ -0,0 +1,5 @@ +/** Lowercased file extension without the leading dot, e.g. "stl" for "model.STL". */ +export function extOf(name: string): string { + const match = /\.([^.]+)$/.exec(name || ""); + return (match?.[1] || "").toLowerCase(); +} diff --git a/frontend/src/utils/fileSize.ts b/frontend/src/utils/fileSize.ts new file mode 100644 index 0000000..1af6d2e --- /dev/null +++ b/frontend/src/utils/fileSize.ts @@ -0,0 +1,11 @@ +export function formatFileSize(bytes: number): string { + if (!bytes && bytes !== 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let size = bytes; + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx++; + } + return `${size.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; +} diff --git a/frontend/src/utils/settingsHelpers.test.ts b/frontend/src/utils/settingsHelpers.test.ts new file mode 100644 index 0000000..037464f --- /dev/null +++ b/frontend/src/utils/settingsHelpers.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { + engraverLabelFor, + resolveTheme, + slicerLabelFor, +} from "./settingsHelpers"; + + +describe("application settings helpers", () => { + it("uses stable labels for configured applications", () => { + expect(slicerLabelFor("orca")).toBe("OrcaSlicer"); + expect(engraverLabelFor("lightburn")).toBe("LightBurn"); + }); + + it("uses safe fallback labels for unknown applications", () => { + expect(slicerLabelFor("unknown")).toBe("Slicer"); + expect(engraverLabelFor(null)).toBe("Engraving"); + }); + + it("resolves system theme safely without a browser window", () => { + expect(resolveTheme("system")).toBe("light"); + expect(resolveTheme("purple")).toBe("purple"); + }); +}); diff --git a/frontend/src/utils/settingsHelpers.ts b/frontend/src/utils/settingsHelpers.ts new file mode 100644 index 0000000..9aafa9c --- /dev/null +++ b/frontend/src/utils/settingsHelpers.ts @@ -0,0 +1,23 @@ +import { ENGRAVER_OPTIONS, SLICER_OPTIONS, type ResolvedTheme, type ThemeSelection } from "../constants/settingsOptions"; + +export function slicerLabelFor(id?: string | null) { + if (!id) return "Slicer"; + const match = SLICER_OPTIONS.find(opt => opt.id === id); + return match ? match.label : "Slicer"; +} + +export function engraverLabelFor(id?: string | null) { + if (!id) return "Engraving"; + const match = ENGRAVER_OPTIONS.find(opt => opt.id === id); + return match ? match.label : "Engraving"; +} + +export function resolveTheme(selected: ThemeSelection): ResolvedTheme { + if (selected === "system") { + if (typeof window !== "undefined") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return "light"; + } + return selected; +} diff --git a/frontend/src/utils/tagColors.ts b/frontend/src/utils/tagColors.ts new file mode 100644 index 0000000..523576f --- /dev/null +++ b/frontend/src/utils/tagColors.ts @@ -0,0 +1,22 @@ +const PALETTE = [ + { bg: "#fee2e2", text: "#991b1b", border: "#fecaca" }, + { bg: "#ffedd5", text: "#9a3412", border: "#fed7aa" }, + { bg: "#fef9c3", text: "#854d0e", border: "#fde68a" }, + { bg: "#dcfce7", text: "#166534", border: "#bbf7d0" }, + { bg: "#e0f2fe", text: "#075985", border: "#bae6fd" }, + { bg: "#e0e7ff", text: "#3730a3", border: "#c7d2fe" }, + { bg: "#f4e8ff", text: "#6d28d9", border: "#e9d5ff" }, + { bg: "#fce7f3", text: "#9d174d", border: "#fbcfe8" }, + { bg: "#f1f5f9", text: "#475569", border: "#e2e8f0" }, +]; + +export function colorForTag(tag: string) { + const normalized = tag.toLowerCase(); + let hash = 0; + for (let i = 0; i < normalized.length; i++) { + hash = (hash << 5) - hash + normalized.charCodeAt(i); + hash |= 0; + } + const palette = PALETTE[Math.abs(hash) % PALETTE.length]; + return palette; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..eb0d312 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "lib": ["ES2020", "DOM"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1aa8db4 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,67 @@ +import { defineConfig } from "vite"; + +function normalizeHostEntry(entry) { + // Accept full URLs or bare hostnames and strip protocol/port when present. + try { + const parsed = new URL(entry); + return parsed.hostname; + } catch { + return entry.split(":")[0]; // handle host:port input + } +} + +function parseAllowedHosts(value, extras = []) { + const extraHosts = extras.map(normalizeHostEntry).filter(Boolean); + + if (!value) { + // Default to permissive host handling in containerized/reverse-proxy setups. + // Users can still explicitly lock this down with VITE_ALLOWED_HOSTS. + return true; + } + + const normalized = value + .split(",") + .map((host) => host.trim()) + .filter(Boolean) + .map(normalizeHostEntry); + + const merged = Array.from(new Set([...normalized, ...extraHosts])); + + if (!merged.length) return true; + + if ( + merged.length === 1 && + ["*", "true", "1"].includes(merged[0].toLowerCase()) + ) { + return true; + } + + return merged; +} + +const apiUrlHost = (() => { + const url = process.env.VITE_API_URL; + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return null; + } +})(); + +const resolvedAllowedHosts = parseAllowedHosts( + process.env.VITE_ALLOWED_HOSTS || + process.env.ALLOWED_HOSTS || + process.env.CORS_ORIGINS, + apiUrlHost ? [apiUrlHost] : [] +); + +export default defineConfig({ + server: { + host: true, + allowedHosts: resolvedAllowedHosts, + }, + preview: { + allowedHosts: resolvedAllowedHosts, + }, +});