diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a201813d..c16f5f0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: - name: Set up Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20 + node-version: 24 cache: "npm" cache-dependency-path: src/frontend/package-lock.json @@ -120,6 +120,14 @@ jobs: working-directory: src/frontend run: npm ci + - name: Lint + working-directory: src/frontend + run: npm run lint + + - name: Check formatting + working-directory: src/frontend + run: npm run format:check + - name: Typecheck working-directory: src/frontend run: npm run typecheck diff --git a/.gitignore b/.gitignore index ce389d90..411fc53d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +src/frontend/coverage/ htmlcov/ .tox/ .nox/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dedf6180..fc281b36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,3 +11,12 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format + + - repo: local + hooks: + - id: oxfmt + name: oxfmt + entry: npx --prefix src/frontend oxfmt --config src/frontend/.oxfmtrc.json + language: system + types_or: [javascript, jsx, ts, tsx, css, json] + files: ^src/frontend/ diff --git a/Makefile b/Makefile index c3009dab..443f06f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks python-coverage prek-install +.PHONY: help install install-python-dev dev build preview typecheck frontend-lint frontend-format frontend-format-check frontend-checks frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks python-coverage prek-install # Frontend directory FRONTEND_DIR := src/frontend @@ -17,6 +17,10 @@ help: @echo " build-serve - Build and serve via Flask (test prod build without Docker)" @echo " preview - Preview production build" @echo " typecheck - Run TypeScript type checking" + @echo " frontend-lint - Run Oxlint against frontend code" + @echo " frontend-format - Format frontend code with Oxfmt" + @echo " frontend-format-check - Check frontend formatting with Oxfmt" + @echo " frontend-checks - Run all frontend static analysis checks" @echo " frontend-test - Run frontend unit tests" @echo " install-python-dev - Sync Python runtime + dev tooling with uv" @echo " python-lint - Run Ruff against Python backend code" @@ -52,6 +56,8 @@ install: install-python-dev: @echo "Syncing Python runtime and dev tooling with uv..." uv sync --locked --extra browser + @echo "Installing prek git hooks..." + uv run prek install # Start development server dev: @@ -137,6 +143,23 @@ prek-install: @echo "Installing prek git hooks..." uv run prek install +# Frontend linting +frontend-lint: + @echo "Running Oxlint..." + cd $(FRONTEND_DIR) && npm run lint + +# Frontend formatting +frontend-format: + @echo "Formatting frontend code with Oxfmt..." + cd $(FRONTEND_DIR) && npm run format + +frontend-format-check: + @echo "Checking frontend formatting with Oxfmt..." + cd $(FRONTEND_DIR) && npm run format:check + +# All frontend static analysis +frontend-checks: frontend-lint frontend-format-check typecheck + # Run frontend unit tests frontend-test: @echo "Running frontend unit tests..." diff --git a/src/README.md b/src/README.md index cefb99f2..53f276d2 100644 --- a/src/README.md +++ b/src/README.md @@ -20,12 +20,14 @@ src/ ## Frontend Development ### Prerequisites + - Node.js (v16 or higher) - npm or yarn ### Quick Start From the project root: + ```bash # Install dependencies make install @@ -44,6 +46,7 @@ make typecheck ``` Alternatively, from `src/frontend`: + ```bash npm install npm run dev @@ -51,12 +54,14 @@ npm run build ``` ### Technology Stack + - **Framework**: React 18 with TypeScript - **Build Tool**: Vite 5 - **Styling**: TailwindCSS 3 - **Communication**: WebSocket for real-time updates ### Key Features + - **Search Interface**: Real-time book search with filtering - **Download Queue**: Live status updates via WebSocket - **Details Modal**: Rich book information display @@ -65,22 +70,30 @@ npm run build ## Development Tips ### Hot Module Replacement (HMR) + The development server supports HMR for instant feedback during development. ### API Integration + The frontend communicates with the Flask backend via: + - REST API endpoints (`/api/*`) - WebSocket connection (`ws://localhost:8084/ws`) ### Building for Production + The production build is optimized and minified: + ```bash make build ``` + Output is generated in `src/frontend/dist/` ### Type Safety + Run TypeScript checks without building: + ```bash make typecheck ``` @@ -88,11 +101,13 @@ make typecheck ## Debugging ### Development Server Issues + - Ensure port 5173 is available - Check that the backend is running on port 8084 - Verify WebSocket connection in browser console ### Build Issues + - Clear `node_modules` and reinstall: `make clean && make install` - Check Node.js version compatibility - Verify TypeScript configuration diff --git a/src/frontend/.oxfmtrc.json b/src/frontend/.oxfmtrc.json new file mode 100644 index 00000000..8b58bbf6 --- /dev/null +++ b/src/frontend/.oxfmtrc.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 2, + "useTabs": false, + "sortImports": true, + "sortTailwindcss": true, + "ignorePatterns": ["dist/**"] +} diff --git a/src/frontend/.oxlintrc.json b/src/frontend/.oxlintrc.json new file mode 100644 index 00000000..1bc6580d --- /dev/null +++ b/src/frontend/.oxlintrc.json @@ -0,0 +1,97 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "unicorn", "oxc", "jsx-a11y", "import"], + "options": { + "typeAware": true + }, + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error" + }, + "rules": { + "react/react-in-jsx-scope": "off", + "no-console": "off", + "unicorn/no-null": "off", + "unicorn/filename-case": "off", + "unicorn/prefer-top-level-await": "off", + "import/no-default-export": "error", + "import/no-cycle": ["error", { "maxDepth": 5 }], + "unicorn/no-abusive-eslint-disable": "error", + "no-new-func": "error", + "unicorn/custom-error-definition": "error", + "typescript/no-explicit-any": "error", + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-invalid-void-type": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-require-imports": "error", + "typescript/no-import-type-side-effects": "error", + "typescript/no-misused-promises": "error", + "typescript/no-non-null-assertion": "error", + "typescript/only-throw-error": "error", + "react/no-danger": "error", + "react/no-clone-element": "error", + "react/no-react-children": "error", + "react/button-has-type": "error", + "no-var": "error", + "no-alert": "error", + "no-script-url": "error", + "no-template-curly-in-string": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "no-return-assign": "error", + "import/no-mutable-exports": "error", + "import/no-commonjs": "error", + "typescript/consistent-type-imports": "error", + "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-object-as-default-parameter": "error", + "unicorn/no-nested-ternary": "error", + "unicorn/no-unreadable-iife": "error", + "no-empty-function": "error", + "no-useless-return": "error", + "import/no-duplicates": "error", + "react/jsx-no-useless-fragment": "error", + "react/self-closing-comp": "error", + "typescript/switch-exhaustiveness-check": "error", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react", + "importNames": ["useEffect"], + "message": "Direct useEffect is not allowed. Derive state during render, use event handlers, reset with key, or use useMountEffect / a named sync hook for external system sync. See hooks/useMountEffect.ts and the React docs: https://react.dev/learn/you-might-not-need-an-effect" + } + ] + } + ] + }, + "overrides": [ + { + "files": [ + "src/hooks/useMountEffect.ts", + "src/hooks/useBodyScrollLock.ts", + "src/hooks/useEscapeKey.ts", + "src/hooks/useDismiss.ts", + "src/hooks/app/useStatusChangeNotifications.ts", + "src/hooks/useRealtimeStatus.ts", + "src/hooks/useActivity.ts" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": ["src/tests/**"], + "rules": { + "typescript/no-explicit-any": "off" + } + } + ], + "ignorePatterns": ["dist/**", "node_modules/**"] +} diff --git a/src/frontend/index.html b/src/frontend/index.html index fd4bc5ac..c53970b6 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -1,4 +1,4 @@ - + diff --git a/src/frontend/knip.jsonc b/src/frontend/knip.jsonc new file mode 100644 index 00000000..f3133f82 --- /dev/null +++ b/src/frontend/knip.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema-jsonc.json", + + // Keep Knip strict about missing entry/config discovery so false positives + // are caught at the config level instead of papered over with ignores. + "treatConfigHintsAsErrors": true, + + // This script is intentionally loaded from index.html as a classic script, + // so we need to declare it as an entry point manually. + "entry": ["public/theme-init.js"] +} diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index b5d5076f..28bbe23b 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -15,51 +15,18 @@ "socket.io-client": "^4.7.5" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "postcss": "^8.5.9", + "knip": "^6.4.0", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "oxlint-tsgolint": "^0.20.0", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", - "vite": "^8.0.8" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "vite": "^8.0.8", + "vitest": "^4.1.4" } }, "node_modules/@emnapi/wasi-threads": { @@ -123,25 +90,1416 @@ "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.121.0.tgz", + "integrity": "sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.121.0.tgz", + "integrity": "sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.121.0.tgz", + "integrity": "sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.121.0.tgz", + "integrity": "sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.121.0.tgz", + "integrity": "sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.121.0.tgz", + "integrity": "sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.121.0.tgz", + "integrity": "sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.121.0.tgz", + "integrity": "sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.121.0.tgz", + "integrity": "sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.121.0.tgz", + "integrity": "sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.121.0.tgz", + "integrity": "sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.121.0.tgz", + "integrity": "sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.121.0.tgz", + "integrity": "sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.121.0.tgz", + "integrity": "sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.121.0.tgz", + "integrity": "sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.121.0.tgz", + "integrity": "sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.121.0.tgz", + "integrity": "sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.121.0.tgz", + "integrity": "sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.121.0.tgz", + "integrity": "sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.121.0.tgz", + "integrity": "sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", + "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.44.0.tgz", + "integrity": "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.44.0.tgz", + "integrity": "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.44.0.tgz", + "integrity": "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.44.0.tgz", + "integrity": "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.44.0.tgz", + "integrity": "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.44.0.tgz", + "integrity": "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.44.0.tgz", + "integrity": "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.44.0.tgz", + "integrity": "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.44.0.tgz", + "integrity": "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.44.0.tgz", + "integrity": "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.44.0.tgz", + "integrity": "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.44.0.tgz", + "integrity": "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.44.0.tgz", + "integrity": "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.44.0.tgz", + "integrity": "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.44.0.tgz", + "integrity": "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.44.0.tgz", + "integrity": "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.44.0.tgz", + "integrity": "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.44.0.tgz", + "integrity": "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.44.0.tgz", + "integrity": "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.20.0.tgz", + "integrity": "sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.20.0.tgz", + "integrity": "sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.20.0.tgz", + "integrity": "sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.20.0.tgz", + "integrity": "sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.20.0.tgz", + "integrity": "sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.20.0.tgz", + "integrity": "sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.59.0.tgz", + "integrity": "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.59.0.tgz", + "integrity": "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.59.0.tgz", + "integrity": "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.59.0.tgz", + "integrity": "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.59.0.tgz", + "integrity": "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.59.0.tgz", + "integrity": "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.59.0.tgz", + "integrity": "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.59.0.tgz", + "integrity": "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.59.0.tgz", + "integrity": "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.59.0.tgz", + "integrity": "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.59.0.tgz", + "integrity": "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.59.0.tgz", + "integrity": "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.59.0.tgz", + "integrity": "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.59.0.tgz", + "integrity": "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.59.0.tgz", + "integrity": "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.59.0.tgz", + "integrity": "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.59.0.tgz", + "integrity": "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==", + "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.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.59.0.tgz", + "integrity": "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.59.0.tgz", + "integrity": "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { @@ -399,6 +1757,13 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "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/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -642,20 +2007,6 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" - } - }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -680,6 +2031,31 @@ "tslib": "^2.4.0" } }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -738,6 +2114,159 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "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.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.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/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -819,6 +2348,70 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -836,6 +2429,35 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -850,12 +2472,71 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -865,6 +2546,47 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/knip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.4.0.tgz", + "integrity": "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "get-tsconfig": "4.13.7", + "jiti": "^2.6.0", + "minimist": "^1.2.8", + "oxc-parser": "^0.121.0", + "oxc-resolver": "^11.19.1", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "unbash": "^2.2.0", + "yaml": "^2.8.2", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1107,20 +2829,67 @@ "win32" ], "engines": { - "node": ">= 12.0.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "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==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ms": { @@ -1147,6 +2916,208 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/oxc-parser": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.121.0.tgz", + "integrity": "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.121.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.121.0", + "@oxc-parser/binding-android-arm64": "0.121.0", + "@oxc-parser/binding-darwin-arm64": "0.121.0", + "@oxc-parser/binding-darwin-x64": "0.121.0", + "@oxc-parser/binding-freebsd-x64": "0.121.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", + "@oxc-parser/binding-linux-arm64-musl": "0.121.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", + "@oxc-parser/binding-linux-x64-gnu": "0.121.0", + "@oxc-parser/binding-linux-x64-musl": "0.121.0", + "@oxc-parser/binding-openharmony-arm64": "0.121.0", + "@oxc-parser/binding-wasm32-wasi": "0.121.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", + "@oxc-parser/binding-win32-x64-msvc": "0.121.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.121.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.121.0.tgz", + "integrity": "sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", + "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + } + }, + "node_modules/oxfmt": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.44.0.tgz", + "integrity": "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.44.0", + "@oxfmt/binding-android-arm64": "0.44.0", + "@oxfmt/binding-darwin-arm64": "0.44.0", + "@oxfmt/binding-darwin-x64": "0.44.0", + "@oxfmt/binding-freebsd-x64": "0.44.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", + "@oxfmt/binding-linux-arm64-gnu": "0.44.0", + "@oxfmt/binding-linux-arm64-musl": "0.44.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", + "@oxfmt/binding-linux-riscv64-musl": "0.44.0", + "@oxfmt/binding-linux-s390x-gnu": "0.44.0", + "@oxfmt/binding-linux-x64-gnu": "0.44.0", + "@oxfmt/binding-linux-x64-musl": "0.44.0", + "@oxfmt/binding-openharmony-arm64": "0.44.0", + "@oxfmt/binding-win32-arm64-msvc": "0.44.0", + "@oxfmt/binding-win32-ia32-msvc": "0.44.0", + "@oxfmt/binding-win32-x64-msvc": "0.44.0" + } + }, + "node_modules/oxlint": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.59.0.tgz", + "integrity": "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.59.0", + "@oxlint/binding-android-arm64": "1.59.0", + "@oxlint/binding-darwin-arm64": "1.59.0", + "@oxlint/binding-darwin-x64": "1.59.0", + "@oxlint/binding-freebsd-x64": "1.59.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", + "@oxlint/binding-linux-arm-musleabihf": "1.59.0", + "@oxlint/binding-linux-arm64-gnu": "1.59.0", + "@oxlint/binding-linux-arm64-musl": "1.59.0", + "@oxlint/binding-linux-ppc64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-gnu": "1.59.0", + "@oxlint/binding-linux-riscv64-musl": "1.59.0", + "@oxlint/binding-linux-s390x-gnu": "1.59.0", + "@oxlint/binding-linux-x64-gnu": "1.59.0", + "@oxlint/binding-linux-x64-musl": "1.59.0", + "@oxlint/binding-openharmony-arm64": "1.59.0", + "@oxlint/binding-win32-arm64-msvc": "1.59.0", + "@oxlint/binding-win32-ia32-msvc": "1.59.0", + "@oxlint/binding-win32-x64-msvc": "1.59.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.18.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.20.0.tgz", + "integrity": "sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "0.20.0", + "@oxlint-tsgolint/darwin-x64": "0.20.0", + "@oxlint-tsgolint/linux-arm64": "0.20.0", + "@oxlint-tsgolint/linux-x64": "0.20.0", + "@oxlint-tsgolint/win32-arm64": "0.20.0", + "@oxlint-tsgolint/win32-x64": "0.20.0" + } + }, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1158,7 +3129,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1194,6 +3164,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -1255,6 +3246,27 @@ "react-dom": ">=18" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", @@ -1294,6 +3306,30 @@ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "license": "MIT" }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1306,6 +3342,26 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "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/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -1343,6 +3399,33 @@ "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": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -1362,6 +3445,23 @@ "url": "https://opencollective.com/webpack" } }, + "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.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1378,6 +3478,39 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1399,6 +3532,16 @@ "node": ">=14.17" } }, + "node_modules/unbash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", + "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -1484,6 +3627,123 @@ } } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "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.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "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/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "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/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -1512,6 +3772,32 @@ "engines": { "node": ">=0.4.0" } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src/frontend/package.json b/src/frontend/package.json index 78c631c1..3c0f14eb 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,15 +1,20 @@ { "name": "cwad-frontend", - "private": true, "version": "1.0.0", + "private": true, "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/*.node.test.js", - "test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json" + "lint": "oxlint src/", + "lint:warn": "oxlint --deny warnings src/", + "knip": "knip", + "knip:prod": "knip --production", + "format": "oxfmt src/", + "format:check": "oxfmt --check src/", + "test:unit": "vitest run" }, "dependencies": { "@tailwindcss/vite": "^4.2.2", @@ -19,14 +24,17 @@ "socket.io-client": "^4.7.5" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "postcss": "^8.5.9", + "knip": "^6.4.0", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "oxlint-tsgolint": "^0.20.0", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" } } diff --git a/src/frontend/postcss.config.js b/src/frontend/postcss.config.js deleted file mode 100644 index 2b4c7ed9..00000000 --- a/src/frontend/postcss.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - plugins: {}, -} diff --git a/src/frontend/public/theme-init.js b/src/frontend/public/theme-init.js index 245c6990..06190969 100644 --- a/src/frontend/public/theme-init.js +++ b/src/frontend/public/theme-init.js @@ -1,10 +1,13 @@ // Apply theme immediately before first paint to prevent flash. // This runs as a blocking script before the app bundle loads. -(function() { +(function () { var saved = localStorage.getItem('preferred-theme') || 'auto'; - var theme = saved === 'auto' - ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - : saved; + var theme = + saved === 'auto' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : saved; var dark = theme === 'dark'; var bg = dark ? '#121212' : '#f8f8f8'; diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index c86e026b..4daf08c1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,66 @@ -import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react'; +import type { CSSProperties } from 'react'; +import { useState, useCallback, useRef, useMemo } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; + +import { ActivitySidebar } from './components/activity'; +import { AdvancedFilters } from './components/AdvancedFilters'; +import { ConfigSetupBanner } from './components/ConfigSetupBanner'; +import { DetailsModal } from './components/DetailsModal'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { MetadataConfigSession } from './components/MetadataConfigSession'; +import { OnBehalfConfirmationModal } from './components/OnBehalfConfirmationModal'; +import { OnboardingModal } from './components/OnboardingModal'; +import { ReleaseModal } from './components/ReleaseModal'; +import { RequestConfirmationModal } from './components/RequestConfirmationModal'; +import { ResultsSection } from './components/ResultsSection'; +import { SearchSection } from './components/SearchSection'; +import { SelfSettingsModal, SettingsModal } from './components/settings'; +import { ToastContainer } from './components/ToastContainer'; +import { UrlSearchBootstrapMount } from './components/UrlSearchBootstrapMount'; +import { SearchModeProvider } from './contexts/SearchModeContext'; +import { useSocket } from './contexts/SocketContext'; +import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; +import { useBookTargetDeselectSync } from './hooks/app/useBookTargetDeselectSync'; +import { useContentTypePreferences } from './hooks/app/useContentTypePreferences'; +import { useShowOnboardingDebug } from './hooks/app/useShowOnboardingDebug'; +import { useStatusChangeNotifications } from './hooks/app/useStatusChangeNotifications'; import { + resolveDefaultModeFromPolicy, + resolveSourceModeFromPolicy, +} from './hooks/requestPolicyCore'; +import { useActivity } from './hooks/useActivity'; +import { useAuth } from './hooks/useAuth'; +import { useDownloadTracking } from './hooks/useDownloadTracking'; +import { useMediaQuery } from './hooks/useMediaQuery'; +import { useMountEffect } from './hooks/useMountEffect'; +import { useRealtimeStatus } from './hooks/useRealtimeStatus'; +import { useRequestPolicy } from './hooks/useRequestPolicy'; +import { useRequests } from './hooks/useRequests'; +import { useSearch } from './hooks/useSearch'; +import { primeSettingsCache } from './hooks/useSettings'; +import { useToast } from './hooks/useToast'; +import { useUrlSearch } from './hooks/useUrlSearch'; +import { primeUsersCache } from './hooks/useUsersFetch'; +import { LoginPage } from './pages/LoginPage'; +import { + getSourceRecordInfo, + getMetadataBookInfo, + downloadRelease, + cancelDownload, + retryDownload, + getConfig, + getStatus, + getAdminUsers, + getMetadataProviders, + getMetadataSearchConfig, + createRequests, + isApiResponseError, + updateSelfUser, + setBookTargetState, + type DownloadReleasePayload, +} from './services/api'; +import type { Book, Release, RequestRecord, @@ -17,60 +77,23 @@ import { QueuedDownloadResult, QueryTargetOption, SearchMode, - isMetadataBook, } from './types'; -import { - getSourceRecordInfo, - getMetadataBookInfo, - downloadRelease, - cancelDownload, - retryDownload, - getConfig, - getStatus, - getMetadataProviders, - getMetadataSearchConfig, - createRequests, - isApiResponseError, - updateSelfUser, - setBookTargetState, - type DownloadReleasePayload, -} from './services/api'; -import { useToast } from './hooks/useToast'; -import { useRealtimeStatus } from './hooks/useRealtimeStatus'; -import { useAuth } from './hooks/useAuth'; -import { useSearch } from './hooks/useSearch'; -import { useUrlSearch } from './hooks/useUrlSearch'; -import { useDownloadTracking } from './hooks/useDownloadTracking'; -import { useRequestPolicy } from './hooks/useRequestPolicy'; -import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore'; -import { useRequests } from './hooks/useRequests'; -import { useActivity } from './hooks/useActivity'; -import { Header } from './components/Header'; -import { SearchSection } from './components/SearchSection'; -import { AdvancedFilters } from './components/AdvancedFilters'; -import { ResultsSection } from './components/ResultsSection'; -import { DetailsModal } from './components/DetailsModal'; -import { ReleaseModal } from './components/ReleaseModal'; -import { RequestConfirmationModal } from './components/RequestConfirmationModal'; -import { OnBehalfConfirmationModal } from './components/OnBehalfConfirmationModal'; -import { ToastContainer } from './components/ToastContainer'; -import { Footer } from './components/Footer'; -import { ActivitySidebar } from './components/activity'; -import { LoginPage } from './pages/LoginPage'; -import { SelfSettingsModal, SettingsModal } from './components/settings'; -import { ConfigSetupBanner } from './components/ConfigSetupBanner'; -import { OnboardingModal } from './components/OnboardingModal'; -import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; -import { buildSearchQuery } from './utils/buildSearchQuery'; +import { isMetadataBook } from './types'; import { formatActingAsUserName } from './utils/actingAsUser'; -import { withBasePath } from './utils/basePath'; import { buildLoginRedirectPath, getReturnToFromSearch } from './utils/authRedirect'; +import { withBasePath } from './utils/basePath'; +import { emitBookTargetChange } from './utils/bookTargetEvents'; +import { bookSupportsTargets } from './utils/bookTargetLoader'; +import { buildSearchQuery } from './utils/buildSearchQuery'; +import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery'; +import { getDynamicOptionGroup } from './utils/dynamicFieldOptions'; import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders'; import { getEffectiveMetadataSort } from './utils/metadataSort'; -import { - applyDirectPolicyModeToButtonState, - applyUniversalPolicyModeToButtonState, -} from './utils/requestPolicyUi'; +import { isRecord } from './utils/objectHelpers'; +import { policyTrace } from './utils/policyTrace'; +import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets'; +import { applyRequestNoteToPayload } from './utils/requestConfirmation'; +import { bookFromRequestData } from './utils/requestFulfil'; import { buildDirectRequestPayload, buildReleaseDataFromDirectBook, @@ -80,33 +103,26 @@ import { getRequestSuccessMessage, toContentType, } from './utils/requestPayload'; -import { applyRequestNoteToPayload } from './utils/requestConfirmation'; -import { bookFromRequestData } from './utils/requestFulfil'; -import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents'; -import { bookSupportsTargets } from './utils/bookTargetLoader'; -import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery'; -import { getDynamicOptionGroup } from './components/shared/DynamicDropdown'; -import { policyTrace } from './utils/policyTrace'; -import { SearchModeProvider } from './contexts/SearchModeContext'; -import { useSocket } from './contexts/SocketContext'; -import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets'; +import { + applyDirectPolicyModeToButtonState, + applyUniversalPolicyModeToButtonState, +} from './utils/requestPolicyUi'; + +// eslint-disable-next-line import/no-unassigned-import -- global app stylesheet is loaded for side effects import './styles.css'; -const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type'; +const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned'; +const getInitialPinnedPreference = (): boolean => { + if (typeof window === 'undefined') { + return false; + } -const getInitialContentType = (): { contentType: ContentType; combinedMode: boolean } => { try { - const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY); - if (saved === 'combined') { - return { contentType: 'ebook', combinedMode: true }; - } - if (saved === 'ebook' || saved === 'audiobook') { - return { contentType: saved, combinedMode: false }; - } + const value = window.localStorage.getItem(ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY); + return value === '1' || value?.toLowerCase() === 'true'; } catch { - // localStorage may be unavailable in private browsing + return false; } - return { contentType: 'ebook', combinedMode: false }; }; const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']); @@ -119,7 +135,10 @@ const isPolicyGuardError = (error: unknown): boolean => { }; const asRequestPolicyMode = (value: unknown): RequestPolicyMode | null => { - return value === 'download' || value === 'request_release' || value === 'request_book' || value === 'blocked' + return value === 'download' || + value === 'request_release' || + value === 'request_book' || + value === 'blocked' ? value : null; }; @@ -146,12 +165,11 @@ const getErrorMessage = (error: unknown, fallback: string): string => { }; const isQueuedDownloadResult = (value: unknown): value is QueuedDownloadResult => { - if (!value || typeof value !== 'object') { + if (!isRecord(value)) { return false; } - const row = value as Record; - return row.kind === 'download' && row.status === 'queued'; + return value.kind === 'download' && value.status === 'queued'; }; const getSubmissionSuccessMessage = ( @@ -165,9 +183,10 @@ const getSubmissionSuccessMessage = ( if (queuedDownloads.length === results.length) { if (queuedDownloads.length === 1) { - const title = typeof queuedDownloads[0].title === 'string' && queuedDownloads[0].title.trim() - ? queuedDownloads[0].title.trim() - : 'Untitled'; + const title = + typeof queuedDownloads[0].title === 'string' && queuedDownloads[0].title.trim() + ? queuedDownloads[0].title.trim() + : 'Untitled'; return `Download queued: ${title}`; } return 'Downloads queued'; @@ -207,6 +226,38 @@ type PendingOnBehalfDownload = actingAsUser: ActingAsUserSelection; }; +interface AuthenticatedAppBootstrapProps { + refreshStatus: () => Promise; + refreshRequestPolicy: (options?: { force?: boolean }) => Promise; + refreshActivitySnapshot: () => Promise; + loadConfig: (mode?: 'initial' | 'settings-saved') => void | Promise; +} + +const AuthenticatedAppBootstrap = ({ + refreshStatus, + refreshRequestPolicy, + refreshActivitySnapshot, + loadConfig, +}: AuthenticatedAppBootstrapProps) => { + useMountEffect(() => { + void refreshStatus(); + void refreshRequestPolicy({ force: true }); + void refreshActivitySnapshot(); + void loadConfig('initial'); + }); + + return null; +}; + +const AdminSettingsWarmupMount = () => { + useMountEffect(() => { + void primeUsersCache(); + void primeSettingsCache(); + }); + + return null; +}; + function App() { const location = useLocation(); const { toasts, showToast, removeToast } = useToast(); @@ -214,11 +265,7 @@ function App() { // Realtime status with WebSocket and polling fallback // Socket connection is managed by SocketProvider in main.tsx - const { - status: currentStatus, - isUsingWebSocket, - forceRefresh: fetchStatus - } = useRealtimeStatus({ + const { status: currentStatus, forceRefresh: fetchStatus } = useRealtimeStatus({ pollInterval: 5000, }); @@ -255,18 +302,9 @@ function App() { showToast, }); - // Re-request status after auth is established so the server can re-scope socket room membership. - useEffect(() => { - if (!authChecked || !isAuthenticated) { - return; - } - policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username }); - void fetchStatus(); - }, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]); - // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it - const initialContentTypePref = useMemo(() => getInitialContentType(), []); - const [contentType, setContentType] = useState(initialContentTypePref.contentType); + const { contentType, setContentType, combinedMode, setCombinedMode } = + useContentTypePreferences(); const { policy: requestPolicy, @@ -280,7 +318,7 @@ function App() { isAdmin: authIsAdmin, }); - const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false; + const requestRoleIsAdmin = requestPolicy?.is_admin ?? false; // Compute which content types this user is allowed to search for. // If a content type's default policy mode is 'blocked', hide it from the dropdown. @@ -296,22 +334,20 @@ function App() { return types.length > 0 ? types : ['ebook', 'audiobook']; }, [requestPolicy, requestRoleIsAdmin, requestsPolicyEnabled, getDefaultMode]); - // Auto-switch content type if the current selection is blocked - useEffect(() => { - if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) { - setContentType(allowedContentTypes[0]); - setCombinedMode(false); - } - }, [allowedContentTypes, contentType]); + const effectiveContentType = useMemo( + () => + allowedContentTypes.includes(contentType) + ? contentType + : (allowedContentTypes[0] ?? contentType), + [allowedContentTypes, contentType], + ); const { - isLoading: isRequestsLoading, cancelRequest: cancelUserRequest, fulfilRequest: fulfilSidebarRequest, rejectRequest: rejectSidebarRequest, } = useRequests({ isAdmin: requestRoleIsAdmin, - enabled: isAuthenticated, }); const { @@ -354,9 +390,12 @@ function App() { return result; }, [dismissedActivityKeys]); - const isDownloadTaskDismissed = useCallback((taskId: string) => { - return dismissedDownloadTaskIds.has(taskId); - }, [dismissedDownloadTaskIds]); + const isDownloadTaskDismissed = useCallback( + (taskId: string) => { + return dismissedDownloadTaskIds.has(taskId); + }, + [dismissedDownloadTaskIds], + ); const statusForButtonState = useMemo(() => { if (!currentStatus.complete || dismissedDownloadTaskIds.size === 0) { @@ -364,7 +403,9 @@ function App() { } const filteredComplete = Object.fromEntries( - Object.entries(currentStatus.complete).filter(([taskId]) => !dismissedDownloadTaskIds.has(taskId)) + Object.entries(currentStatus.complete).filter( + ([taskId]) => !dismissedDownloadTaskIds.has(taskId), + ), ) as Record; if (Object.keys(filteredComplete).length === Object.keys(currentStatus.complete).length) { @@ -382,11 +423,11 @@ function App() { // counts stay consistent with the activity panel. const activitySidebarStatus = useMemo(() => { const filterDismissed = ( - bucket: Record | undefined + bucket: Record | undefined, ): Record | undefined => { if (!bucket || dismissedDownloadTaskIds.size === 0) return bucket; const filtered = Object.fromEntries( - Object.entries(bucket).filter(([taskId]) => !dismissedDownloadTaskIds.has(taskId)) + Object.entries(bucket).filter(([taskId]) => !dismissedDownloadTaskIds.has(taskId)), ) as Record; return Object.keys(filtered).length > 0 ? filtered : undefined; }; @@ -413,8 +454,7 @@ function App() { return false; } return !( - requestPolicy.defaults.ebook === 'download' && - requestPolicy.defaults.audiobook === 'download' + requestPolicy.defaults.ebook === 'download' && requestPolicy.defaults.audiobook === 'download' ); }, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]); @@ -446,99 +486,186 @@ function App() { setIsAuthenticated, authRequired, onSearchReset: clearTracking, - contentType, + contentType: effectiveContentType, }); // When a book is removed from the Hardcover list currently being browsed, remove it from results const searchFieldValuesRef = useRef(searchFieldValues); searchFieldValuesRef.current = searchFieldValues; + useBookTargetDeselectSync({ + activeListValue: searchFieldValues.hardcover_list, + setBooks, + }); - useEffect(() => { - return onBookTargetChange((event) => { - if (event.selected) return; - const activeListValue = searchFieldValuesRef.current.hardcover_list; - if (!activeListValue || String(activeListValue) !== event.target) return; - setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId)); - }); - }, [setBooks]); - - const [pendingRequestPayload, setPendingRequestPayload] = useState(null); - const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState([]); + const [pendingRequestPayload, setPendingRequestPayload] = useState( + null, + ); + const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState< + CreateRequestPayload[] + >([]); const [actingAsUser, setActingAsUser] = useState(null); - const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState(null); + const [adminUsers, setAdminUsers] = useState([]); + const [isAdminUsersLoading, setIsAdminUsersLoading] = useState(false); + const [adminUsersError, setAdminUsersError] = useState(null); + const [hasLoadedAdminUsers, setHasLoadedAdminUsers] = useState(false); + const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = + useState(null); const [fulfillingRequest, setFulfillingRequest] = useState<{ requestId: number; book: Book; contentType: ContentType; } | null>(null); + const [selectedBook, setSelectedBook] = useState(null); + const [releaseBook, setReleaseBook] = useState(null); + const [activeResultsSort, setActiveResultsSort] = useState(''); + + const resetSearchResultsState = useCallback(() => { + setBooks([]); + setSelectedBook(null); + setReleaseBook(null); + setActiveResultsSort(''); + clearTracking(); + }, [clearTracking, setBooks]); + + const loadAdminUsers = useCallback(async () => { + if (!isAuthenticated || !authIsAdmin || !requestRoleIsAdmin) { + return; + } + + setIsAdminUsersLoading(true); + setAdminUsersError(null); + try { + const users = await getAdminUsers(); + const nextAdminUsers = users.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.display_name, + })); + const availableNextAdminUsers = nextAdminUsers.filter((user) => { + return !username || user.username !== username; + }); + + setAdminUsers(nextAdminUsers); + setHasLoadedAdminUsers(true); + + if (actingAsUser && !availableNextAdminUsers.some((user) => user.id === actingAsUser.id)) { + setActingAsUser(null); + setPendingOnBehalfDownload(null); + } + } catch (error) { + console.error('Failed to load admin users:', error); + setAdminUsersError('Failed to load users'); + } finally { + setIsAdminUsersLoading(false); + } + }, [actingAsUser, authIsAdmin, isAuthenticated, requestRoleIsAdmin, username]); + + const availableActingAsUsers = useMemo(() => { + return adminUsers.filter((user) => !username || user.username !== username); + }, [adminUsers, username]); + + const effectiveActingAsUser = useMemo(() => { + if (!actingAsUser || !isAuthenticated || !authIsAdmin || !requestRoleIsAdmin) { + return null; + } + if (username && actingAsUser.username === username) { + return null; + } + if (hasLoadedAdminUsers && !isAdminUsersLoading) { + return availableActingAsUsers.some((user) => user.id === actingAsUser.id) + ? actingAsUser + : null; + } + return actingAsUser; + }, [ + actingAsUser, + authIsAdmin, + availableActingAsUsers, + hasLoadedAdminUsers, + isAdminUsersLoading, + isAuthenticated, + requestRoleIsAdmin, + username, + ]); + + const effectivePendingOnBehalfDownload = useMemo(() => { + if (!pendingOnBehalfDownload || !effectiveActingAsUser) { + return null; + } + + if (pendingOnBehalfDownload.actingAsUser.id !== effectiveActingAsUser.id) { + return null; + } + + return { + ...pendingOnBehalfDownload, + actingAsUser: effectiveActingAsUser, + }; + }, [effectiveActingAsUser, pendingOnBehalfDownload]); // Wire up logout callback to clear search state const handleLogoutWithCleanup = useCallback(async () => { await handleLogout(); - setBooks([]); - clearTracking(); + resetSearchResultsState(); setActiveQueryTarget('general'); setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); setActingAsUser(null); + setAdminUsers([]); + setAdminUsersError(null); + setHasLoadedAdminUsers(false); setPendingOnBehalfDownload(null); setFulfillingRequest(null); resetActivity(); setSettingsOpen(false); setSelfSettingsOpen(false); - }, [handleLogout, setBooks, clearTracking, resetActivity]); - - useEffect(() => { - if (isAuthenticated && authIsAdmin) { - return; - } - setActingAsUser(null); - setPendingOnBehalfDownload(null); - }, [isAuthenticated, authIsAdmin]); - - // UI state - const [selectedBook, setSelectedBook] = useState(null); - const [releaseBook, setReleaseBook] = useState(null); + }, [handleLogout, resetActivity, resetSearchResultsState]); // Combined mode state (ebook + audiobook in one transaction) - const [combinedMode, setCombinedMode] = useState(initialContentTypePref.combinedMode); const [combinedState, setCombinedState] = useState(null); - // Persist content type + combined mode to localStorage - useEffect(() => { - try { - localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType); - } catch { - // localStorage may be unavailable in private browsing - } - }, [contentType, combinedMode]); - - // Clear combined state when combined mode is turned off - // (combinedModeAllowed guard is in a separate effect below, after effectiveSearchMode is declared) - useEffect(() => { - if (!combinedMode) { - setCombinedState(null); - } - }, [combinedMode]); - const [config, setConfig] = useState(null); const [metadataProviders, setMetadataProviders] = useState([]); const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState(null); - const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState(null); - const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState(null); - const [activeMetadataConfig, setActiveMetadataConfig] = useState(null); - const [activeQueryTarget, setActiveQueryTarget] = useState('general'); - const [activeResultsSort, setActiveResultsSort] = useState(''); + const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState< + string | null + >(null); + const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState< + string | null + >(null); + const [activeQueryTarget, setActiveQueryTarget] = useState('general'); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); - const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false); + const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(() => + getInitialPinnedPreference(), + ); const [headerHeight, setHeaderHeight] = useState(0); const headerObserverRef = useRef(null); - useEffect(() => { - if (!downloadsSidebarOpen) { + const isDesktopViewport = useMediaQuery('(min-width: 1024px)'); + const openDownloadsSidebar = useCallback(() => { + setDownloadsSidebarOpen(true); + prefetchActivityHistory(); + }, [prefetchActivityHistory]); + const toggleDownloadsSidebar = useCallback(() => { + if (downloadsSidebarOpen) { + setDownloadsSidebarOpen(false); return; } + setDownloadsSidebarOpen(true); prefetchActivityHistory(); }, [downloadsSidebarOpen, prefetchActivityHistory]); + const handleSettingsClick = useCallback(() => { + if (config?.settings_enabled) { + if (authIsAdmin) { + void primeUsersCache(); + void primeSettingsCache(); + setSettingsOpen(true); + } else { + setSelfSettingsOpen(true); + } + return; + } + setConfigBannerOpen(true); + }, [authIsAdmin, config?.settings_enabled]); const headerRef = useCallback((el: HTMLDivElement | null) => { if (headerObserverRef.current) { @@ -557,22 +684,15 @@ function App() { const [selfSettingsOpen, setSelfSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); - - // Expose debug function to trigger onboarding from browser console - useEffect(() => { - (window as unknown as { showOnboarding: () => void }).showOnboarding = () => setOnboardingOpen(true); - return () => { - delete (window as unknown as { showOnboarding?: () => void }).showOnboarding; - }; - }, []); + useShowOnboardingDebug({ + setOnboardingOpen, + }); // URL-based search: parse URL params for automatic search on page load const urlSearchEnabled = isAuthenticated && config !== null; const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled }); - const urlSearchExecutedRef = useRef(false); + const [hasExecutedUrlSearchBootstrap, setHasExecutedUrlSearchBootstrap] = useState(false); - // Track previous status and search mode for change detection - const prevStatusRef = useRef({}); const prevSearchModeRef = useRef(undefined); // Calculate status counts for header badges (memoized) @@ -580,7 +700,7 @@ function App() { const dismissedKeySet = new Set(dismissedActivityKeys); const countVisibleDownloads = ( bucket: Record | undefined, - options: { filterDismissed: boolean } + options: { filterDismissed: boolean }, ): number => { const { filterDismissed } = options; if (!bucket) { @@ -589,7 +709,8 @@ function App() { if (!filterDismissed) { return Object.keys(bucket).length; } - return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)).length; + return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)) + .length; }; const ongoing = [ @@ -599,7 +720,9 @@ function App() { activitySidebarStatus.downloading, ].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0); - const completed = countVisibleDownloads(activitySidebarStatus.complete, { filterDismissed: true }); + const completed = countVisibleDownloads(activitySidebarStatus.complete, { + filterDismissed: true, + }); const errored = countVisibleDownloads(activitySidebarStatus.error, { filterDismissed: true }); const pendingVisibleRequests = requestItems.filter((item) => { const requestId = item.requestId; @@ -617,186 +740,119 @@ function App() { }; }, [activitySidebarStatus, dismissedActivityKeys, requestItems]); - // Compute visibility states const hasResults = books.length > 0; const isInitialState = !hasResults; - // Detect status changes and show notifications - const detectChanges = useCallback((prev: StatusData, curr: StatusData) => { - if (!prev || Object.keys(prev).length === 0) return; - - const autoDownloadContentTypes = Array.isArray(config?.download_to_browser_content_types) - ? config.download_to_browser_content_types - : []; - const canAutoDownloadContentType = (contentType?: string): boolean => { - const contentTypeKey = String(contentType || '').trim().toLowerCase() === 'audiobook' - ? 'audiobook' - : 'book'; - return autoDownloadContentTypes.includes(contentTypeKey); - }; + useStatusChangeNotifications({ + currentStatus, + config, + showToast, + openDownloadsSidebar, + bookToReleaseMap, + markBookCompleted, + }); - // Check for new items in queue - const prevQueued = prev.queued || {}; - const currQueued = curr.queued || {}; - Object.keys(currQueued).forEach(bookId => { - if (!prevQueued[bookId]) { - const book = currQueued[bookId]; - showToast(`${book.title || 'Book'} added to queue`, 'info'); - // Auto-open downloads sidebar if enabled - if (config?.auto_open_downloads_sidebar !== false) { - setDownloadsSidebarOpen(true); + // Load config function + const loadConfig = useCallback( + async (mode: 'initial' | 'settings-saved' = 'initial') => { + try { + const [cfg, metadataProviderState] = await Promise.all([ + getConfig(), + getMetadataProviders(), + ]); + const nextCombinedModeAllowed = + cfg.search_mode === 'universal' && + (cfg.show_combined_selector ?? true) && + getDefaultMode('ebook') !== 'blocked' && + getDefaultMode('audiobook') !== 'blocked'; + const nextEffectiveCombinedMode = combinedMode && nextCombinedModeAllowed; + const activeConfiguredProvider = + nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined + ? metadataProviderState.configured_provider_combined + : getConfiguredMetadataProviderForContentType({ + contentType: effectiveContentType, + configuredMetadataProvider: metadataProviderState.configured_provider, + configuredAudiobookMetadataProvider: + metadataProviderState.configured_provider_audiobook, + }); + let nextMetadataConfig: MetadataSearchConfig | null = null; + + if (cfg.search_mode === 'universal') { + try { + nextMetadataConfig = await getMetadataSearchConfig( + effectiveContentType, + activeConfiguredProvider ?? undefined, + ); + } catch (metadataConfigError) { + console.error( + 'Failed to load metadata search config during config sync:', + metadataConfigError, + ); + } } - } - }); - // Check for items that started downloading - const prevDownloading = prev.downloading || {}; - const currDownloading = curr.downloading || {}; - Object.keys(currDownloading).forEach(bookId => { - if (!prevDownloading[bookId]) { - const book = currDownloading[bookId]; - showToast(`${book.title || 'Book'} started downloading`, 'info'); - } - }); + const resolvedMetadataDefaultSort = getEffectiveMetadataSort({ + currentSort: '', + defaultSort: nextMetadataConfig?.default_sort || cfg.metadata_default_sort || 'relevance', + sortOptions: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, + }); - // Check for completed items - const prevComplete = prev.complete || {}; - const currComplete = curr.complete || {}; - - Object.keys(currComplete).forEach(bookId => { - if (!prevComplete[bookId]) { - const book = currComplete[bookId]; - showToast(`${book.title || 'Book'} completed`, 'success'); - - // Auto-download to browser if enabled - if (book.download_path && canAutoDownloadContentType(book.content_type)) { - const link = document.createElement('a'); - link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`); - link.download = ''; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + // Check if search mode changed (only on settings save) + if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) { + resetSearchResultsState(); } - // Track completed release IDs in session state for universal mode - Object.entries(bookToReleaseMap).forEach(([metadataBookId, releaseIds]) => { - if (releaseIds.includes(bookId)) { - markBookCompleted(metadataBookId); - } + prevSearchModeRef.current = cfg.search_mode; + setConfig({ + ...cfg, + metadata_default_sort: resolvedMetadataDefaultSort, + metadata_sort_options: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, }); - } - }); - - // Check for failed items - const prevError = prev.error || {}; - const currError = curr.error || {}; - Object.keys(currError).forEach(bookId => { - if (!prevError[bookId]) { - const book = currError[bookId]; - const errorMsg = book.status_message || 'Download failed'; - showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error'); - } - }); - }, [showToast, bookToReleaseMap, markBookCompleted, config]); - - // Detect status changes when currentStatus updates - useEffect(() => { - if (prevStatusRef.current && Object.keys(prevStatusRef.current).length > 0) { - detectChanges(prevStatusRef.current, currentStatus); - } - prevStatusRef.current = currentStatus; - }, [currentStatus, detectChanges]); - - // Load config function - const loadConfig = useCallback(async (mode: 'initial' | 'settings-saved' = 'initial') => { - try { - const [cfg, metadataProviderState] = await Promise.all([ - getConfig(), - getMetadataProviders(), - ]); - const activeConfiguredProvider = combinedMode && metadataProviderState.configured_provider_combined - ? metadataProviderState.configured_provider_combined - : getConfiguredMetadataProviderForContentType({ - contentType, - configuredMetadataProvider: metadataProviderState.configured_provider, - configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook, - }); - let nextMetadataConfig: MetadataSearchConfig | null = null; - - if (cfg.search_mode === 'universal') { - try { - nextMetadataConfig = await getMetadataSearchConfig( - contentType, - activeConfiguredProvider ?? undefined, - ); - } catch (metadataConfigError) { - console.error('Failed to load metadata search config during config sync:', metadataConfigError); + setMetadataProviders(metadataProviderState.providers); + setConfiguredMetadataProvider(metadataProviderState.configured_provider); + setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook); + setConfiguredCombinedMetadataProvider(metadataProviderState.configured_provider_combined); + + // Show onboarding modal on first run (settings enabled but not completed yet) + if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) { + setOnboardingOpen(true); } - } - - const resolvedMetadataDefaultSort = getEffectiveMetadataSort({ - currentSort: '', - defaultSort: nextMetadataConfig?.default_sort || cfg.metadata_default_sort || 'relevance', - sortOptions: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, - }); - - // Check if search mode changed (only on settings save) - if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) { - setBooks([]); - setSelectedBook(null); - clearTracking(); - } - - prevSearchModeRef.current = cfg.search_mode; - setConfig({ - ...cfg, - metadata_default_sort: resolvedMetadataDefaultSort, - metadata_sort_options: nextMetadataConfig?.sort_options ?? cfg.metadata_sort_options, - }); - setMetadataProviders(metadataProviderState.providers); - setConfiguredMetadataProvider(metadataProviderState.configured_provider); - setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook); - setConfiguredCombinedMetadataProvider(metadataProviderState.configured_provider_combined); - setActiveMetadataConfig(nextMetadataConfig); - - // Show onboarding modal on first run (settings enabled but not completed yet) - if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) { - setOnboardingOpen(true); - } - // Determine the default sort based on search mode - const defaultSort = cfg.search_mode === 'universal' - ? resolvedMetadataDefaultSort - : (cfg.default_sort || 'relevance'); - - if (cfg?.supported_formats) { - if (mode === 'initial') { - setAdvancedFilters(prev => ({ - ...prev, - formats: cfg.supported_formats, - sort: defaultSort, - })); - } else if (mode === 'settings-saved') { - // On settings save, update formats and reset sort to new default - setAdvancedFilters(prev => ({ - ...prev, - formats: prev.formats.filter(f => cfg.supported_formats.includes(f)), - sort: defaultSort, - })); + // Determine the default sort based on search mode + const defaultSort = + cfg.search_mode === 'universal' + ? resolvedMetadataDefaultSort + : cfg.default_sort || 'relevance'; + + if (cfg?.supported_formats) { + if (mode === 'initial') { + setAdvancedFilters((prev) => ({ + ...prev, + formats: cfg.supported_formats, + sort: defaultSort, + })); + } else if (mode === 'settings-saved') { + // On settings save, update formats and reset sort to new default + setAdvancedFilters((prev) => ({ + ...prev, + formats: prev.formats.filter((f) => cfg.supported_formats.includes(f)), + sort: defaultSort, + })); + } } + } catch (error) { + console.error('Failed to load config:', error); } - } catch (error) { - console.error('Failed to load config:', error); - } - }, [clearTracking, combinedMode, contentType, setAdvancedFilters, setBooks]); - - // Fetch config when authenticated - useEffect(() => { - if (isAuthenticated) { - loadConfig('initial'); - } - }, [isAuthenticated, loadConfig]); + }, + [ + combinedMode, + effectiveContentType, + getDefaultMode, + resetSearchResultsState, + setAdvancedFilters, + ], + ); const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct'; @@ -808,128 +864,53 @@ function App() { const audiobookMode = getDefaultMode('audiobook'); return ebookMode !== 'blocked' && audiobookMode !== 'blocked'; }, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]); - - // Auto-disable combined mode if policy changes make it unavailable - // Skip while config is still loading to avoid resetting localStorage-restored state - useEffect(() => { - if (!config) return; - if (combinedMode && !combinedModeAllowed) { - setCombinedMode(false); - } - }, [config, combinedMode, combinedModeAllowed]); - - const defaultMetadataProviderForContentType = combinedMode && configuredCombinedMetadataProvider - ? configuredCombinedMetadataProvider - : getConfiguredMetadataProviderForContentType({ - contentType, - configuredMetadataProvider, - configuredAudiobookMetadataProvider, - }); - const effectiveMetadataProvider = effectiveSearchMode === 'universal' - ? (defaultMetadataProviderForContentType || null) - : null; + const effectiveCombinedMode = combinedMode && combinedModeAllowed; + const effectiveCombinedState = effectiveCombinedMode ? combinedState : null; + + const defaultMetadataProviderForContentType = + effectiveCombinedMode && configuredCombinedMetadataProvider + ? configuredCombinedMetadataProvider + : getConfiguredMetadataProviderForContentType({ + contentType: effectiveContentType, + configuredMetadataProvider, + configuredAudiobookMetadataProvider, + }); + const effectiveMetadataProvider = + effectiveSearchMode === 'universal' ? defaultMetadataProviderForContentType || null : null; + const metadataConfigSessionKey = + isAuthenticated && effectiveSearchMode === 'universal' + ? `${effectiveContentType}:${effectiveMetadataProvider ?? ''}` + : null; + const [activeMetadataConfigState, setActiveMetadataConfigState] = useState<{ + sessionKey: string; + config: MetadataSearchConfig | null; + } | null>(null); + const activeMetadataConfig = + metadataConfigSessionKey && activeMetadataConfigState?.sessionKey === metadataConfigSessionKey + ? activeMetadataConfigState.config + : null; const resolvedMetadataSortOptions = useMemo( () => activeMetadataConfig?.sort_options ?? config?.metadata_sort_options ?? [], [activeMetadataConfig?.sort_options, config?.metadata_sort_options], ); - const resolvedMetadataDefaultSort = useMemo(() => getEffectiveMetadataSort({ - currentSort: '', - defaultSort: activeMetadataConfig?.default_sort || config?.metadata_default_sort || 'relevance', - sortOptions: resolvedMetadataSortOptions, - }), [activeMetadataConfig?.default_sort, config?.metadata_default_sort, resolvedMetadataSortOptions]); - const prevMetadataSortContextRef = useRef(''); + const resolvedMetadataDefaultSort = useMemo( + () => + getEffectiveMetadataSort({ + currentSort: '', + defaultSort: + activeMetadataConfig?.default_sort || config?.metadata_default_sort || 'relevance', + sortOptions: resolvedMetadataSortOptions, + }), + [ + activeMetadataConfig?.default_sort, + config?.metadata_default_sort, + resolvedMetadataSortOptions, + ], + ); // Non-admins in universal mode have nothing in the advanced panel const hasAdvancedContent = requestRoleIsAdmin || effectiveSearchMode === 'direct'; - - useEffect(() => { - if (!hasAdvancedContent && showAdvanced) { - setShowAdvanced(false); - } - }, [hasAdvancedContent, showAdvanced, setShowAdvanced]); - - useEffect(() => { - let isMounted = true; - - if (!isAuthenticated || effectiveSearchMode !== 'universal') { - setActiveMetadataConfig(null); - return () => { - isMounted = false; - }; - } - - const loadMetadataConfig = async () => { - try { - const nextConfig = await getMetadataSearchConfig( - contentType, - effectiveMetadataProvider ?? undefined, - ); - if (isMounted) { - setActiveMetadataConfig(nextConfig); - } - } catch (error) { - console.error('Failed to load metadata search config:', error); - if (isMounted) { - setActiveMetadataConfig(null); - } - } - }; - - void loadMetadataConfig(); - - return () => { - isMounted = false; - }; - }, [isAuthenticated, effectiveSearchMode, contentType, effectiveMetadataProvider]); - - useEffect(() => { - if (effectiveSearchMode !== 'universal') { - prevMetadataSortContextRef.current = ''; - return; - } - - const metadataSortContext = [ - contentType, - effectiveMetadataProvider ?? '', - resolvedMetadataDefaultSort, - resolvedMetadataSortOptions.map((option) => option.value).join(','), - ].join('::'); - const contextChanged = prevMetadataSortContextRef.current !== '' - && prevMetadataSortContextRef.current !== metadataSortContext; - const nextSort = contextChanged - ? resolvedMetadataDefaultSort - : getEffectiveMetadataSort({ - currentSort: advancedFilters.sort, - defaultSort: resolvedMetadataDefaultSort, - sortOptions: resolvedMetadataSortOptions, - }); - - prevMetadataSortContextRef.current = metadataSortContext; - - if (nextSort !== advancedFilters.sort) { - setAdvancedFilters((prev) => ({ ...prev, sort: nextSort })); - } - }, [ - advancedFilters.sort, - contentType, - effectiveMetadataProvider, - effectiveSearchMode, - resolvedMetadataDefaultSort, - resolvedMetadataSortOptions, - setAdvancedFilters, - ]); - - const prevEffectiveSearchModeRef = useRef(effectiveSearchMode); - useEffect(() => { - if (prevEffectiveSearchModeRef.current !== effectiveSearchMode) { - setBooks([]); - setSelectedBook(null); - setReleaseBook(null); - setActiveResultsSort(''); - clearTracking(); - prevEffectiveSearchModeRef.current = effectiveSearchMode; - } - }, [effectiveSearchMode, setBooks, clearTracking]); + const effectiveShowAdvanced = hasAdvancedContent ? showAdvanced : false; const runSearchWithPolicyRefresh = useCallback( (opts: { @@ -949,146 +930,13 @@ function App() { providerOverride: opts.providerOverride, }); }, - [refreshRequestPolicy, handleSearch, config] + [refreshRequestPolicy, handleSearch, config], ); - // Execute URL-based search when params are present - useEffect(() => { - if ( - wasProcessed && - parsedParams && - !urlSearchExecutedRef.current && - config - ) { - urlSearchExecutedRef.current = true; - - const parsedSearchMode = config.search_mode || 'direct'; - const urlContentTypeOverride = - parsedSearchMode === 'universal' ? parsedParams.contentType : undefined; - - if (urlContentTypeOverride && urlContentTypeOverride !== contentType) { - setContentType(urlContentTypeOverride); - } - - if (!parsedParams.hasSearchParams) { - return; - } - const bookLanguages = config.book_languages || []; - const defaultLanguageCodes = - config.default_language && config.default_language.length > 0 - ? config.default_language - : [bookLanguages[0]?.code || 'en']; - - // Populate search input from URL - if (parsedParams.searchInput) { - setSearchInput(parsedParams.searchInput); - } - - let nextQueryTarget = 'general'; - if (parsedSearchMode === 'direct') { - if (parsedParams.advancedFilters.isbn) { - nextQueryTarget = 'isbn'; - } else if (parsedParams.advancedFilters.author) { - nextQueryTarget = 'author'; - } else if (parsedParams.advancedFilters.title) { - nextQueryTarget = 'title'; - } - } - setActiveQueryTarget(nextQueryTarget); - - const resolvedUrlMetadataSort = parsedSearchMode === 'universal' - ? getEffectiveMetadataSort({ - currentSort: typeof parsedParams.advancedFilters.sort === 'string' - ? parsedParams.advancedFilters.sort - : '', - defaultSort: resolvedMetadataDefaultSort, - sortOptions: resolvedMetadataSortOptions, - }) - : parsedParams.advancedFilters.sort; - - // Apply advanced filters from URL - if (Object.keys(parsedParams.advancedFilters).length > 0) { - setAdvancedFilters(prev => ({ - ...prev, - ...parsedParams.advancedFilters, - ...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort - ? { sort: resolvedUrlMetadataSort } - : {}), - })); - - const hasAdvancedValues = ['content', 'lang', 'formats'].some( - key => parsedParams.advancedFilters[key as keyof typeof parsedParams.advancedFilters] - ); - if (hasAdvancedValues) { - setShowAdvanced(true); - } - } - - // Build query and trigger search - const mergedFilters = { - ...advancedFilters, - ...parsedParams.advancedFilters, - ...(parsedSearchMode === 'universal' && resolvedUrlMetadataSort - ? { sort: resolvedUrlMetadataSort } - : {}), - }; - - const query = buildSearchQuery({ - searchInput: - parsedSearchMode === 'direct' && nextQueryTarget !== 'general' - ? '' - : parsedParams.searchInput, - showAdvanced: true, - advancedFilters: { - ...(mergedFilters as typeof advancedFilters), - isbn: nextQueryTarget === 'isbn' ? String(parsedParams.advancedFilters.isbn || '') : '', - author: nextQueryTarget === 'author' ? String(parsedParams.advancedFilters.author || '') : '', - title: nextQueryTarget === 'title' ? String(parsedParams.advancedFilters.title || '') : '', - }, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode: parsedSearchMode, - }); - - runSearchWithPolicyRefresh({ - query, - contentTypeOverride: urlContentTypeOverride, - searchModeOverride: parsedSearchMode, - }); - } - }, [ - wasProcessed, - parsedParams, - contentType, - config, - advancedFilters, - resolvedMetadataDefaultSort, - resolvedMetadataSortOptions, - runSearchWithPolicyRefresh, - setSearchInput, - setAdvancedFilters, - setShowAdvanced, - setActiveQueryTarget, - ]); - const handleSettingsSaved = useCallback(() => { - loadConfig('settings-saved'); + void loadConfig('settings-saved'); }, [loadConfig]); - // Log WebSocket connection status - useEffect(() => { - if (isUsingWebSocket) { - console.log('✅ Using WebSocket for real-time updates'); - } else { - console.log('⏳ Using polling fallback (5s interval)'); - } - }, [isUsingWebSocket]); - - // Fetch status on startup - useEffect(() => { - fetchStatus(); - }, [fetchStatus]); - // Show book details const handleShowDetails = async (id: string): Promise => { const book = books.find((entry) => entry.id === id); @@ -1096,7 +944,7 @@ function App() { if (metadataBook) { try { - const fullBook = await getMetadataBookInfo(metadataBook.provider!, metadataBook.provider_id!); + const fullBook = await getMetadataBookInfo(metadataBook.provider, metadataBook.provider_id); setSelectedBook({ ...metadataBook, description: fullBook.description || metadataBook.description, @@ -1147,36 +995,45 @@ function App() { return false; } }, - [fetchStatus, showToast, refreshRequestPolicy, refreshActivitySnapshot] + [fetchStatus, showToast, refreshRequestPolicy, refreshActivitySnapshot], ); - const openRequestConfirmation = useCallback(( - payload: CreateRequestPayload, - extraPayloads: CreateRequestPayload[] = [], - onBehalfOfUserId: number | undefined = actingAsUser?.id, - ) => { - const applyOnBehalf = (requestPayload: CreateRequestPayload): CreateRequestPayload => { - if (typeof onBehalfOfUserId !== 'number') { - return requestPayload; - } - return { - ...requestPayload, - on_behalf_of_user_id: onBehalfOfUserId, + const openRequestConfirmation = useCallback( + ( + payload: CreateRequestPayload, + extraPayloads: CreateRequestPayload[] = [], + onBehalfOfUserId: number | undefined = effectiveActingAsUser?.id, + ) => { + const applyOnBehalf = (requestPayload: CreateRequestPayload): CreateRequestPayload => { + if (typeof onBehalfOfUserId !== 'number') { + return requestPayload; + } + return { + ...requestPayload, + on_behalf_of_user_id: onBehalfOfUserId, + }; }; - }; - setPendingRequestPayload(applyOnBehalf(payload)); - setPendingRequestExtraPayloads(extraPayloads.map(applyOnBehalf)); - }, [actingAsUser?.id]); + setPendingRequestPayload(applyOnBehalf(payload)); + setPendingRequestExtraPayloads(extraPayloads.map(applyOnBehalf)); + }, + [effectiveActingAsUser?.id], + ); const handleConfirmRequest = useCallback( - async (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]): Promise => { - const requestPayloads = [payload, ...(extraPayloads ?? pendingRequestExtraPayloads)].map((requestPayload) => - applyRequestNoteToPayload(requestPayload, payload.note ?? '', allowRequestNotes) + async ( + payload: CreateRequestPayload, + extraPayloads?: CreateRequestPayload[], + ): Promise => { + const requestPayloads = [payload, ...(extraPayloads ?? pendingRequestExtraPayloads)].map( + (requestPayload) => + applyRequestNoteToPayload(requestPayload, payload.note ?? '', allowRequestNotes), ); const success = await submitRequests( requestPayloads, - requestPayloads.length === 1 ? getRequestSuccessMessage(requestPayloads[0]) : 'Requests submitted', + requestPayloads.length === 1 + ? getRequestSuccessMessage(requestPayloads[0]) + : 'Requests submitted', ); if (!success) return false; @@ -1184,16 +1041,19 @@ function App() { setPendingRequestExtraPayloads([]); return true; }, - [allowRequestNotes, pendingRequestExtraPayloads, submitRequests] + [allowRequestNotes, pendingRequestExtraPayloads, submitRequests], ); - const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => { - return getSourceMode(getBrowseSource(book), 'ebook'); - }, [getSourceMode]); + const getDirectPolicyMode = useCallback( + (book: Book): RequestPolicyMode => { + return getSourceMode(getBrowseSource(book), 'ebook'); + }, + [getSourceMode], + ); const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => { - return getDefaultMode(contentType); - }, [getDefaultMode, contentType]); + return getDefaultMode(effectiveContentType); + }, [effectiveContentType, getDefaultMode]); const getCombinedSelectionPhases = useCallback( (state: Pick): ContentType[] => { @@ -1206,20 +1066,22 @@ function App() { } return phases; }, - [] + [], ); const buildReleaseDownloadPayload = useCallback( (book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => { const isManual = book.provider === 'manual'; - const releasePreview = typeof release.extra?.preview === 'string' ? release.extra.preview : undefined; - const releaseAuthor = typeof release.extra?.author === 'string' ? release.extra.author : undefined; + const releasePreview = + typeof release.extra?.preview === 'string' ? release.extra.preview : undefined; + const releaseAuthor = + typeof release.extra?.author === 'string' ? release.extra.author : undefined; return { source: release.source, source_id: release.source_id, title: isManual ? release.title : book.title, - author: isManual ? (releaseAuthor || '') : book.author, + author: isManual ? releaseAuthor || '' : book.author, year: book.year, format: release.format, size: release.size, @@ -1229,14 +1091,14 @@ function App() { indexer: release.indexer, seeders: release.seeders, extra: release.extra, - preview: isManual ? (releasePreview || undefined) : book.preview, + preview: isManual ? releasePreview || undefined : book.preview, content_type: releaseContentType, series_name: book.series_name, series_position: book.series_position, subtitle: book.subtitle, }; }, - [] + [], ); // When downloading a book while browsing a Hardcover list the user owns, @@ -1246,35 +1108,43 @@ function App() { const metadataConfigRef = useRef(activeMetadataConfig); metadataConfigRef.current = activeMetadataConfig; - const removeBookFromActiveList = useCallback((book: Book) => { - if (config?.hardcover_auto_remove_on_download === false) return; - if (!bookSupportsTargets(book)) return; - const activeList = searchFieldValuesRef.current.hardcover_list; - if (!activeList) return; - const target = String(activeList); - - // Only auto-remove from lists the user owns (Reading Status / My Lists) - const listField = metadataConfigRef.current?.search_fields.find( - (f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField', - ); - if (listField && listField.type === 'DynamicSelectSearchField') { - const group = getDynamicOptionGroup(listField.options_endpoint, target); - if (group && group !== 'Reading Status' && group !== 'My Lists') return; - } - - void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => { - if (result.changed) { - emitBookTargetChange({ - provider: book.provider!, - bookId: book.provider_id!, - target, - selected: false, - }); - const listName = searchFieldLabelsRef.current['hardcover_list']; - showToast(`Removed from ${listName || 'list'}`, 'info'); + const removeBookFromActiveList = useCallback( + (book: Book) => { + if (config?.hardcover_auto_remove_on_download === false) return; + if (!bookSupportsTargets(book)) return; + const activeList = searchFieldValuesRef.current.hardcover_list; + if (!activeList) return; + const target = String(activeList); + const provider = book.provider; + const bookId = book.provider_id; + if (!provider || !bookId) return; + + // Only auto-remove from lists the user owns (Reading Status / My Lists) + const listField = metadataConfigRef.current?.search_fields.find( + (f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField', + ); + if (listField && listField.type === 'DynamicSelectSearchField') { + const group = getDynamicOptionGroup(listField.options_endpoint, target); + if (group && group !== 'Reading Status' && group !== 'My Lists') return; } - }).catch(() => {}); - }, [config?.hardcover_auto_remove_on_download, showToast]); + + void setBookTargetState(provider, bookId, target, false) + .then((result) => { + if (result.changed) { + emitBookTargetChange({ + provider, + bookId, + target, + selected: false, + }); + const listName = searchFieldLabelsRef.current['hardcover_list']; + showToast(`Removed from ${listName || 'list'}`, 'info'); + } + }) + .catch(() => undefined); + }, + [config?.hardcover_auto_remove_on_download, showToast], + ); const executeBookDownload = useCallback( async (book: Book, onBehalfOfUserId?: number): Promise => { @@ -1308,7 +1178,9 @@ function App() { } try { const status = await getStatus(); - if (wasDownloadQueuedAfterResponseError(status, payload.source_id, requestStartedAtSeconds)) { + if ( + wasDownloadQueuedAfterResponseError(status, payload.source_id, requestStartedAtSeconds) + ) { await fetchStatus(); removeBookFromActiveList(book); showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info'); @@ -1321,7 +1193,13 @@ function App() { throw error; } }, - [fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast] + [ + fetchStatus, + openRequestConfirmation, + refreshRequestPolicy, + removeBookFromActiveList, + showToast, + ], ); const executeReleaseDownload = useCallback( @@ -1329,14 +1207,14 @@ function App() { book: Book, release: Release, releaseContentType: ContentType, - onBehalfOfUserId?: number + onBehalfOfUserId?: number, ): Promise => { const requestStartedAtSeconds = Date.now() / 1000; try { trackRelease(book.id, release.source_id); await downloadRelease( buildReleaseDownloadPayload(book, release, releaseContentType), - onBehalfOfUserId + onBehalfOfUserId, ); await fetchStatus(); removeBookFromActiveList(book); @@ -1354,29 +1232,41 @@ function App() { contentType: normalizedContentType, }); if (requiredMode === 'request_release') { - openRequestConfirmation({ - book_data: buildMetadataBookRequestData(book, normalizedContentType), - release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), - context: { - source: release.source, - content_type: normalizedContentType, - request_level: 'release', + openRequestConfirmation( + { + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: buildReleaseDataFromMetadataRelease( + book, + release, + normalizedContentType, + ), + context: { + source: release.source, + content_type: normalizedContentType, + request_level: 'release', + }, }, - }, [], onBehalfOfUserId); + [], + onBehalfOfUserId, + ); await refreshRequestPolicy({ force: true }); return; } if (requiredMode === 'request_book') { setReleaseBook(null); - openRequestConfirmation({ - book_data: buildMetadataBookRequestData(book, normalizedContentType), - release_data: null, - context: { - source: release.source, - content_type: normalizedContentType, - request_level: 'book', + openRequestConfirmation( + { + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: null, + context: { + source: release.source, + content_type: normalizedContentType, + request_level: 'book', + }, }, - }, [], onBehalfOfUserId); + [], + onBehalfOfUserId, + ); await refreshRequestPolicy({ force: true }); return; } @@ -1386,53 +1276,84 @@ function App() { } try { const status = await getStatus(); - if (wasDownloadQueuedAfterResponseError(status, release.source_id, requestStartedAtSeconds)) { + if ( + wasDownloadQueuedAfterResponseError(status, release.source_id, requestStartedAtSeconds) + ) { await fetchStatus(); removeBookFromActiveList(book); showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info'); return; } } catch (verificationError) { - console.warn('Failed to verify release download after response error:', verificationError); + console.warn( + 'Failed to verify release download after response error:', + verificationError, + ); } showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); throw error; } }, - [buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease] + [ + buildReleaseDownloadPayload, + fetchStatus, + openRequestConfirmation, + refreshRequestPolicy, + removeBookFromActiveList, + showToast, + trackRelease, + ], ); const executeCombinedAction = useCallback( - async (book: Book, selection: CombinedSelectionState, onBehalfOfUserId?: number): Promise => { + async ( + book: Book, + selection: CombinedSelectionState, + onBehalfOfUserId?: number, + ): Promise => { const ebookRelease = selection.stagedEbook?.release; const audiobookRelease = selection.stagedAudiobook; - const ebookMode = ebookRelease ? getSourceMode(ebookRelease.source, 'ebook') : selection.ebookMode; - const audiobookMode = audiobookRelease ? getSourceMode(audiobookRelease.source, 'audiobook') : selection.audiobookMode; + const ebookMode = ebookRelease + ? getSourceMode(ebookRelease.source, 'ebook') + : selection.ebookMode; + const audiobookMode = audiobookRelease + ? getSourceMode(audiobookRelease.source, 'audiobook') + : selection.audiobookMode; const buildRequestPayload = ( release: Release | undefined, releaseContentType: ContentType, mode: RequestPolicyMode, ): CreateRequestPayload => { - const payload = mode === 'request_release' - ? { - book_data: buildMetadataBookRequestData(book, releaseContentType), - release_data: buildReleaseDataFromMetadataRelease(book, release!, releaseContentType), - context: { - source: release!.source, - content_type: releaseContentType, - request_level: 'release' as const, - }, - } - : { - book_data: buildMetadataBookRequestData(book, releaseContentType), - release_data: null, - context: { - source: '*', - content_type: releaseContentType, - request_level: 'book' as const, - }, - }; + const payload = + mode === 'request_release' + ? (() => { + if (!release) { + throw new Error('Missing release for combined request payload'); + } + return { + book_data: buildMetadataBookRequestData(book, releaseContentType), + release_data: buildReleaseDataFromMetadataRelease( + book, + release, + releaseContentType, + ), + context: { + source: release.source, + content_type: releaseContentType, + request_level: 'release' as const, + }, + }; + })() + : { + book_data: buildMetadataBookRequestData(book, releaseContentType), + release_data: null, + context: { + source: '*', + content_type: releaseContentType, + request_level: 'book' as const, + }, + }; if (typeof onBehalfOfUserId !== 'number') { return payload; @@ -1447,13 +1368,19 @@ function App() { const requestPayloads: CreateRequestPayload[] = []; if (ebookMode === 'download') { - await executeReleaseDownload(book, ebookRelease!, 'ebook', onBehalfOfUserId); + if (!ebookRelease) { + throw new Error('Missing ebook release for combined download'); + } + await executeReleaseDownload(book, ebookRelease, 'ebook', onBehalfOfUserId); } else { requestPayloads.push(buildRequestPayload(ebookRelease, 'ebook', ebookMode)); } if (audiobookMode === 'download') { - await executeReleaseDownload(book, audiobookRelease!, 'audiobook', onBehalfOfUserId); + if (!audiobookRelease) { + throw new Error('Missing audiobook release for combined download'); + } + await executeReleaseDownload(book, audiobookRelease, 'audiobook', onBehalfOfUserId); } else { requestPayloads.push(buildRequestPayload(audiobookRelease, 'audiobook', audiobookMode)); } @@ -1462,30 +1389,30 @@ function App() { openRequestConfirmation(requestPayloads[0], requestPayloads.slice(1), onBehalfOfUserId); } }, - [executeReleaseDownload, getSourceMode, openRequestConfirmation] + [executeReleaseDownload, getSourceMode, openRequestConfirmation], ); const handleConfirmOnBehalfDownload = useCallback(async (): Promise => { - if (!pendingOnBehalfDownload) { + if (!effectivePendingOnBehalfDownload) { return true; } - const onBehalfOfUserId = pendingOnBehalfDownload.actingAsUser.id; + const onBehalfOfUserId = effectivePendingOnBehalfDownload.actingAsUser.id; try { - if (pendingOnBehalfDownload.type === 'book') { - await executeBookDownload(pendingOnBehalfDownload.book, onBehalfOfUserId); - } else if (pendingOnBehalfDownload.type === 'combined') { + if (effectivePendingOnBehalfDownload.type === 'book') { + await executeBookDownload(effectivePendingOnBehalfDownload.book, onBehalfOfUserId); + } else if (effectivePendingOnBehalfDownload.type === 'combined') { await executeCombinedAction( - pendingOnBehalfDownload.book, - pendingOnBehalfDownload.combinedState, - onBehalfOfUserId + effectivePendingOnBehalfDownload.book, + effectivePendingOnBehalfDownload.combinedState, + onBehalfOfUserId, ); } else { await executeReleaseDownload( - pendingOnBehalfDownload.book, - pendingOnBehalfDownload.release, - pendingOnBehalfDownload.releaseContentType, - onBehalfOfUserId + effectivePendingOnBehalfDownload.book, + effectivePendingOnBehalfDownload.release, + effectivePendingOnBehalfDownload.releaseContentType, + onBehalfOfUserId, ); } setPendingOnBehalfDownload(null); @@ -1493,7 +1420,12 @@ function App() { } catch { return false; } - }, [executeBookDownload, executeCombinedAction, executeReleaseDownload, pendingOnBehalfDownload]); + }, [ + effectivePendingOnBehalfDownload, + executeBookDownload, + executeCombinedAction, + executeReleaseDownload, + ]); // Direct-mode action (download or release-level request based on policy). const handleDownload = async (book: Book): Promise => { @@ -1509,7 +1441,7 @@ function App() { }); try { const latestPolicy = await refreshRequestPolicy({ force: true }); - const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin; + const effectiveIsAdmin = latestPolicy?.is_admin ?? requestRoleIsAdmin; mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, source, directContentType); policyTrace('direct.action:resolved', { bookId: book.id, @@ -1544,11 +1476,11 @@ function App() { return; } - if (actingAsUser) { + if (effectiveActingAsUser) { setPendingOnBehalfDownload({ type: 'book', book, - actingAsUser, + actingAsUser: effectiveActingAsUser, }); return; } @@ -1580,7 +1512,7 @@ function App() { // Universal-mode "Get" action (open releases, request-book, or block by policy). const handleGetReleases = async (book: Book) => { let mode = getUniversalDefaultPolicyMode(); - const normalizedContentType = toContentType(contentType); + const normalizedContentType = toContentType(effectiveContentType); policyTrace('universal.get:start', { bookId: book.id, contentType: normalizedContentType, @@ -1589,8 +1521,8 @@ function App() { }); try { const latestPolicy = await refreshRequestPolicy({ force: true }); - const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin; - mode = resolveDefaultModeFromPolicy(latestPolicy, effectiveIsAdmin, contentType); + const effectiveIsAdmin = latestPolicy?.is_admin ?? requestRoleIsAdmin; + mode = resolveDefaultModeFromPolicy(latestPolicy, effectiveIsAdmin, effectiveContentType); policyTrace('universal.get:resolved', { bookId: book.id, contentType: normalizedContentType, @@ -1616,11 +1548,15 @@ function App() { } // Combined mode is only available when both default content types are accessible. - if (combinedMode) { + if (effectiveCombinedMode) { const latestPolicy2 = await refreshRequestPolicy({ force: true }).catch(() => null); - const effectiveIsAdmin2 = latestPolicy2 ? Boolean(latestPolicy2.is_admin) : requestRoleIsAdmin; + const effectiveIsAdmin2 = latestPolicy2?.is_admin ?? requestRoleIsAdmin; const ebookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'ebook'); - const audiobookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'audiobook'); + const audiobookMode = resolveDefaultModeFromPolicy( + latestPolicy2, + effectiveIsAdmin2, + 'audiobook', + ); if (ebookMode === 'request_book' && audiobookMode === 'request_book') { const ebookPayload: CreateRequestPayload = { @@ -1697,7 +1633,11 @@ function App() { }; // Handle download from ReleaseModal (universal mode release rows). - const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { + const handleReleaseDownload = async ( + book: Book, + release: Release, + releaseContentType: ContentType, + ) => { policyTrace('release.action:start', { bookId: book.id, releaseId: release.source_id, @@ -1705,13 +1645,13 @@ function App() { contentType: toContentType(releaseContentType), }); - if (actingAsUser) { + if (effectiveActingAsUser) { setPendingOnBehalfDownload({ type: 'release', book, release, releaseContentType, - actingAsUser, + actingAsUser: effectiveActingAsUser, }); return; } @@ -1733,7 +1673,7 @@ function App() { }, }); }, - [openRequestConfirmation, refreshRequestPolicy] + [openRequestConfirmation, refreshRequestPolicy], ); const handleReleaseBookRequest = useCallback( @@ -1750,59 +1690,64 @@ function App() { }, }); }, - [openRequestConfirmation, refreshRequestPolicy] + [openRequestConfirmation, refreshRequestPolicy], ); - const handleReleaseModalPolicyRefresh = useCallback(() => { - return refreshRequestPolicy({ force: true }); - }, [refreshRequestPolicy]); - // Combined mode callbacks - const handleCombinedNext = useCallback((release: Release) => { - if (!releaseBook || !combinedState) return; - const phases = getCombinedSelectionPhases(combinedState); - const nextPhase = phases[phases.indexOf(combinedState.phase) + 1]; - - setCombinedState({ - ...combinedState, - phase: nextPhase, - stagedEbook: { book: releaseBook, release }, - }); - }, [combinedState, getCombinedSelectionPhases, releaseBook]); + const handleCombinedNext = useCallback( + (release: Release) => { + if (!releaseBook || !combinedState) return; + const phases = getCombinedSelectionPhases(combinedState); + const nextPhase = phases[phases.indexOf(combinedState.phase) + 1]; + + setCombinedState({ + ...combinedState, + phase: nextPhase, + stagedEbook: { book: releaseBook, release }, + }); + }, + [combinedState, getCombinedSelectionPhases, releaseBook], + ); const handleCombinedBack = useCallback((audiobookRelease: Release | null) => { - setCombinedState((prev) => prev ? { ...prev, phase: 'ebook', stagedAudiobook: audiobookRelease ?? undefined } : null); + setCombinedState((prev) => + prev ? { ...prev, phase: 'ebook', stagedAudiobook: audiobookRelease ?? undefined } : null, + ); }, []); - const handleCombinedDownload = useCallback(async (release: Release) => { - if (!combinedState || !releaseBook) return; + const handleCombinedDownload = useCallback( + async (release: Release) => { + if (!combinedState || !releaseBook) return; - const nextCombinedState: CombinedSelectionState = combinedState.phase === 'ebook' - ? { - ...combinedState, - stagedEbook: { book: releaseBook, release }, - } - : { - ...combinedState, - stagedAudiobook: release, - }; + const nextCombinedState: CombinedSelectionState = + combinedState.phase === 'ebook' + ? { + ...combinedState, + stagedEbook: { book: releaseBook, release }, + } + : { + ...combinedState, + stagedAudiobook: release, + }; - if (actingAsUser) { - setPendingOnBehalfDownload({ - type: 'combined', - book: releaseBook, - combinedState: nextCombinedState, - actingAsUser, - }); + if (effectiveActingAsUser) { + setPendingOnBehalfDownload({ + type: 'combined', + book: releaseBook, + combinedState: nextCombinedState, + actingAsUser: effectiveActingAsUser, + }); + setCombinedState(null); + setReleaseBook(null); + return; + } + + await executeCombinedAction(releaseBook, nextCombinedState); setCombinedState(null); setReleaseBook(null); - return; - } - - await executeCombinedAction(releaseBook, nextCombinedState); - setCombinedState(null); - setReleaseBook(null); - }, [actingAsUser, combinedState, executeCombinedAction, releaseBook]); + }, + [combinedState, effectiveActingAsUser, executeCombinedAction, releaseBook], + ); const handleRequestCancel = useCallback( async (requestId: number) => { @@ -1814,7 +1759,7 @@ function App() { showToast(getErrorMessage(error, 'Failed to cancel request'), 'error'); } }, - [cancelUserRequest, refreshActivitySnapshot, showToast] + [cancelUserRequest, refreshActivitySnapshot, showToast], ); const handleRequestReject = useCallback( @@ -1831,7 +1776,7 @@ function App() { showToast(getErrorMessage(error, 'Failed to reject request'), 'error'); } }, - [refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast] + [refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast], ); const handleRequestApprove = useCallback( @@ -1841,7 +1786,7 @@ function App() { options?: { browseOnly?: boolean; manualApproval?: boolean; - } + }, ) => { if (!requestRoleIsAdmin) { return; @@ -1879,8 +1824,16 @@ function App() { book: bookFromRequestData(record.book_data), contentType: record.content_type, }); + void refreshRequestPolicy({ force: true }); }, - [requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot] + [ + requestRoleIsAdmin, + fulfilSidebarRequest, + showToast, + fetchStatus, + refreshActivitySnapshot, + refreshRequestPolicy, + ], ); const handleBrowseFulfilDownload = useCallback( @@ -1892,7 +1845,7 @@ function App() { try { await fulfilSidebarRequest( fulfillingRequest.requestId, - buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType)) + buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType)), ); await refreshActivitySnapshot(); showToast(`Request approved: ${book.title || 'Untitled'}`, 'success'); @@ -1904,7 +1857,7 @@ function App() { throw error; } }, - [fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot] + [fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot], ); const getDirectActionButtonState = useCallback( @@ -1917,20 +1870,21 @@ function App() { if (baseState.state === 'complete' && isDownloadTaskDismissed(bookId)) { return applyDirectPolicyModeToButtonState( { text: 'Download', state: 'download' }, - getDirectPolicyMode(book) + getDirectPolicyMode(book), ); } const mode = getDirectPolicyMode(book); return applyDirectPolicyModeToButtonState(baseState, mode); }, - [books, getButtonState, getDirectPolicyMode, isDownloadTaskDismissed] + [books, getButtonState, getDirectPolicyMode, isDownloadTaskDismissed], ); const getUniversalActionButtonState = useCallback( (bookId: string): ButtonStateInfo => { const baseState = getUniversalButtonState(bookId); const trackedReleaseIds = bookToReleaseMap[bookId] || []; - const allTrackedReleasesDismissed = trackedReleaseIds.length > 0 && + const allTrackedReleasesDismissed = + trackedReleaseIds.length > 0 && trackedReleaseIds.every((releaseId) => isDownloadTaskDismissed(releaseId)); if ( @@ -1939,67 +1893,86 @@ function App() { ) { return applyUniversalPolicyModeToButtonState( { text: 'Get', state: 'download' }, - getUniversalDefaultPolicyMode() + getUniversalDefaultPolicyMode(), ); } const mode = getUniversalDefaultPolicyMode(); return applyUniversalPolicyModeToButtonState(baseState, mode); }, - [bookToReleaseMap, getUniversalButtonState, getUniversalDefaultPolicyMode, isDownloadTaskDismissed] + [ + bookToReleaseMap, + getUniversalButtonState, + getUniversalDefaultPolicyMode, + isDownloadTaskDismissed, + ], ); - const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES; + const bookLanguages = useMemo( + () => config?.book_languages || DEFAULT_LANGUAGES, + [config?.book_languages], + ); const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS; - const defaultLanguageCodes = - config?.default_language && config.default_language.length > 0 - ? config.default_language - : [bookLanguages[0]?.code || 'en']; + const defaultLanguageCodes = useMemo( + () => + config?.default_language && config.default_language.length > 0 + ? config.default_language + : [bookLanguages[0]?.code || 'en'], + [config?.default_language, bookLanguages], + ); const logoUrl = withBasePath('/logo.png'); // Manual search is only allowed when the default policy permits browsing releases const universalDefaultMode = getUniversalDefaultPolicyMode(); - const manualSearchAllowed = effectiveSearchMode === 'universal' - && (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); + const manualSearchAllowed = + effectiveSearchMode === 'universal' && + (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); const queryTargets = useMemo( - () => buildQueryTargets({ - searchMode: effectiveSearchMode, - metadataSearchFields: activeMetadataConfig?.search_fields ?? [], - manualSearchAllowed, - }), + () => + buildQueryTargets({ + searchMode: effectiveSearchMode, + metadataSearchFields: activeMetadataConfig?.search_fields ?? [], + manualSearchAllowed, + }), [effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed], ); - - useEffect(() => { - setActiveQueryTarget((prev) => { - if (queryTargets.some((target) => target.key === prev)) return prev; - return getDefaultQueryTargetKey(queryTargets); - }); - }, [queryTargets]); + const effectiveActiveQueryTarget = useMemo(() => { + if (queryTargets.some((target) => target.key === activeQueryTarget)) { + return activeQueryTarget; + } + return getDefaultQueryTargetKey(queryTargets); + }, [queryTargets, activeQueryTarget]); const activeQueryOption = useMemo( - () => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0], - [queryTargets, activeQueryTarget], + () => + queryTargets.find((target) => target.key === effectiveActiveQueryTarget) ?? queryTargets[0], + [queryTargets, effectiveActiveQueryTarget], ); const activeQueryField = activeQueryOption?.field ?? null; const seriesBrowseCapability = useMemo( - () => activeMetadataConfig?.capabilities.find((capability) => - capability.key === 'view_series' - && capability.field_key - ) ?? null, + () => + activeMetadataConfig?.capabilities.find( + (capability) => capability.key === 'view_series' && capability.field_key, + ) ?? null, [activeMetadataConfig?.capabilities], ); const seriesBrowseTarget = useMemo( - () => seriesBrowseCapability?.field_key - ? queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ?? null - : null, + () => + seriesBrowseCapability?.field_key + ? (queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ?? + null) + : null, [queryTargets, seriesBrowseCapability?.field_key], ); const activeQueryValue = useMemo(() => { - if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') { + if ( + !activeQueryOption || + activeQueryOption.source === 'general' || + activeQueryOption.source === 'manual' + ) { return searchInput; } @@ -2015,7 +1988,9 @@ function App() { } if (activeQueryOption.field.type === 'CheckboxSearchField') { - return searchFieldValues[activeQueryOption.field.key] ?? activeQueryOption.field.default ?? false; + return ( + searchFieldValues[activeQueryOption.field.key] ?? activeQueryOption.field.default ?? false + ); } return searchFieldValues[activeQueryOption.field.key] ?? ''; @@ -2028,118 +2003,171 @@ function App() { return searchFieldLabels[activeQueryOption.field.key]; }, [activeQueryOption, searchFieldLabels]); const activeQueryUsesSeriesBrowse = Boolean( - seriesBrowseCapability?.field_key - && activeQueryOption?.source === 'provider-field' - && activeQueryOption.field?.key === seriesBrowseCapability.field_key - && activeQueryValue !== '' - && activeQueryValue !== false, - ); - const activeQueryUsesListBrowse = Boolean( - activeQueryOption?.source === 'provider-field' - && activeQueryOption.field?.type === 'DynamicSelectSearchField' - && activeQueryValue !== '' - && activeQueryValue !== false, + seriesBrowseCapability?.field_key && + activeQueryOption?.source === 'provider-field' && + activeQueryOption.field?.key === seriesBrowseCapability.field_key && + activeQueryValue !== '' && + activeQueryValue !== false, ); + const activeQueryUsesListBrowse = + activeQueryOption?.source === 'provider-field' && + activeQueryOption.field?.type === 'DynamicSelectSearchField' && + activeQueryValue !== '' && + activeQueryValue !== false; const effectiveMetadataSort = getEffectiveMetadataSort({ currentSort: advancedFilters.sort, defaultSort: resolvedMetadataDefaultSort, sortOptions: resolvedMetadataSortOptions, }); - const visibleResultsSort = activeResultsSort || ( - effectiveSearchMode === 'universal' ? effectiveMetadataSort : advancedFilters.sort - ); + const visibleResultsSort = + activeResultsSort || + (effectiveSearchMode === 'universal' ? effectiveMetadataSort : advancedFilters.sort); - const getAppliedUniversalSort = useCallback((sortOverride?: string) => { - const requestedSort = sortOverride ?? effectiveMetadataSort; - const seriesBrowseSort = seriesBrowseCapability?.sort ?? ''; + const getAppliedUniversalSort = useCallback( + (sortOverride?: string) => { + const requestedSort = sortOverride ?? effectiveMetadataSort; + const seriesBrowseSort = seriesBrowseCapability?.sort ?? ''; - if (activeQueryUsesSeriesBrowse && seriesBrowseSort) { - return seriesBrowseSort; - } + if (activeQueryUsesSeriesBrowse && seriesBrowseSort) { + return seriesBrowseSort; + } - return requestedSort; - }, [activeQueryUsesSeriesBrowse, effectiveMetadataSort, seriesBrowseCapability?.sort]); + return requestedSort; + }, + [activeQueryUsesSeriesBrowse, effectiveMetadataSort, seriesBrowseCapability?.sort], + ); - const handleActiveQueryValueChange = useCallback((value: string | number | boolean, label?: string) => { - if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') { - setSearchInput(typeof value === 'string' ? value : String(value ?? '')); - return; - } + const handleActiveQueryValueChange = useCallback( + (value: string | number | boolean, label?: string) => { + if ( + !activeQueryOption || + activeQueryOption.source === 'general' || + activeQueryOption.source === 'manual' + ) { + setSearchInput(typeof value === 'string' ? value : String(value ?? '')); + return; + } - if (activeQueryOption.source === 'direct-field') { - const nextValue = typeof value === 'string' ? value : String(value ?? ''); - if (activeQueryOption.key === 'isbn') { - updateAdvancedFilters({ isbn: nextValue }); - } else if (activeQueryOption.key === 'author') { - updateAdvancedFilters({ author: nextValue }); - } else if (activeQueryOption.key === 'title') { - updateAdvancedFilters({ title: nextValue }); + if (activeQueryOption.source === 'direct-field') { + const nextValue = typeof value === 'string' ? value : String(value ?? ''); + if (activeQueryOption.key === 'isbn') { + updateAdvancedFilters({ isbn: nextValue }); + } else if (activeQueryOption.key === 'author') { + updateAdvancedFilters({ author: nextValue }); + } else if (activeQueryOption.key === 'title') { + updateAdvancedFilters({ title: nextValue }); + } + return; } - return; - } - if (activeQueryOption.field) { - updateSearchFieldValue(activeQueryOption.field.key, value, label); - } - }, [activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue]); + if (activeQueryOption.field) { + updateSearchFieldValue(activeQueryOption.field.key, value, label); + } + }, + [activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue], + ); - const handleSearchModeChange = useCallback((nextMode: SearchMode) => { - setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev); - if (nextMode !== 'universal') { - setCombinedMode(false); - } - updateSelfUser({ settings: { SEARCH_MODE: nextMode } }) - .then(() => loadConfig('settings-saved')) - .catch((err) => console.error('Failed to save search mode:', err)); - }, [loadConfig]); + const handleSearchModeChange = useCallback( + (nextMode: SearchMode) => { + resetSearchResultsState(); + setConfig((prev) => (prev ? { ...prev, search_mode: nextMode } : prev)); + if (nextMode !== 'universal') { + setCombinedMode(false); + } + updateSelfUser({ settings: { SEARCH_MODE: nextMode } }) + .then(() => loadConfig('settings-saved')) + .catch((err) => console.error('Failed to save search mode:', err)); + }, + [loadConfig, resetSearchResultsState, setCombinedMode], + ); - const handleMetadataProviderChange = useCallback((provider: string) => { - if (combinedMode) { - setConfiguredCombinedMetadataProvider(provider); - } else if (contentType === 'audiobook') { - setConfiguredAudiobookMetadataProvider(provider); - } else { - setConfiguredMetadataProvider(provider); - } - const key = combinedMode - ? 'METADATA_PROVIDER_COMBINED' - : contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER'; - updateSelfUser({ settings: { [key]: provider } }) - .then(() => loadConfig('settings-saved')) - .catch((err) => console.error('Failed to save metadata provider:', err)); - }, [combinedMode, contentType, loadConfig]); - - const buildCurrentSearchRequest = useCallback((sortOverride?: string) => { - const appliedSort = effectiveSearchMode === 'universal' - ? getAppliedUniversalSort(sortOverride) - : (sortOverride ?? advancedFilters.sort); - const nextFilters = appliedSort === advancedFilters.sort && sortOverride === undefined - ? advancedFilters - : { ...advancedFilters, sort: appliedSort }; - - if (effectiveSearchMode === 'direct') { - const directFilters = { - ...nextFilters, - isbn: '', - author: '', - title: '', - }; + const handleMetadataProviderChange = useCallback( + (provider: string) => { + if (effectiveCombinedMode) { + setConfiguredCombinedMetadataProvider(provider); + } else if (effectiveContentType === 'audiobook') { + setConfiguredAudiobookMetadataProvider(provider); + } else { + setConfiguredMetadataProvider(provider); + } + let key = 'METADATA_PROVIDER'; + if (effectiveCombinedMode) { + key = 'METADATA_PROVIDER_COMBINED'; + } else if (effectiveContentType === 'audiobook') { + key = 'METADATA_PROVIDER_AUDIOBOOK'; + } + updateSelfUser({ settings: { [key]: provider } }) + .then(() => loadConfig('settings-saved')) + .catch((err) => console.error('Failed to save metadata provider:', err)); + }, + [effectiveCombinedMode, effectiveContentType, loadConfig], + ); - if (activeQueryOption?.source === 'direct-field') { - const nextValue = typeof activeQueryValue === 'string' ? activeQueryValue : String(activeQueryValue ?? ''); - if (activeQueryOption.key === 'isbn') { - directFilters.isbn = nextValue; - } else if (activeQueryOption.key === 'author') { - directFilters.author = nextValue; - } else if (activeQueryOption.key === 'title') { - directFilters.title = nextValue; + const buildCurrentSearchRequest = useCallback( + (sortOverride?: string) => { + const appliedSort = + effectiveSearchMode === 'universal' + ? getAppliedUniversalSort(sortOverride) + : (sortOverride ?? advancedFilters.sort); + const nextFilters = + appliedSort === advancedFilters.sort && sortOverride === undefined + ? advancedFilters + : { ...advancedFilters, sort: appliedSort }; + + if (effectiveSearchMode === 'direct') { + const directFilters = { + ...nextFilters, + isbn: '', + author: '', + title: '', + }; + + if (activeQueryOption?.source === 'direct-field') { + const nextValue = + typeof activeQueryValue === 'string' + ? activeQueryValue + : String(activeQueryValue ?? ''); + if (activeQueryOption.key === 'isbn') { + directFilters.isbn = nextValue; + } else if (activeQueryOption.key === 'author') { + directFilters.author = nextValue; + } else if (activeQueryOption.key === 'title') { + directFilters.title = nextValue; + } } + + const query = buildSearchQuery({ + searchInput: activeQueryOption?.source === 'general' ? searchInput : '', + showAdvanced: true, + advancedFilters: directFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode: effectiveSearchMode, + }); + + return { + query, + fieldValues: {}, + providerOverride: undefined, + appliedSort, + }; } + const fieldValues = + activeQueryOption?.source === 'provider-field' && + activeQueryOption.field && + activeQueryValue !== '' && + activeQueryValue !== false + ? { [activeQueryOption.field.key]: activeQueryValue } + : {}; + const query = buildSearchQuery({ - searchInput: activeQueryOption?.source === 'general' ? searchInput : '', + searchInput: + activeQueryOption?.source === 'general' || activeQueryOption?.source === 'manual' + ? searchInput + : '', showAdvanced: true, - advancedFilters: directFilters, + advancedFilters: nextFilters, bookLanguages, defaultLanguage: defaultLanguageCodes, searchMode: effectiveSearchMode, @@ -2147,120 +2175,95 @@ function App() { return { query, - fieldValues: {}, - providerOverride: undefined, + fieldValues, + providerOverride: effectiveMetadataProvider ?? undefined, appliedSort, }; - } - - const fieldValues = - activeQueryOption?.source === 'provider-field' - && activeQueryOption.field - && activeQueryValue !== '' - && activeQueryValue !== false - ? { [activeQueryOption.field.key]: activeQueryValue } - : {}; - - const query = buildSearchQuery({ - searchInput: - activeQueryOption?.source === 'general' || activeQueryOption?.source === 'manual' - ? searchInput - : '', - showAdvanced: true, - advancedFilters: nextFilters, + }, + [ + activeQueryOption, + activeQueryValue, + advancedFilters, bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode: effectiveSearchMode, - }); - - return { - query, - fieldValues, - providerOverride: effectiveMetadataProvider ?? undefined, - appliedSort, - }; - }, [ - activeQueryOption, - activeQueryValue, - advancedFilters, - bookLanguages, - defaultLanguageCodes, - effectiveMetadataProvider, - effectiveSearchMode, - getAppliedUniversalSort, - searchInput, - ]); + defaultLanguageCodes, + effectiveMetadataProvider, + effectiveSearchMode, + getAppliedUniversalSort, + searchInput, + ], + ); // Handle "View Series" - trigger search with series field and series order sort - const handleSearchSeries = useCallback((seriesName: string, seriesId?: string) => { - const seriesTarget = seriesBrowseTarget; - const seriesFieldKey = seriesTarget?.field?.key; - const seriesSort = seriesBrowseCapability?.sort; - if (!seriesTarget || !seriesFieldKey || !seriesSort) { - return; - } + const handleSearchSeries = useCallback( + (seriesName: string, seriesId?: string) => { + const seriesTarget = seriesBrowseTarget; + const seriesFieldKey = seriesTarget?.field?.key; + const seriesSort = seriesBrowseCapability?.sort; + if (!seriesTarget || !seriesFieldKey || !seriesSort) { + return; + } - // Clear UI state - setSearchInput(''); - setSelectedBook(null); - setReleaseBook(null); - clearTracking(); + // Clear UI state + setSearchInput(''); + setSelectedBook(null); + setReleaseBook(null); + clearTracking(); - const seriesFilters = { ...advancedFilters, sort: seriesSort }; - setActiveResultsSort(seriesSort); + const seriesFilters = { ...advancedFilters, sort: seriesSort }; + setActiveResultsSort(seriesSort); - setActiveQueryTarget(seriesTarget.key); - updateSearchFieldValue( - seriesFieldKey, - seriesId ? `id:${seriesId}` : seriesName, - seriesName, - ); + setActiveQueryTarget(seriesTarget.key); + updateSearchFieldValue(seriesFieldKey, seriesId ? `id:${seriesId}` : seriesName, seriesName); - const query = buildSearchQuery({ - searchInput: '', - showAdvanced: true, - advancedFilters: seriesFilters, - bookLanguages, - defaultLanguage: defaultLanguageCodes, - searchMode: effectiveSearchMode, - }); + const query = buildSearchQuery({ + searchInput: '', + showAdvanced: true, + advancedFilters: seriesFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode: effectiveSearchMode, + }); - runSearchWithPolicyRefresh({ - query, - fieldValues: { [seriesFieldKey]: seriesId ? `id:${seriesId}` : seriesName }, - searchModeOverride: effectiveSearchMode, - providerOverride: effectiveMetadataProvider ?? undefined, - }); - }, [ - advancedFilters, - bookLanguages, - clearTracking, - defaultLanguageCodes, - effectiveMetadataProvider, - effectiveSearchMode, - runSearchWithPolicyRefresh, - setAdvancedFilters, - setSearchInput, - seriesBrowseCapability?.sort, - seriesBrowseTarget, - updateSearchFieldValue, - ]); + runSearchWithPolicyRefresh({ + query, + fieldValues: { [seriesFieldKey]: seriesId ? `id:${seriesId}` : seriesName }, + searchModeOverride: effectiveSearchMode, + providerOverride: effectiveMetadataProvider ?? undefined, + }); + }, + [ + advancedFilters, + bookLanguages, + clearTracking, + defaultLanguageCodes, + effectiveMetadataProvider, + effectiveSearchMode, + runSearchWithPolicyRefresh, + setSearchInput, + seriesBrowseCapability?.sort, + seriesBrowseTarget, + updateSearchFieldValue, + ], + ); - const canSearchSeriesForBook = useCallback((book: Book | null): boolean => { - if (!book?.provider || !book.series_name) { - return false; - } + const canSearchSeriesForBook = useCallback( + (book: Book | null): boolean => { + if (!book?.provider || !book.series_name) { + return false; + } - if (!seriesBrowseCapability?.sort || !seriesBrowseTarget?.field || !activeMetadataConfig?.provider) { - return false; - } + if ( + !seriesBrowseCapability?.sort || + !seriesBrowseTarget?.field || + !activeMetadataConfig?.provider + ) { + return false; + } - return book.provider === activeMetadataConfig.provider; - }, [ - activeMetadataConfig?.provider, - seriesBrowseCapability?.sort, - seriesBrowseTarget?.field, - ]); + return book.provider === activeMetadataConfig.provider; + }, + [activeMetadataConfig?.provider, seriesBrowseCapability?.sort, seriesBrowseTarget?.field], + ); const handleManualSearch = useCallback(() => { const trimmed = searchInput.trim(); @@ -2277,12 +2280,6 @@ function App() { setReleaseBook(syntheticBook); }, [searchInput]); - useEffect(() => { - if (!manualSearchAllowed && activeQueryTarget === 'manual') { - setActiveQueryTarget(getDefaultQueryTargetKey(queryTargets)); - } - }, [manualSearchAllowed, activeQueryTarget, queryTargets]); - // Unified search dispatch: intercepts manual search mode, otherwise runs normal search const handleSearchDispatch = useCallback(() => { if (activeQueryOption?.source === 'manual') { @@ -2291,9 +2288,9 @@ function App() { } const request = buildCurrentSearchRequest(); const shouldPersistAppliedSort = !( - effectiveSearchMode === 'universal' - && activeQueryUsesSeriesBrowse - && request.appliedSort === seriesBrowseCapability?.sort + effectiveSearchMode === 'universal' && + activeQueryUsesSeriesBrowse && + request.appliedSort === seriesBrowseCapability?.sort ); if (shouldPersistAppliedSort && request.appliedSort !== advancedFilters.sort) { @@ -2320,16 +2317,22 @@ function App() { const isBrowseFulfilMode = fulfillingRequest !== null; const activeReleaseBook = fulfillingRequest?.book ?? releaseBook; - const activeReleaseContentType = fulfillingRequest?.contentType ?? combinedState?.phase ?? contentType; - const combinedSelectionPhases = combinedState ? getCombinedSelectionPhases(combinedState) : []; - const combinedCurrentStep = combinedState ? combinedSelectionPhases.indexOf(combinedState.phase) + 1 : 0; - const combinedIsFinalStep = combinedState - ? combinedSelectionPhases[combinedSelectionPhases.length - 1] === combinedState.phase + const activeReleaseContentType = + fulfillingRequest?.contentType ?? effectiveCombinedState?.phase ?? effectiveContentType; + const combinedSelectionPhases = effectiveCombinedState + ? getCombinedSelectionPhases(effectiveCombinedState) + : []; + const combinedCurrentStep = effectiveCombinedState + ? combinedSelectionPhases.indexOf(effectiveCombinedState.phase) + 1 + : 0; + const combinedIsFinalStep = effectiveCombinedState + ? combinedSelectionPhases[combinedSelectionPhases.length - 1] === effectiveCombinedState.phase : false; - const combinedHasPreviousStep = combinedState - ? combinedSelectionPhases.indexOf(combinedState.phase) > 0 + const combinedHasPreviousStep = effectiveCombinedState + ? combinedSelectionPhases.indexOf(effectiveCombinedState.phase) > 0 : false; - const usePinnedMainScrollContainer = sidebarPinnedOpen; + const usePinnedMainScrollContainer = + downloadsSidebarOpen && isDesktopViewport && sidebarPinnedOpen; const handleReleaseModalClose = useCallback(() => { if (isBrowseFulfilMode) { @@ -2340,22 +2343,27 @@ function App() { setReleaseBook(null); }, [isBrowseFulfilMode]); - const pendingOnBehalfTitle = pendingOnBehalfDownload - ? pendingOnBehalfDownload.type === 'book' - ? pendingOnBehalfDownload.book.title || 'Untitled' - : pendingOnBehalfDownload.type === 'combined' - ? pendingOnBehalfDownload.book.title || 'Untitled' - : pendingOnBehalfDownload.release.title || - pendingOnBehalfDownload.book.title || - 'Untitled' - : ''; - const pendingOnBehalfUserName = pendingOnBehalfDownload - ? formatActingAsUserName(pendingOnBehalfDownload.actingAsUser) + let pendingOnBehalfTitle = ''; + if (effectivePendingOnBehalfDownload) { + if ( + effectivePendingOnBehalfDownload.type === 'book' || + effectivePendingOnBehalfDownload.type === 'combined' + ) { + pendingOnBehalfTitle = effectivePendingOnBehalfDownload.book.title || 'Untitled'; + } else { + pendingOnBehalfTitle = + effectivePendingOnBehalfDownload.release.title || + effectivePendingOnBehalfDownload.book.title || + 'Untitled'; + } + } + const pendingOnBehalfUserName = effectivePendingOnBehalfDownload + ? formatActingAsUserName(effectivePendingOnBehalfDownload.actingAsUser) : ''; const mainAppContent = ( -
+
setDownloadsSidebarOpen((prev) => !prev)} - onSettingsClick={() => { - if (config?.settings_enabled) { - if (authIsAdmin) { - setSettingsOpen(true); - } else { - setSelfSettingsOpen(true); - } - } else { - setConfigBannerOpen(true); - } - }} + onDownloadsClick={toggleDownloadsSidebar} + onSettingsClick={handleSettingsClick} isAdmin={requestRoleIsAdmin} canAccessSettings={isAuthenticated} username={username} displayName={displayName} - actingAsUser={actingAsUser} + actingAsUser={effectiveActingAsUser} onActingAsUserChange={setActingAsUser} + adminUsers={availableActingAsUsers} + isAdminUsersLoading={isAdminUsersLoading} + adminUsersError={adminUsersError} + hasLoadedAdminUsers={hasLoadedAdminUsers} + onLoadAdminUsers={loadAdminUsers} statusCounts={statusCounts} onLogoClick={() => { handleResetSearch(config); @@ -2391,20 +2394,24 @@ function App() { }} authRequired={authRequired} isAuthenticated={isAuthenticated} - onLogout={handleLogoutWithCleanup} + onLogout={() => { + void handleLogoutWithCleanup(); + }} onSearch={handleSearchDispatch} - onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined} - isAdvancedActive={showAdvanced} + onAdvancedToggle={ + hasAdvancedContent ? () => setShowAdvanced(!effectiveShowAdvanced) : undefined + } + isAdvancedActive={effectiveShowAdvanced} isLoading={isSearching} onShowToast={showToast} onRemoveToast={removeToast} - contentType={contentType} + contentType={effectiveContentType} onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} - combinedMode={combinedMode} + combinedMode={effectiveCombinedMode} onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} queryTargets={queryTargets} - activeQueryTarget={activeQueryTarget} + activeQueryTarget={effectiveActiveQueryTarget} onQueryTargetChange={setActiveQueryTarget} activeQueryField={activeQueryField} /> @@ -2412,9 +2419,7 @@ function App() {
setShowAdvanced(false)} /> - {!isInitialState && activeQueryTarget === 'manual' && ( -

- Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates. + {!isInitialState && effectiveActiveQueryTarget === 'manual' && ( +

+ Manual search queries release sources directly. Some sources may return limited + metadata, which can affect file naming templates.

)} -
- setShowAdvanced(!showAdvanced) : undefined} - advancedFilters={advancedFilters} - onAdvancedFiltersChange={updateAdvancedFilters} - contentType={contentType} - onContentTypeChange={setContentType} - allowedContentTypes={allowedContentTypes} - combinedMode={combinedMode} - onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} - activeQueryField={activeQueryField} - searchMode={effectiveSearchMode} - onSearchModeChange={handleSearchModeChange} - metadataProviders={metadataProviders} - activeMetadataProvider={effectiveMetadataProvider} - onMetadataProviderChange={handleMetadataProviderChange} - isAdmin={requestRoleIsAdmin} - /> - - { - const request = buildCurrentSearchRequest(value); - const shouldPersistAppliedSort = !( - effectiveSearchMode === 'universal' - && activeQueryUsesSeriesBrowse - && request.appliedSort === seriesBrowseCapability?.sort - ); - if (shouldPersistAppliedSort) { - updateAdvancedFilters({ sort: request.appliedSort }); - } - setActiveResultsSort(request.appliedSort); - runSearchWithPolicyRefresh({ - query: request.query, - fieldValues: request.fieldValues, - searchModeOverride: effectiveSearchMode, - providerOverride: request.providerOverride, - }); - }} - metadataSortOptions={resolvedMetadataSortOptions} - hasMore={hasMore} - isLoadingMore={isLoadingMore} - onLoadMore={() => loadMore(config, effectiveSearchMode)} - totalFound={totalFound} - onShowToast={showToast} - resultsSourceUrl={resultsSourceUrl} - /> - - {selectedBook && ( - setSelectedBook(null)} - onDownload={handleDownload} - onShowToast={showToast} - onFindDownloads={(book) => { - setSelectedBook(null); - void handleGetReleases(book); - }} - onSearchSeries={canSearchSeriesForBook(selectedBook) ? handleSearchSeries : undefined} - buttonState={ - isMetadataBook(selectedBook) - ? getUniversalActionButtonState(selectedBook.id) - : getDirectActionButtonState(selectedBook.id) +
+ setShowAdvanced(!effectiveShowAdvanced) : undefined } - showReleaseSourceLinks={config?.show_release_source_links !== false} + advancedFilters={advancedFilters} + onAdvancedFiltersChange={updateAdvancedFilters} + contentType={effectiveContentType} + onContentTypeChange={setContentType} + allowedContentTypes={allowedContentTypes} + combinedMode={effectiveCombinedMode} + onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} + activeQueryField={activeQueryField} + searchMode={effectiveSearchMode} + onSearchModeChange={handleSearchModeChange} + metadataProviders={metadataProviders} + activeMetadataProvider={effectiveMetadataProvider} + onMetadataProviderChange={handleMetadataProviderChange} + isAdmin={requestRoleIsAdmin} /> - )} - {activeReleaseBook && ( - 'download' : (source, ct) => getSourceMode(source, ct)} - onPolicyRefresh={handleReleaseModalPolicyRefresh} - supportedFormats={supportedFormats} - supportedAudiobookFormats={config?.supported_audiobook_formats || []} - contentType={activeReleaseContentType} - defaultLanguages={defaultLanguageCodes} - bookLanguages={bookLanguages} - currentStatus={statusForButtonState} - defaultReleaseSource={config?.default_release_source} - defaultAudiobookReleaseSource={config?.default_release_source_audiobook} - onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries} - defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} - isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} - showReleaseSourceLinks={config?.show_release_source_links !== false} + onSortChange={(value) => { + const request = buildCurrentSearchRequest(value); + const shouldPersistAppliedSort = !( + effectiveSearchMode === 'universal' && + activeQueryUsesSeriesBrowse && + request.appliedSort === seriesBrowseCapability?.sort + ); + if (shouldPersistAppliedSort) { + updateAdvancedFilters({ sort: request.appliedSort }); + } + setActiveResultsSort(request.appliedSort); + runSearchWithPolicyRefresh({ + query: request.query, + fieldValues: request.fieldValues, + searchModeOverride: effectiveSearchMode, + providerOverride: request.providerOverride, + }); + }} + metadataSortOptions={resolvedMetadataSortOptions} + hasMore={hasMore} + isLoadingMore={isLoadingMore} + onLoadMore={() => { + void loadMore(config, effectiveSearchMode); + }} + totalFound={totalFound} onShowToast={showToast} - combinedMode={combinedState ? { - phase: combinedState.phase, - stepLabel: `Step ${combinedCurrentStep} of ${combinedSelectionPhases.length} — Select ${combinedState.phase === 'ebook' ? 'book' : 'audiobook'}`, - ebookMode: combinedState.ebookMode, - audiobookMode: combinedState.audiobookMode, - stagedEbookRelease: combinedState.stagedEbook?.release ?? null, - stagedAudiobookRelease: combinedState.stagedAudiobook ?? null, - onNext: !combinedIsFinalStep ? handleCombinedNext : undefined, - onBack: combinedHasPreviousStep ? handleCombinedBack : undefined, - onDownload: combinedIsFinalStep ? handleCombinedDownload : undefined, - } : null} - /> - )} - - {pendingRequestPayload && ( - { setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); }} + resultsSourceUrl={resultsSourceUrl} /> - )} - {pendingOnBehalfDownload && ( - setPendingOnBehalfDownload(null)} + {selectedBook && ( + setSelectedBook(null)} + onDownload={handleDownload} + onShowToast={showToast} + onFindDownloads={(book) => { + setSelectedBook(null); + void handleGetReleases(book); + }} + onSearchSeries={canSearchSeriesForBook(selectedBook) ? handleSearchSeries : undefined} + buttonState={ + isMetadataBook(selectedBook) + ? getUniversalActionButtonState(selectedBook.id) + : getDirectActionButtonState(selectedBook.id) + } + showReleaseSourceLinks={config?.show_release_source_links !== false} + /> + )} + + {activeReleaseBook && ( + 'download' : (source, ct) => getSourceMode(source, ct) + } + supportedFormats={supportedFormats} + supportedAudiobookFormats={config?.supported_audiobook_formats || []} + contentType={activeReleaseContentType} + defaultLanguages={defaultLanguageCodes} + bookLanguages={bookLanguages} + currentStatus={statusForButtonState} + defaultReleaseSource={config?.default_release_source} + defaultAudiobookReleaseSource={config?.default_release_source_audiobook} + onSearchSeries={ + isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) + ? undefined + : handleSearchSeries + } + defaultShowManualQuery={ + isBrowseFulfilMode || activeReleaseBook?.provider === 'manual' + } + isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} + showReleaseSourceLinks={config?.show_release_source_links !== false} + onShowToast={showToast} + combinedMode={ + effectiveCombinedState + ? { + phase: effectiveCombinedState.phase, + stepLabel: `Step ${combinedCurrentStep} of ${combinedSelectionPhases.length} — Select ${effectiveCombinedState.phase === 'ebook' ? 'book' : 'audiobook'}`, + ebookMode: effectiveCombinedState.ebookMode, + audiobookMode: effectiveCombinedState.audiobookMode, + stagedEbookRelease: effectiveCombinedState.stagedEbook?.release ?? null, + stagedAudiobookRelease: effectiveCombinedState.stagedAudiobook ?? null, + onNext: !combinedIsFinalStep ? handleCombinedNext : undefined, + onBack: combinedHasPreviousStep ? handleCombinedBack : undefined, + onDownload: combinedIsFinalStep + ? (release) => { + void handleCombinedDownload(release); + } + : undefined, + } + : null + } + /> + )} + + {pendingRequestPayload && ( + { + setPendingRequestPayload(null); + setPendingRequestExtraPayloads([]); + }} + /> + )} + + {effectivePendingOnBehalfDownload && ( + setPendingOnBehalfDownload(null)} + /> + )} +
+ +
+
- )} - -
- -
-
-
+
{ + void handleCancel(id); + }} + onRetry={(id) => { + void handleRetry(id); + }} onDownloadDismiss={handleDownloadDismiss} requestItems={requestItems} dismissedItemKeys={dismissedActivityKeys} @@ -2639,7 +2670,7 @@ function App() { onActiveTabChange={handleActivityTabChange} pendingRequestCount={pendingRequestCount} showRequestsTab={showRequestsTab} - isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading} + isRequestsLoading={isActivitySnapshotLoading} onRequestCancel={showRequestsTab ? handleRequestCancel : undefined} onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined} onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined} @@ -2667,9 +2698,7 @@ function App() { /> {/* Auto-show banner on startup for users without config */} - {config && ( - - )} + {config && } {/* Controlled banner shown when clicking settings without config */} { setConfigBannerOpen(false); if (authIsAdmin) { + void primeUsersCache(); + void primeSettingsCache(); setSettingsOpen(true); } else { setSelfSettingsOpen(true); @@ -2689,10 +2720,11 @@ function App() { setOnboardingOpen(false)} - onComplete={() => loadConfig('settings-saved')} + onComplete={() => { + void loadConfig('settings-saved'); + }} onShowToast={showToast} /> - ); @@ -2707,56 +2739,124 @@ function App() { whiteSpace: 'nowrap', border: 0, }; + const authenticatedBootstrapKey = + authChecked && isAuthenticated ? `${username ?? 'authenticated'}:${String(authIsAdmin)}` : null; + const authenticatedBootstrap = authenticatedBootstrapKey ? ( + + ) : null; + const adminSettingsWarmupKey = + authChecked && isAuthenticated && authIsAdmin && config?.settings_enabled + ? `${username ?? 'authenticated'}:settings-warmup` + : null; + const adminSettingsWarmup = adminSettingsWarmupKey ? ( + + ) : null; + const urlSearchBootstrapMount = + wasProcessed && parsedParams && config && !hasExecutedUrlSearchBootstrap ? ( + { + setHasExecutedUrlSearchBootstrap(true); + }} + /> + ) : null; + const metadataConfigSession = metadataConfigSessionKey ? ( + { + setActiveMetadataConfigState({ + sessionKey: metadataConfigSessionKey, + config: nextConfig, + }); + }} + /> + ) : null; if (!authChecked) { return ( -
- Checking authentication… -
+ <> + {authenticatedBootstrap} + {adminSettingsWarmup} + {metadataConfigSession} + {urlSearchBootstrapMount} +
+ Checking authentication… +
+ ); } // Wait for config to load before rendering main UI to prevent flicker if (isAuthenticated && !config) { return ( -
- Loading configuration… -
+ <> + {authenticatedBootstrap} + {adminSettingsWarmup} + {metadataConfigSession} + {urlSearchBootstrapMount} +
+ Loading configuration… +
+ ); } const shouldRedirectFromLogin = !authRequired || isAuthenticated; const postLoginPath = getReturnToFromSearch(location.search); const loginRedirectPath = buildLoginRedirectPath(location); - const appElement = authRequired && !isAuthenticated ? ( - - ) : ( - mainAppContent - ); + const appElement = + authRequired && !isAuthenticated ? : mainAppContent; return ( - - - ) : ( - - ) - } - /> - - + <> + {authenticatedBootstrap} + {adminSettingsWarmup} + {metadataConfigSession} + {urlSearchBootstrapMount} + + + ) : ( + { + void handleLogin(credentials); + }} + error={loginError} + isLoading={isLoggingIn} + authMode={authMode} + oidcButtonLabel={oidcButtonLabel} + hideLocalAuth={hideLocalAuth} + oidcAutoRedirect={oidcAutoRedirect} + /> + ) + } + /> + + + ); } -export default App; +export { App }; diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index 644fbba6..20d56f45 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -1,5 +1,7 @@ -import { ReactNode } from 'react'; -import { +import type { ReactNode } from 'react'; + +import { CONTENT_OPTIONS } from '../data/filterOptions'; +import type { AdvancedFilterState, ContentType, Language, @@ -7,11 +9,21 @@ import { SearchMode, } from '../types'; import { normalizeLanguageSelection } from '../utils/languageFilters'; -import { LanguageMultiSelect } from './LanguageMultiSelect'; import { DropdownList } from './DropdownList'; -import { CONTENT_OPTIONS } from '../data/filterOptions'; +import { LanguageMultiSelect } from './LanguageMultiSelect'; -const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const; +const FORMAT_TYPES = [ + 'pdf', + 'epub', + 'mobi', + 'azw3', + 'fb2', + 'djvu', + 'cbz', + 'cbr', + 'zip', + 'rar', +] as const; interface AdvancedFiltersProps { visible: boolean; @@ -33,8 +45,17 @@ interface AdvancedFiltersProps { } const SEARCH_MODE_OPTIONS = [ - { value: 'direct', label: 'Direct', description: 'Search web sources for books and download directly. Works out of the box.' }, - { value: 'universal', label: 'Universal', description: 'Metadata-based search with downloads from all sources. Book and Audiobook support.' }, + { + value: 'direct', + label: 'Direct', + description: 'Search web sources for books and download directly. Works out of the box.', + }, + { + value: 'universal', + label: 'Universal', + description: + 'Metadata-based search with downloads from all sources. Book and Audiobook support.', + }, ]; export const AdvancedFilters = ({ @@ -63,16 +84,21 @@ export const AdvancedFilters = ({ }; const handleContentChange = (next: string[] | string) => { - const value = Array.isArray(next) ? next[0] ?? '' : next; + const value = Array.isArray(next) ? (next[0] ?? '') : next; onFiltersChange({ content: value }); }; const handleFormatsChange = (next: string[] | string) => { - const nextFormats = Array.isArray(next) ? next : next ? [next] : []; + let nextFormats: string[] = []; + if (Array.isArray(next)) { + nextFormats = next; + } else if (next) { + nextFormats = [next]; + } onFiltersChange({ formats: nextFormats }); }; - const formatOptions = FORMAT_TYPES.map(format => ({ + const formatOptions = FORMAT_TYPES.map((format) => ({ value: format, label: format.toUpperCase(), })); @@ -91,25 +117,30 @@ export const AdvancedFilters = ({ }; }); + let metadataProviderLabel = 'Book Metadata Provider'; + if (combinedMode) { + metadataProviderLabel = 'Combined Metadata Provider'; + } else if (contentType === 'audiobook') { + metadataProviderLabel = 'Audiobook Metadata Provider'; + } + if (!visible) return null; - const wrapperClassName = formClassName - ? 'px-2' - : 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]'; + const wrapperClassName = formClassName ? 'px-2' : 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]'; const settingsForm = (
{onClose && ( -
+
{/* Content */} -
+

{showContinueButton ? 'To save settings, add a config volume to your Docker Compose file:' @@ -113,20 +118,25 @@ export const ConfigSetupBanner = ({

{/* Code snippet */} -
-
+
+
docker-compose.yml
               
-                services:{'\n'}
-                {'  '}shelfmark:{'\n'}
+                services:
+                {'\n'}
+                {'  '}shelfmark:
+                {'\n'}
                 {'    '}volumes:{'\n'}
-                {'      '}- /path/to/config:/config
+                {'      '}- /path/to/config:
+                /config
               
             
@@ -139,32 +149,29 @@ export const ConfigSetupBanner = ({
{/* Footer */} -
+
{showContinueButton ? ( <> ) : ( diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index da7d45cc..ad5b4af2 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -1,21 +1,41 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Book, ButtonStateInfo, isMetadataBook } from '../types'; + +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import { useEscapeKey } from '../hooks/useEscapeKey'; +import { useMountEffect } from '../hooks/useMountEffect'; +import type { Book, ButtonStateInfo } from '../types'; +import { isMetadataBook } from '../types'; +import { bookSupportsTargets } from '../utils/bookTargetLoader'; import { isUserCancelledError } from '../utils/errors'; import { BookTargetDropdown } from './BookTargetDropdown'; -import { bookSupportsTargets } from '../utils/bookTargetLoader'; interface DetailsModalProps { book: Book | null; onClose: () => void; onDownload: (book: Book) => Promise; - onFindDownloads?: (book: Book) => void; // For Universal mode - onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series + onFindDownloads?: (book: Book) => void; // For Universal mode + onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series buttonState: ButtonStateInfo; showReleaseSourceLinks?: boolean; onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; } +interface DetailsModalAutoCloseProps { + clearQueuing: () => void; + closeModal: () => void; +} + +const DetailsModalAutoClose = ({ clearQueuing, closeModal }: DetailsModalAutoCloseProps) => { + useMountEffect(() => { + clearQueuing(); + const timer = window.setTimeout(closeModal, 500); + return () => window.clearTimeout(timer); + }); + + return null; +}; + export const DetailsModal = ({ book, onClose, @@ -37,37 +57,8 @@ export const DetailsModal = ({ }, 150); }, [onClose]); - // Clear queuing state and close modal once button state changes from download - useEffect(() => { - if (isQueuing && buttonState.state !== 'download') { - setIsQueuing(false); - // Close modal after status has updated - const timer = setTimeout(handleClose, 500); - return () => clearTimeout(timer); - } - }, [buttonState.state, isQueuing, handleClose]); - - // Handle ESC key to close modal - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - handleClose(); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [handleClose]); - - useEffect(() => { - if (book) { - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = previousOverflow; - }; - } - }, [book]); + useBodyScrollLock(Boolean(book)); + useEscapeKey(Boolean(book), handleClose); const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book)); @@ -98,7 +89,15 @@ export const DetailsModal = ({ isMetadata && buttonState.state === 'download' && buttonState.text === 'Get' ? 'Find Downloads' : buttonState.text; + const downloadButtonClassName = (() => { + if (buttonState.state === 'blocked') { + return 'bg-gray-500'; + } + return isMetadata ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-sky-700 hover:bg-sky-800'; + })(); const publisherInfo = { label: 'Publisher', value: book.publisher || '-' }; + const bookProvider = book.provider; + const bookProviderId = book.provider_id; // Build metadata grid based on mode // Universal mode: Year, Genres (no language, no publisher - often blank from providers) @@ -116,18 +115,22 @@ export const DetailsModal = ({ { label: 'Language', value: book.language || '-' }, { label: 'Format', value: book.format || '-' }, { label: 'Size', value: book.size || '-' }, - ...(downloadCount ? [{ label: 'Downloads', value: Number(downloadCount).toLocaleString() }] : []), + ...(downloadCount + ? [{ label: 'Downloads', value: Number(downloadCount).toLocaleString() }] + : []), ]; // Extract rating and readers from display_fields for dedicated boxes (Universal mode) - const ratingField = isMetadata && book.display_fields?.find(f => f.icon === 'star'); - const readersField = isMetadata && book.display_fields?.find(f => f.icon === 'users'); + const ratingField = isMetadata && book.display_fields?.find((f) => f.icon === 'star'); + const readersField = isMetadata && book.display_fields?.find((f) => f.icon === 'users'); // Other display fields (pages, editions, etc.) shown inline - const otherDisplayFields = isMetadata && book.display_fields?.filter(f => f.icon !== 'star' && f.icon !== 'users'); + const otherDisplayFields = + isMetadata && book.display_fields?.filter((f) => f.icon !== 'star' && f.icon !== 'users'); // Use provider display name from backend, fall back to capitalized provider name - const providerDisplay = book.provider_display_name - || (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : ''); + const providerDisplay = + book.provider_display_name || + (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : ''); const isSquareCover = book.cover_aspect === 'square'; const artworkMaxHeight = 'calc(90vh - 220px)'; const artworkMaxWidth = isSquareCover @@ -141,28 +144,36 @@ export const DetailsModal = ({ }) : []; const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo]; - const infoCardClass = 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)'; + const infoCardClass = + 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)'; const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400'; const infoValueClass = 'text-gray-900 dark:text-gray-100'; const modal = ( -
{ - if (e.target === e.currentTarget) handleClose(); - }} - > +
+ {isQueuing && buttonState.state !== 'download' && ( + setIsQueuing(false)} closeModal={handleClose} /> + )} + @@ -306,11 +355,13 @@ export const DetailsModal = ({ {/* Extended info (publisher, etc.) - Direct Download mode only */} {!isMetadata && extendedInfoEntries.length > 0 && (
-
    +
      {extendedInfoEntries.map(([key, value]) => (
    • {key}

      -

      {Array.isArray(value) ? value.join(', ') : value}

      +

      + {Array.isArray(value) ? value.join(', ') : value} +

    • ))}
    @@ -320,9 +371,7 @@ export const DetailsModal = ({
-