diff --git a/.gitignore b/.gitignore index 2ccf0de9..ef41d8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ plugins/**/openapi.json seed-config.yaml -.playwright-mcp \ No newline at end of file +.playwright-mcp +qa-*.png \ No newline at end of file diff --git a/docs/docs/offline-reading.md b/docs/docs/offline-reading.md new file mode 100644 index 00000000..a49dc967 --- /dev/null +++ b/docs/docs/offline-reading.md @@ -0,0 +1,96 @@ +--- +sidebar_position: 8 +--- + +# Offline Reading + +Codex can save individual books or whole series to your device so you can keep reading without a network connection: on a flight, on the train, or anywhere mobile data is patchy. Downloads live in your browser's storage, so each device manages its own offline library. + +## What can be downloaded + +| Format | What gets saved | Notes | +|--------|-----------------|-------| +| EPUB | Single book file | One request to `/api/v1/books/{id}/file` | +| PDF | Single book file | Same as EPUB | +| CBZ | Every page, one at a time | The server-rendered images at the resolution your reader uses | +| CBR | Every page, one at a time | Same as CBZ | + +Raw archive files (CBZ/CBR) are not cached as-is. Codex caches the server-rendered page images instead, so phones do not download a 50 MB archive when only ~5 MB of images are needed to read. + +## Saving a single book + +On a book's detail page, tap the cloud-down icon in the action row: + +- **Cloud-down icon**: not saved offline. Tap to start the download. +- **Spinner ring**: download in progress. Tap the red X next to it to cancel. +- **Green cloud-check icon**: saved. Tap to open a menu with **Re-download** or **Remove offline copy**. + +The download runs in the foreground. You can keep using Codex in other tabs, but closing the tab pauses the download. Codex remembers what was already saved and resumes from the next page when you come back. + +## Saving a whole series + +Open the series detail page and tap **Download series**. A modal lists every book with its format. **Start downloading** runs them one at a time so the network does not get flooded. + +While the queue runs you can: + +- Tap the red **X** next to any book to cancel that one. The other books keep going. +- Tap **Cancel all** at the top to stop everything that has not yet finished. +- Close the modal — the queue keeps running. A badge on the Download series button shows aggregate progress (e.g. `2/12`). + +Before the queue starts, Codex estimates the total size and compares it to the available storage on your device. If the queue would use more than 90% of your quota it is refused with a clear message and no books are downloaded. Free up storage from **Settings → Offline downloads**, or remove a few books you have already read, then try again. + +## Managing what is saved + +Settings → **Offline downloads** lists every book currently on this device with its size and the date it was saved. From there you can: + +- See a meter for **Storage used / available** based on the browser's quota estimate. +- See a **Storage durability** indicator that tells you whether the browser has marked your data as persistent. +- Remove individual downloads (frees up storage immediately). +- **Clear all downloads** in one action. + +Removing a book from this list also removes its cached pages, so the next time you open it offline you will see a network error instead of stale content. + +## Reading progress while offline + +Page turns and "mark as read" actions made offline are not lost. Codex queues them in a small outbox and replays them when your browser comes back online — either when the operating system fires the `online` event or the next time the tab becomes visible. Conflict resolution is last-write-wins by client timestamp, so the most recent progress for each book ends up on the server. + +You do not have to do anything special: just keep reading. The outbox is invisible unless you go looking for it. + +## How durable are these downloads? + +It depends on your browser: + +| Surface | Durability | +|---------|-----------| +| **Desktop Chrome / Firefox / Edge** | Very durable. The browser only evicts under severe storage pressure. | +| **Android Chrome (tab)** | Durable for as long as Codex is actively used. | +| **Installed PWA (any platform)** | Most durable. The browser treats installed PWAs as application data. | +| **iOS Safari (tab)** | The browser may clear offline storage after about a week of inactivity, even if you call it persistent. The first time you download something from an iOS Safari tab, Codex shows a soft nudge that explains this and suggests adding Codex to your Home Screen. | + +If you read on an iPhone or iPad regularly, install Codex to your Home Screen for the best offline experience. From the Settings → Offline downloads page you can also see at a glance whether your browser has marked storage as persistent. + +## Install Codex on your phone + +On any device, the **Install Codex** prompt that appears in the corner of the screen will add Codex to your home screen / app launcher. + +For iOS Safari specifically: + +1. Tap the **Share** icon in the bottom toolbar. +2. Scroll down and choose **Add to Home Screen**. +3. Confirm the name and tap **Add**. + +Once installed, Codex opens in a full-screen window without the Safari address bar, and offline downloads survive ordinary periods of inactivity. + +## Troubleshooting + +**A book I downloaded says "could not load" when I open it offline.** +The book may have been removed from the offline list (Settings → Offline downloads) or the browser may have evicted it under storage pressure. Re-download it. + +**The Download series button says my storage is full but Settings → Offline downloads shows much less than 90% used.** +The storage quota covers everything the browser stores for Codex, not only offline downloads (caches, IndexedDB, application data all count). Try **Clear all downloads** and re-download only what you need. + +**My reading progress did not sync after I reconnected.** +The outbox drains on the `online` event and on tab-visibility changes. Switch to another tab and back, or refresh the page. If progress still does not appear on the server, the original write may have failed at a layer above the outbox; check your browser's network panel for the most recent `PUT /api/v1/books/.../read-progress` response. + +**The series download was interrupted when I closed my laptop.** +Foreground downloads stop when the tab closes. Re-open the series and tap **Download series** again — books that completed are still saved and the queue will only re-fetch the rest. diff --git a/web/index.html b/web/index.html index 382fd825..11d7eac2 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,19 @@ - + + + + + + + + + + Codex diff --git a/web/package-lock.json b/web/package-lock.json index f2e1c1db..335bcd8e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -54,6 +54,7 @@ "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react-swc": "^4.2.3", "@vitest/ui": "^4.0.18", + "fake-indexeddb": "^6.2.5", "globals": "^16.5.0", "happy-dom": "^16.7.0", "jsdom": "^25.0.1", @@ -64,6 +65,7 @@ "postcss-simple-vars": "^7.0.1", "typescript": "~5.9.3", "vite": "^7.3.1", + "vite-plugin-pwa": "^1.3.0", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18" } @@ -75,6 +77,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -104,4215 +123,7223 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@biomejs/biome": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz", - "integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==", + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.4", - "@biomejs/cli-darwin-x64": "2.4.4", - "@biomejs/cli-linux-arm64": "2.4.4", - "@biomejs/cli-linux-arm64-musl": "2.4.4", - "@biomejs/cli-linux-x64": "2.4.4", - "@biomejs/cli-linux-x64-musl": "2.4.4", - "@biomejs/cli-win32-arm64": "2.4.4", - "@biomejs/cli-win32-x64": "2.4.4" + "url": "https://opencollective.com/babel" } }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz", - "integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz", - "integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz", - "integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz", - "integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" } }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz", - "integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", + "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.29.0", + "semver": "^6.3.1" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz", - "integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz", - "integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz", - "integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=6.9.0" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { - "node": ">=18" + "node": ">=6.9.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@babel/core": "^7.0.0" } }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@babel/types": "^7.27.1" }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", + "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" } }, - "node_modules/@faker-js/faker": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz", - "integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==", + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" - } + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@floating-ui/react": { - "version": "0.27.17", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", - "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.7", - "@floating-ui/utils": "^0.2.10", - "tabbable": "^6.0.0" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.5" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=18" + "node": ">=6.9.0" }, "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@babel/core": "^7.0.0-0" } }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" }, "engines": { - "node": ">=18" + "node": ">=6.9.0" }, "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@babel/core": "^7.0.0-0" } }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" }, "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "dev": true, - "license": "MIT" - }, - "node_modules/@mantine/core": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", - "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", "license": "MIT", "dependencies": { - "@floating-ui/react": "^0.27.16", - "clsx": "^2.1.1", - "react-number-format": "^5.4.4", - "react-remove-scroll": "^2.7.1", - "react-textarea-autosize": "8.5.9", - "type-fest": "^4.41.0" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@mantine/hooks": "8.3.15", - "react": "^18.x || ^19.x", - "react-dom": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mantine/dropzone": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.15.tgz", - "integrity": "sha512-12bx1msHULi4D2/VV2PHTBBSshjax/ogLZEIAewX4tK0vRN3OKtA0qR+lqKhywUW4KYv4Z9Dr6O1LoGKHntrUA==", + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, "license": "MIT", "dependencies": { - "react-dropzone": "15.0.0" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@mantine/core": "8.3.15", - "@mantine/hooks": "8.3.15", - "react": "^18.x || ^19.x", - "react-dom": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mantine/form": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.15.tgz", - "integrity": "sha512-A6S70KSPjkKkuXxplqTQbPJZ/pkVfJXU/I5bnsSpGacTJxUlU6KR9Ez+Wwea+NHsupl2MHks98oC0f/UiqWbwQ==", + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "klona": "^2.0.6" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "react": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mantine/hooks": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", - "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, "peerDependencies": { - "react": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mantine/notifications": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.15.tgz", - "integrity": "sha512-CJGSv8oeLWyJIVPninU7Ud6vV6/UJKWZJwRGBNg2K0Ak0U0coFN3gW3H6G1Mh2zllNxb3K4fpMJNz4Iy0sCBFw==", + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, "license": "MIT", "dependencies": { - "@mantine/store": "8.3.15", - "react-transition-group": "4.4.5" + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@mantine/core": "8.3.15", - "@mantine/hooks": "8.3.15", - "react": "^18.x || ^19.x", - "react-dom": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mantine/store": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.15.tgz", - "integrity": "sha512-wdx91a73dM2G02YPIZ9i5UXPWfvjdf3qPAwSGnSsBFQg5uM/5CcPAOOQwlYIkvX1edUA5BFOk/4IjpEXSYUDeQ==", + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, "peerDependencies": { - "react": "^18.x || ^19.x" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.2.tgz", - "integrity": "sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==", + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dev": true, "license": "MIT", "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.94.tgz", - "integrity": "sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==", + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, "license": "MIT", - "optional": true, - "workspaces": [ - "e2e/*" - ], + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.94", - "@napi-rs/canvas-darwin-arm64": "0.1.94", - "@napi-rs/canvas-darwin-x64": "0.1.94", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.94", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.94", - "@napi-rs/canvas-linux-arm64-musl": "0.1.94", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.94", - "@napi-rs/canvas-linux-x64-gnu": "0.1.94", - "@napi-rs/canvas-linux-x64-musl": "0.1.94", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.94", - "@napi-rs/canvas-win32-x64-msvc": "0.1.94" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.94.tgz", - "integrity": "sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.94.tgz", - "integrity": "sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.94.tgz", - "integrity": "sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.94.tgz", - "integrity": "sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==", - "cpu": [ - "arm" - ], + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.94.tgz", - "integrity": "sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.94.tgz", - "integrity": "sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.94.tgz", - "integrity": "sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.94.tgz", - "integrity": "sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.94.tgz", - "integrity": "sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.94.tgz", - "integrity": "sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.94", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.94.tgz", - "integrity": "sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { - "node": ">= 10" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "dev": true, "license": "MIT", "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@redocly/ajv": { - "version": "8.17.3", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.3.tgz", - "integrity": "sha512-NQsbJbB/GV7JVO88ebFkMndrnuGp/dTm5/2NISeg+JGcLzTfGBJZ01+V5zD8nKBOpi/dLLNFT+Ql6IcUk8ehng==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@babel/helper-plugin-utils": "^7.27.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@redocly/config": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", - "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@redocly/openapi-core": { - "version": "1.34.6", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", - "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dev": true, "license": "MIT", "dependencies": { - "@redocly/ajv": "^8.11.2", - "@redocly/config": "^0.22.0", - "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.5", - "js-levenshtein": "^1.1.6", - "js-yaml": "^4.1.0", - "minimatch": "^5.0.1", - "pluralize": "^8.0.0", - "yaml-ast-parser": "0.0.43" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz", + "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.4", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], + "node_modules/@biomejs/biome": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz", + "integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.4", + "@biomejs/cli-darwin-x64": "2.4.4", + "@biomejs/cli-linux-arm64": "2.4.4", + "@biomejs/cli-linux-arm64-musl": "2.4.4", + "@biomejs/cli-linux-x64": "2.4.4", + "@biomejs/cli-linux-x64-musl": "2.4.4", + "@biomejs/cli-win32-arm64": "2.4.4", + "@biomejs/cli-win32-x64": "2.4.4" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz", + "integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" + "darwin" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz", + "integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" + "darwin" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz", + "integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz", + "integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz", + "integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz", + "integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" + "linux" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz", + "integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz", + "integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=14.21.3" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } }, - "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==", + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } }, - "node_modules/@swc/core": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", - "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.11", - "@swc/core-darwin-x64": "1.15.11", - "@swc/core-linux-arm-gnueabihf": "1.15.11", - "@swc/core-linux-arm64-gnu": "1.15.11", - "@swc/core-linux-arm64-musl": "1.15.11", - "@swc/core-linux-x64-gnu": "1.15.11", - "@swc/core-linux-x64-musl": "1.15.11", - "@swc/core-win32-arm64-msvc": "1.15.11", - "@swc/core-win32-ia32-msvc": "1.15.11", - "@swc/core-win32-x64-msvc": "1.15.11" + "node": ">=18" }, "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", - "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", - "cpu": [ - "arm64" + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", - "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "darwin" + "aix" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", - "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", - "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", - "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", - "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", - "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", - "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", - "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", - "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ - "x64" + "loong64" ], "dev": true, - "license": "Apache-2.0 AND MIT", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "Apache-2.0" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@tabler/icons": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", - "integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@tabler/icons-react": { - "version": "3.37.1", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.37.1.tgz", - "integrity": "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@tabler/icons": "" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - }, - "peerDependencies": { - "react": ">= 16" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@tanstack/history": { - "version": "1.161.4", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.4.tgz", - "integrity": "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "node": ">=18" } }, - "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@faker-js/faker": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz", + "integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.17", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", + "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.7", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mantine/core": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", + "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "clsx": "^2.1.1", + "react-number-format": "^5.4.4", + "react-remove-scroll": "^2.7.1", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.41.0" + }, + "peerDependencies": { + "@mantine/hooks": "8.3.15", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/dropzone": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.15.tgz", + "integrity": "sha512-12bx1msHULi4D2/VV2PHTBBSshjax/ogLZEIAewX4tK0vRN3OKtA0qR+lqKhywUW4KYv4Z9Dr6O1LoGKHntrUA==", + "license": "MIT", + "dependencies": { + "react-dropzone": "15.0.0" + }, + "peerDependencies": { + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/form": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.15.tgz", + "integrity": "sha512-A6S70KSPjkKkuXxplqTQbPJZ/pkVfJXU/I5bnsSpGacTJxUlU6KR9Ez+Wwea+NHsupl2MHks98oC0f/UiqWbwQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", + "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/notifications": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.15.tgz", + "integrity": "sha512-CJGSv8oeLWyJIVPninU7Ud6vV6/UJKWZJwRGBNg2K0Ak0U0coFN3gW3H6G1Mh2zllNxb3K4fpMJNz4Iy0sCBFw==", + "license": "MIT", + "dependencies": { + "@mantine/store": "8.3.15", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/store": { + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.15.tgz", + "integrity": "sha512-wdx91a73dM2G02YPIZ9i5UXPWfvjdf3qPAwSGnSsBFQg5uM/5CcPAOOQwlYIkvX1edUA5BFOk/4IjpEXSYUDeQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.2.tgz", + "integrity": "sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.94.tgz", + "integrity": "sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.94", + "@napi-rs/canvas-darwin-arm64": "0.1.94", + "@napi-rs/canvas-darwin-x64": "0.1.94", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.94", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.94", + "@napi-rs/canvas-linux-arm64-musl": "0.1.94", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.94", + "@napi-rs/canvas-linux-x64-gnu": "0.1.94", + "@napi-rs/canvas-linux-x64-musl": "0.1.94", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.94", + "@napi-rs/canvas-win32-x64-msvc": "0.1.94" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.94.tgz", + "integrity": "sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.94.tgz", + "integrity": "sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.94.tgz", + "integrity": "sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.94.tgz", + "integrity": "sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.94.tgz", + "integrity": "sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.94.tgz", + "integrity": "sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.94.tgz", + "integrity": "sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.94.tgz", + "integrity": "sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.94.tgz", + "integrity": "sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.94.tgz", + "integrity": "sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.94", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.94.tgz", + "integrity": "sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.17.3", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.3.tgz", + "integrity": "sha512-NQsbJbB/GV7JVO88ebFkMndrnuGp/dTm5/2NISeg+JGcLzTfGBJZ01+V5zD8nKBOpi/dLLNFT+Ql6IcUk8ehng==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", + "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.22.0", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.5", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", + "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", + "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", + "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", + "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", + "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", + "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", + "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", + "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", + "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", + "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", + "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tabler/icons": { + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", + "integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.37.1.tgz", + "integrity": "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tanstack/history": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.4.tgz", + "integrity": "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.162.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.162.1.tgz", + "integrity": "sha512-HF2uSWqLqENWNH7vn+qnz1QY9ZrVunwLNUO57Lonvq5X20tziN/AK5p3z0A4zExej9I5SgEcG6Z/eaIv7aGhPA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/react-store": "^0.9.1", + "@tanstack/router-core": "1.162.1", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.162.1", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.162.1.tgz", + "integrity": "sha512-zq/ePd7UhWE1NkY4DZJ/a//2O+yiwOxkCqbFF+v++twnQUsKkTUepUln30S9yrPcnZFe76PlHnIzZSl5UeKm9w==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/store": "^0.9.1", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "integrity": "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==", + "deprecated": "This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!", + "license": "MIT", + "dependencies": { + "localforage": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", + "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" }, "peerDependencies": { - "react": "^18 || ^19" + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@tanstack/react-router": { - "version": "1.162.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.162.1.tgz", - "integrity": "sha512-HF2uSWqLqENWNH7vn+qnz1QY9ZrVunwLNUO57Lonvq5X20tziN/AK5p3z0A4zExej9I5SgEcG6Z/eaIv7aGhPA==", + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/history": "1.161.4", - "@tanstack/react-store": "^0.9.1", - "@tanstack/router-core": "1.162.1", - "isbot": "^5.1.22", - "tiny-invariant": "^1.3.3", - "tiny-warning": "^1.0.3" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" }, "peerDependencies": { - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@tanstack/react-store": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", - "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/store": "0.9.1", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@tanstack/router-core": { - "version": "1.162.1", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.162.1.tgz", - "integrity": "sha512-zq/ePd7UhWE1NkY4DZJ/a//2O+yiwOxkCqbFF+v++twnQUsKkTUepUln30S9yrPcnZFe76PlHnIzZSl5UeKm9w==", + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", "license": "MIT", - "dependencies": { - "@tanstack/history": "1.161.4", - "@tanstack/store": "^0.9.1", - "cookie-es": "^2.0.0", - "seroval": "^1.4.2", - "seroval-plugins": "^1.4.2", - "tiny-invariant": "^1.3.3", - "tiny-warning": "^1.0.3" - }, - "engines": { - "node": ">=20.19" - }, "funding": { "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@tanstack/store": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", - "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "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": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT" }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" + "engines": { + "node": ">= 0.4" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "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==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/ms": "*" + "engines": { + "node": ">= 6" } }, - "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==", + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, - "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==", - "license": "MIT" + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "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", - "dependencies": { - "@types/estree": "*" + "engines": { + "node": ">=18" } }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/unist": "*" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "dev": true, "license": "MIT" }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MIT", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" } }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/localforage": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", - "integrity": "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==", - "deprecated": "This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!", "license": "MIT", "dependencies": { - "localforage": "*" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "dependencies": { - "@types/unist": "*" + "engines": { + "node": ">=6" } }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/@types/prismjs": { - "version": "1.26.6", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", - "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } + "license": "MIT" }, - "node_modules/@types/react-router": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", - "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", - "dev": true, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*" + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, - "node_modules/@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "dev": true, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "license": "MIT", - "dependencies": { - "@types/history": "^4.7.11", - "@types/react": "*", - "@types/react-router": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT" }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" + "engines": { + "node": ">=4.0.0" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "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/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", - "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", - "dev": true, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2", - "@swc/core": "^1.15.11" - }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" }, - "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "browserslist": "^4.28.1" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "luxon": "^3.7.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, + "node_modules/cronstrue": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.12.0.tgz", + "integrity": "sha512-k9oiM4G7U1GEEktOGfZabldP0gtFWTsaRVqq9X06ifytr73mpSYYdt+zGZBeS5lRCsqMfq0y7oSHycWGIJSo6g==", "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "bin": { + "cronstrue": "bin/cli.js" } }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 8" } }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, - "node_modules/@vitest/ui": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", - "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "bin": { + "cssesc": "bin/cssesc" }, - "peerDependencies": { - "vitest": "4.0.18" + "engines": { + "node": ">=4" } }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xmldom/xmldom": { - "version": "0.7.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", - "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", - "deprecated": "this version is no longer supported, please update to at least 0.8.*", - "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=18" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, "engines": { - "node": ">= 14" + "node": ">=0.12" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "dequal": "^2.0.3" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/attr-accept": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "engines": { - "node": ">=4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": ">=0.10.0" } }, - "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==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=0.4.0" } }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/epubjs": { + "version": "0.3.93", + "resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.93.tgz", + "integrity": "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/localforage": "0.0.34", + "@xmldom/xmldom": "^0.7.5", + "core-js": "^3.18.3", + "event-emitter": "^0.3.5", + "jszip": "^3.7.1", + "localforage": "^1.10.0", + "lodash": "^4.17.21", + "marks-pane": "^1.0.9", + "path-webpack": "0.0.3" } }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">= 0.4" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, "engines": { - "node": ">= 12" + "node": ">= 0.4" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=12" + "node": ">= 0.4" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, "engines": { - "node": ">=6" + "node": ">=0.10" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=0.12" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=6" } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cookie-es": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", - "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", - "license": "MIT" + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", "license": "MIT", "funding": { "type": "opencollective", - "url": "https://opencollective.com/core-js" + "url": "https://opencollective.com/unified" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cron-parser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", - "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "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": { - "luxon": "^3.7.1" - }, + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/cronstrue": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.12.0.tgz", - "integrity": "sha512-k9oiM4G7U1GEEktOGfZabldP0gtFWTsaRVqq9X06ifytr73mpSYYdt+zGZBeS5lRCsqMfq0y7oSHycWGIJSo6g==", + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, "license": "MIT", - "bin": { - "cronstrue": "bin/cli.js" + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "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": "MIT", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, + "type": "^2.7.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18" } }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, "license": "MIT" }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "license": "ISC", + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" + "tslib": "^2.7.0" }, "engines": { - "node": ">=0.12" + "node": ">= 12" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" + "minimatch": "^5.0.1" } }, - "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "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": ">=18" + "node": ">=8" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" + "node": ">=4.0" }, "peerDependenciesMeta": { - "supports-color": { + "debug": { "optional": true } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, - "license": "MIT" - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", "dependencies": { - "character-entities": "^2.0.0" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, "engines": { - "node": ">=0.4.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, "engines": { - "node": ">=6" + "node": ">= 6" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { - "dequal": "^2.0.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=10" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/epubjs": { - "version": "0.3.93", - "resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.93.tgz", - "integrity": "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/localforage": "0.0.34", - "@xmldom/xmldom": "^0.7.5", - "core-js": "^3.18.3", - "event-emitter": "^0.3.5", - "jszip": "^3.7.1", - "localforage": "^1.10.0", - "lodash": "^4.17.21", - "marks-pane": "^1.0.9", - "path-webpack": "0.0.3" + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6.9.0" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "license": "ISC", "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": ">=0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "license": "MIT", + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.12" + "node": "18 || 20 || >=22" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "node": "18 || 20 || >=22" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "license": "ISC", + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { - "node": ">=0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "license": "MIT", "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "node_modules/happy-dom": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.8.1.tgz", + "integrity": "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.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==", + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "license": "ISC", - "dependencies": { - "type": "^2.7.2" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, - "license": "MIT" - }, - "node_modules/file-selector": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", - "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", "license": "MIT", "dependencies": { - "tslib": "^2.7.0" + "es-define-property": "^1.0.0" }, - "engines": { - "node": ">= 12" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "dunder-proto": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.4" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@types/hast": "^3.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "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==", - "dev": true, - "license": "ISC" + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + "node": ">= 14" } }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "node": ">= 14" } }, - "node_modules/happy-dom": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.8.1.tgz", - "integrity": "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/has-flag": { + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "call-bound": "^1.0.3" }, "engines": { - "node": ">= 14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/index-to-position": { + "node_modules/is-node-process": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "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": ">=18" + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, "license": "MIT" }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "license": "MIT", "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-decimal": { + "node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=8" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "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==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/isarray": { "version": "1.0.0", @@ -4329,6 +7356,47 @@ "node": ">=18" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -4595,6 +7663,19 @@ "node": ">=18" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-edit-react": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/json-edit-react/-/json-edit-react-1.29.0.tgz", @@ -4615,6 +7696,42 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4636,6 +7753,16 @@ "node": ">= 8" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -4669,6 +7796,20 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -5687,6 +8828,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -5838,6 +8989,13 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC" }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", @@ -5854,6 +9012,29 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object-property-assigner": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/object-property-assigner/-/object-property-assigner-1.3.5.tgz", @@ -5866,6 +9047,27 @@ "integrity": "sha512-9kgEjTWDhTPuPn7nyof+5mLmCKBPKdU0c7IVpTbOvYKYSdXQ5skH4Pa/8MPbZXeyXBGrqS82JyWecsh6tMxiLw==", "license": "MIT" }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5918,6 +9120,31 @@ "dev": true, "license": "MIT" }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5979,6 +9206,50 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -6038,7 +9309,17 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, "node_modules/postcss": { @@ -6196,6 +9477,19 @@ "postcss": "^8.2.1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -6591,6 +9885,108 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -6692,6 +10088,28 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rettime": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", @@ -6751,12 +10169,81 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6764,58 +10251,226 @@ "dev": true, "license": "MIT" }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/seroval": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", + "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=v12.22.7" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/seroval": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", - "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/seroval-plugins": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", - "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, - "peerDependencies": { - "seroval": "^1.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6861,6 +10516,16 @@ "node": ">=8" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6880,6 +10545,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -6937,6 +10613,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -6968,6 +10658,93 @@ "node": ">=8" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6982,6 +10759,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6995,6 +10787,16 @@ "node": ">=8" } }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -7062,6 +10864,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -7088,6 +10903,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7309,6 +11185,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7336,6 +11290,25 @@ "node": ">=0.8.0" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7343,6 +11316,50 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -7362,6 +11379,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -7430,6 +11460,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -7440,6 +11480,48 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -7660,6 +11742,37 @@ } } }, + "node_modules/vite-plugin-pwa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, "node_modules/vite-tsconfig-paths": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", @@ -7877,6 +11990,118 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "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", @@ -7900,6 +12125,269 @@ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "license": "MIT" }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7964,6 +12452,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", diff --git a/web/package.json b/web/package.json index fc7a5cfd..c6bd72be 100644 --- a/web/package.json +++ b/web/package.json @@ -63,6 +63,7 @@ "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react-swc": "^4.2.3", "@vitest/ui": "^4.0.18", + "fake-indexeddb": "^6.2.5", "globals": "^16.5.0", "happy-dom": "^16.7.0", "jsdom": "^25.0.1", @@ -73,6 +74,7 @@ "postcss-simple-vars": "^7.0.1", "typescript": "~5.9.3", "vite": "^7.3.1", + "vite-plugin-pwa": "^1.3.0", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18" }, diff --git a/web/public/icons/apple-touch-icon-180.png b/web/public/icons/apple-touch-icon-180.png new file mode 100644 index 00000000..f1ecbdc4 Binary files /dev/null and b/web/public/icons/apple-touch-icon-180.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 00000000..f32464d2 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 00000000..a6f29d13 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/maskable-192.png b/web/public/icons/maskable-192.png new file mode 100644 index 00000000..c4b76b26 Binary files /dev/null and b/web/public/icons/maskable-192.png differ diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 00000000..c9f0494a Binary files /dev/null and b/web/public/icons/maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 00000000..6c3acd4f --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,39 @@ +{ + "name": "Codex", + "short_name": "Codex", + "description": "Codex digital library for comics, manga, and ebooks.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#1e3a8a", + "background_color": "#242424", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "categories": ["books", "entertainment"] +} diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index ac1add3f..00000000 --- a/web/src/App.css +++ /dev/null @@ -1,52 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} - -/* Ensure cards scale properly with browser zoom */ -[data-mantine-simple-grid] { - width: 100%; -} - -[data-mantine-simple-grid] > * { - min-width: 0; - width: 100%; -} diff --git a/web/src/App.tsx b/web/src/App.tsx index 399d55a1..18386056 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,6 +29,7 @@ import { Setup } from "@/pages/Setup"; import { BooksInErrorSettings, CleanupSettings, + DownloadsSettings, DuplicatesSettings, IntegrationsSettings, MetricsSettings, @@ -444,6 +445,17 @@ function App() { } /> + + + + + + } + /> + } /> diff --git a/web/src/api/readProgress.test.ts b/web/src/api/readProgress.test.ts index f1e2e48c..7d088cc7 100644 --- a/web/src/api/readProgress.test.ts +++ b/web/src/api/readProgress.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { _resetForTests, getOutbox, setDbContext } from "@/lib/offline/db"; +import { isOfflineQueuedError, OfflineQueuedError } from "@/lib/offline/outbox"; import { api } from "./client"; import { readProgressApi } from "./readProgress"; @@ -13,6 +16,12 @@ vi.mock("./client", () => ({ describe("readProgressApi", () => { beforeEach(() => { vi.clearAllMocks(); + setDbContext({ indexedDB: new IDBFactory() }); + }); + + afterEach(() => { + setDbContext(null); + _resetForTests(); }); describe("get", () => { @@ -119,4 +128,63 @@ describe("readProgressApi", () => { expect(api.delete).toHaveBeenCalledWith("/books/book-123/progress"); }); }); + + describe("offline outbox integration", () => { + it("update throws OfflineQueuedError and enqueues on network failure", async () => { + vi.mocked(api.put).mockRejectedValueOnce({ + error: "Network Error", + message: "offline", + }); + + const request = { currentPage: 42, completed: false }; + await expect( + readProgressApi.update("book-123", request), + ).rejects.toSatisfy(isOfflineQueuedError); + + const queued = await getOutbox(); + expect(queued).toHaveLength(1); + expect(queued[0]?.request.url).toBe("/api/v1/books/book-123/progress"); + expect(queued[0]?.request.method).toBe("PUT"); + expect(queued[0]?.request.body).toBe(JSON.stringify(request)); + }); + + it("update rethrows non-network errors without queueing", async () => { + vi.mocked(api.put).mockRejectedValueOnce({ + error: "Internal Server Error", + message: "server died", + }); + + await expect( + readProgressApi.update("book-123", { currentPage: 1 }), + ).rejects.not.toSatisfy(isOfflineQueuedError); + + expect(await getOutbox()).toEqual([]); + }); + + it("updateProgression enqueues on network failure", async () => { + vi.mocked(api.put).mockRejectedValueOnce({ error: "Network Error" }); + await expect( + readProgressApi.updateProgression("book-123", { + device: { id: "d", name: "n" }, + locator: { + href: "ch1", + locations: { totalProgression: 0.5 }, + type: "application/xhtml+xml", + }, + modified: "2024-01-01T00:00:00Z", + }), + ).rejects.toBeInstanceOf(OfflineQueuedError); + const queued = await getOutbox(); + expect(queued[0]?.request.url).toBe("/api/v1/books/book-123/progression"); + }); + + it("delete enqueues on network failure", async () => { + vi.mocked(api.delete).mockRejectedValueOnce({ error: "Network Error" }); + await expect(readProgressApi.delete("book-123")).rejects.toBeInstanceOf( + OfflineQueuedError, + ); + const queued = await getOutbox(); + expect(queued[0]?.request.method).toBe("DELETE"); + }); + }); }); diff --git a/web/src/api/readProgress.ts b/web/src/api/readProgress.ts index 38631a6f..519f706e 100644 --- a/web/src/api/readProgress.ts +++ b/web/src/api/readProgress.ts @@ -1,3 +1,8 @@ +import { + enqueueOfflineWrite, + isOfflineError, + OfflineQueuedError, +} from "@/lib/offline/outbox"; import type { components } from "@/types"; import { api } from "./client"; @@ -6,6 +11,26 @@ export type ReadProgressResponse = export type UpdateProgressRequest = components["schemas"]["UpdateProgressRequest"]; +const API_BASE = "/api/v1"; + +/** + * Build the auth + content-type headers the outbox needs to replay this + * request later. Captures the JWT at enqueue time; if the user logs out + * before the drain fires the replay will get a 401 (the drain marks the + * record as failed-retry; the user re-authenticates and tries again). + */ +function captureWriteHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + const token = + typeof localStorage !== "undefined" + ? localStorage.getItem("jwt_token") + : null; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + /** Readium R2Progression format for EPUB position sync */ export interface R2Progression { device: { id: string; name: string }; @@ -36,24 +61,52 @@ export const readProgressApi = { }, /** - * Update reading progress for a book + * Update reading progress for a book. + * + * On network failure (offline / server unreachable) the request is + * serialised into the offline outbox and an {@link OfflineQueuedError} + * is thrown. Callers should treat that error as "saved locally, will + * sync when online" rather than a real failure. */ update: async ( bookId: string, request: UpdateProgressRequest, ): Promise => { - const response = await api.put( - `/books/${bookId}/progress`, - request, - ); - return response.data; + try { + const response = await api.put( + `/books/${bookId}/progress`, + request, + ); + return response.data; + } catch (err) { + if (!isOfflineError(err)) throw err; + const descriptor = { + url: `${API_BASE}/books/${bookId}/progress`, + method: "PUT", + headers: captureWriteHeaders(), + body: request, + }; + await enqueueOfflineWrite(descriptor); + throw new OfflineQueuedError(descriptor); + } }, /** - * Delete reading progress for a book + * Delete reading progress for a book. Same offline semantics as `update`. */ delete: async (bookId: string): Promise => { - await api.delete(`/books/${bookId}/progress`); + try { + await api.delete(`/books/${bookId}/progress`); + } catch (err) { + if (!isOfflineError(err)) throw err; + const descriptor = { + url: `${API_BASE}/books/${bookId}/progress`, + method: "DELETE", + headers: captureWriteHeaders(), + }; + await enqueueOfflineWrite(descriptor); + throw new OfflineQueuedError(descriptor); + } }, /** @@ -71,12 +124,25 @@ export const readProgressApi = { }, /** - * Update R2Progression for a book (Readium standard) + * Update R2Progression for a book (Readium standard). Same offline + * semantics as `update`. */ updateProgression: async ( bookId: string, progression: R2Progression, ): Promise => { - await api.put(`/books/${bookId}/progression`, progression); + try { + await api.put(`/books/${bookId}/progression`, progression); + } catch (err) { + if (!isOfflineError(err)) throw err; + const descriptor = { + url: `${API_BASE}/books/${bookId}/progression`, + method: "PUT", + headers: captureWriteHeaders(), + body: progression, + }; + await enqueueOfflineWrite(descriptor); + throw new OfflineQueuedError(descriptor); + } }, }; diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index 7f9d4a66..f97c18cd 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { useRef } from "react"; import type { SearchInputHandle } from "@/components/search"; import { useSearchShortcut } from "@/hooks/useSearchShortcut"; import { Header } from "./Header"; +import { OfflineBanner } from "./OfflineBanner"; import { PluginStatusBanner } from "./PluginStatusBanner"; import { Sidebar } from "./Sidebar"; @@ -12,7 +13,8 @@ interface AppLayoutProps { } export function AppLayout({ children }: AppLayoutProps) { - const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const searchInputRef = useRef(null); @@ -35,10 +37,11 @@ export function AppLayout({ children }: AppLayoutProps) { toggleDesktop={toggleDesktop} searchInputRef={searchInputRef} /> - + + {children} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 697737cd..bf7e05bd 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -6,9 +6,14 @@ import { Text, useComputedColorScheme, } from "@mantine/core"; -import { IconMenu2, IconMoon, IconSun } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; +import { IconMenu2, IconMoon, IconSearch, IconSun } from "@tabler/icons-react"; import type { RefObject } from "react"; -import { SearchInput, type SearchInputHandle } from "@/components/search"; +import { + MobileSearchSheet, + SearchInput, + type SearchInputHandle, +} from "@/components/search"; import { useAppName } from "@/hooks/useAppName"; import { useUserPreferencesStore } from "@/store/userPreferencesStore"; @@ -28,6 +33,10 @@ export function Header({ const appName = useAppName(); const computedColorScheme = useComputedColorScheme("dark"); const setPreference = useUserPreferencesStore((state) => state.setPreference); + const [ + searchSheetOpened, + { open: openSearchSheet, close: closeSearchSheet }, + ] = useDisclosure(false); const toggleColorScheme = () => { // Toggle between light and dark (not system) for explicit user action @@ -45,7 +54,8 @@ export function Header({ opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" - size="sm" + size="md" + aria-label={mobileOpened ? "Close navigation" : "Open navigation"} /> + + + + {computedColorScheme === "dark" ? ( @@ -77,6 +99,11 @@ export function Header({ + + ); } diff --git a/web/src/components/layout/OfflineBanner.test.tsx b/web/src/components/layout/OfflineBanner.test.tsx new file mode 100644 index 00000000..a4707068 --- /dev/null +++ b/web/src/components/layout/OfflineBanner.test.tsx @@ -0,0 +1,66 @@ +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderWithProviders, screen } from "@/test/utils"; +import { OfflineBanner } from "./OfflineBanner"; + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { + configurable: true, + get: () => value, + }); +} + +describe("OfflineBanner (U6)", () => { + afterEach(() => { + // Restore the default — JSDOM treats the property as writable but tests + // share globals, so reset after each test. + setOnline(true); + }); + + it("renders nothing while online", () => { + setOnline(true); + renderWithProviders(); + + expect(screen.queryByText(/offline/i)).not.toBeInTheDocument(); + }); + + it("shows the banner on initial mount when navigator.onLine is false", () => { + setOnline(false); + renderWithProviders(); + + expect( + screen.getByText(/you're offline\. showing cached content/i), + ).toBeInTheDocument(); + }); + + it("appears when the window dispatches an offline event", () => { + setOnline(true); + renderWithProviders(); + + expect(screen.queryByText(/offline/i)).not.toBeInTheDocument(); + + act(() => { + setOnline(false); + window.dispatchEvent(new Event("offline")); + }); + + expect( + screen.getByText(/you're offline\. showing cached content/i), + ).toBeInTheDocument(); + }); + + it("disappears when the window dispatches an online event", () => { + setOnline(false); + renderWithProviders(); + expect( + screen.getByText(/you're offline\. showing cached content/i), + ).toBeInTheDocument(); + + act(() => { + setOnline(true); + window.dispatchEvent(new Event("online")); + }); + + expect(screen.queryByText(/offline/i)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/layout/OfflineBanner.tsx b/web/src/components/layout/OfflineBanner.tsx new file mode 100644 index 00000000..f79e041d --- /dev/null +++ b/web/src/components/layout/OfflineBanner.tsx @@ -0,0 +1,58 @@ +import { Alert } from "@mantine/core"; +import { IconWifiOff } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; + +/** + * Read the current online state from the browser, defaulting to `true` when + * `navigator.onLine` is unavailable (SSR / older browsers). + */ +function readOnlineState(): boolean { + if (typeof navigator === "undefined" || navigator.onLine === undefined) { + return true; + } + return navigator.onLine; +} + +/** + * Thin top banner shown when the browser reports the user is offline. The + * service worker's NetworkFirst strategy for `/api/` will fall through to + * cached responses or fail; without this cue the user sees "No content + * available" with no indication they're disconnected. (U6) + * + * Mounted in `AppLayout` below `PluginStatusBanner`. Reader pages keep their + * intentional chrome-free presentation; the banner does not appear in + * fullscreen reader mode (it's only inside the AppShell main area). + */ +export function OfflineBanner() { + const [isOnline, setIsOnline] = useState(readOnlineState); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + if (isOnline) { + return null; + } + + return ( + } + color="yellow" + variant="light" + radius={0} + style={{ borderBottom: "1px solid var(--mantine-color-yellow-3)" }} + > + You're offline. Showing cached content. + + ); +} diff --git a/web/src/components/layout/PluginStatusBanner.test.tsx b/web/src/components/layout/PluginStatusBanner.test.tsx new file mode 100644 index 00000000..acbf3442 --- /dev/null +++ b/web/src/components/layout/PluginStatusBanner.test.tsx @@ -0,0 +1,157 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type PluginDto, pluginsApi } from "@/api/plugins"; +import { useAuthStore } from "@/store/authStore"; +import { userEvent } from "@/test/utils"; +import { theme } from "@/theme"; +import type { User } from "@/types"; +import { PluginStatusBanner } from "./PluginStatusBanner"; + +vi.mock("@/api/plugins", async () => { + const actual = + await vi.importActual("@/api/plugins"); + return { + ...actual, + pluginsApi: { + ...actual.pluginsApi, + getAll: vi.fn(), + }, + }; +}); + +function basePlugin(overrides: Partial): PluginDto { + return { + args: [], + command: "node", + config: {}, + createdAt: "2026-01-01T00:00:00Z", + credentialDelivery: "env", + displayName: "Test Plugin", + enabled: false, + env: {}, + failureCount: 1, + hasCredentials: false, + healthStatus: "unhealthy", + id: "plugin-1", + name: "test-plugin", + updatedAt: "2026-01-01T00:00:00Z", + version: "1.0.0", + capabilities: {}, + disabledReason: "max-failures", + // PluginDto has additional fields we don't care about for this test; use + // an unknown spread to satisfy the structural type. + ...overrides, + } as PluginDto; +} + +function renderBanner() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + return render( + + + + + + + , + ); +} + +describe("PluginStatusBanner (U5)", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + const adminUser: User = { + id: "u1", + username: "admin", + email: "admin@test.com", + role: "admin", + emailVerified: true, + permissions: [], + }; + useAuthStore.setState({ + user: adminUser, + token: "tok", + isAuthenticated: true, + }); + }); + + it("shows the banner for failed plugins", async () => { + vi.mocked(pluginsApi.getAll).mockResolvedValue({ + plugins: [ + basePlugin({ id: "p1", displayName: "Buggy", failureCount: 3 }), + ], + }); + + renderBanner(); + + expect( + await screen.findByText(/Plugin "Buggy" is disabled/i), + ).toBeInTheDocument(); + }); + + it("persists dismissal across remounts (localStorage, not sessionStorage)", async () => { + const user = userEvent.setup(); + vi.mocked(pluginsApi.getAll).mockResolvedValue({ + plugins: [ + basePlugin({ id: "p1", displayName: "Buggy", failureCount: 3 }), + ], + }); + + const { unmount } = renderBanner(); + + const dismissBtn = await screen.findByLabelText("Dismiss all"); + await user.click(dismissBtn); + + await waitFor(() => { + expect( + screen.queryByText(/Plugin "Buggy" is disabled/i), + ).not.toBeInTheDocument(); + }); + + unmount(); + + // Re-render with the same failureCount; dismissal should persist. + renderBanner(); + await waitFor(() => + expect(vi.mocked(pluginsApi.getAll)).toHaveBeenCalledTimes(2), + ); + expect( + screen.queryByText(/Plugin "Buggy" is disabled/i), + ).not.toBeInTheDocument(); + }); + + it("reappears when failureCount increases beyond the dismissed value", async () => { + const user = userEvent.setup(); + vi.mocked(pluginsApi.getAll).mockResolvedValueOnce({ + plugins: [ + basePlugin({ id: "p1", displayName: "Buggy", failureCount: 3 }), + ], + }); + + const { unmount } = renderBanner(); + const dismissBtn = await screen.findByLabelText("Dismiss all"); + await user.click(dismissBtn); + unmount(); + + // A new failure has incremented failureCount; the banner should return. + vi.mocked(pluginsApi.getAll).mockResolvedValueOnce({ + plugins: [ + basePlugin({ id: "p1", displayName: "Buggy", failureCount: 4 }), + ], + }); + + renderBanner(); + expect( + await screen.findByText(/Plugin "Buggy" is disabled/i), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/layout/PluginStatusBanner.tsx b/web/src/components/layout/PluginStatusBanner.tsx index daf7ce81..dfec5e4e 100644 --- a/web/src/components/layout/PluginStatusBanner.tsx +++ b/web/src/components/layout/PluginStatusBanner.tsx @@ -1,36 +1,52 @@ -import { Alert, Anchor, Group, Text } from "@mantine/core"; +import { Alert, Anchor, Group, Stack, Text } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconPlugConnectedX } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; import { pluginsApi } from "@/api/plugins"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui/ResponsiveTable"; import { useAuthStore } from "@/store/authStore"; -// Session storage key for dismissed plugins +// Local storage key for dismissed plugins. We map plugin ID -> failureCount +// at the moment of dismissal. The banner re-appears whenever the plugin's +// current failureCount exceeds the stored value, which corresponds to a new +// failure since the user last dismissed it. Persisting across reloads +// (rather than sessionStorage) is intentional: on a phone the banner eats +// ~75px of above-the-fold space, so reload-survival matters. (U5) const DISMISSED_KEY = "codex:dismissed-plugin-alerts"; +type DismissedMap = Record; + /** - * Get the set of dismissed plugin IDs from session storage. + * Get the dismissed map (plugin id -> failureCount at dismissal time) from + * localStorage. Returns an empty map on parse / storage errors. */ -function getDismissedPluginIds(): Set { +function getDismissedMap(): DismissedMap { try { - const stored = sessionStorage.getItem(DISMISSED_KEY); + const stored = localStorage.getItem(DISMISSED_KEY); if (stored) { - return new Set(JSON.parse(stored)); + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as DismissedMap; + } } } catch { // Ignore parsing errors } - return new Set(); + return {}; } /** - * Add a plugin ID to the dismissed set in session storage. + * Persist the dismissal map back to localStorage. No-op on quota / private- + * mode failures. */ -function dismissPlugin(pluginId: string): void { - const dismissed = getDismissedPluginIds(); - dismissed.add(pluginId); - sessionStorage.setItem(DISMISSED_KEY, JSON.stringify([...dismissed])); +function saveDismissedMap(map: DismissedMap): void { + try { + localStorage.setItem(DISMISSED_KEY, JSON.stringify(map)); + } catch { + // Ignore storage errors + } } /** @@ -40,9 +56,9 @@ function dismissPlugin(pluginId: string): void { export function PluginStatusBanner() { const { user } = useAuthStore(); const isAdmin = user?.role === "admin"; - const [dismissedIds, setDismissedIds] = useState>( - getDismissedPluginIds, - ); + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + const [dismissedMap, setDismissedMap] = + useState(getDismissedMap); const { data: pluginsResponse } = useQuery({ queryKey: ["plugins"], @@ -61,12 +77,14 @@ export function PluginStatusBanner() { p.disabledReason || (p.healthStatus === "unhealthy" && p.failureCount > 0), ); - for (const plugin of failedPlugins) { - dismissPlugin(plugin.id); - } - setDismissedIds( - (prev) => new Set([...prev, ...failedPlugins.map((p) => p.id)]), - ); + setDismissedMap((prev) => { + const next = { ...prev }; + for (const plugin of failedPlugins) { + next[plugin.id] = plugin.failureCount; + } + saveDismissedMap(next); + return next; + }); }, [pluginsResponse]); // Don't show for non-admins or if no data @@ -84,8 +102,14 @@ export function PluginStatusBanner() { (p.healthStatus === "unhealthy" && p.failureCount > 0 && p.enabled), ); - // Filter out dismissed plugins - const visiblePlugins = failedPlugins.filter((p) => !dismissedIds.has(p.id)); + // Filter out plugins the user has dismissed at the current failureCount. + // If a *new* failure has happened since dismissal (current failureCount > + // stored), the banner returns; that's the desired behavior per U5. + const visiblePlugins = failedPlugins.filter((p) => { + const dismissedAt = dismissedMap[p.id]; + if (dismissedAt === undefined) return true; + return p.failureCount > dismissedAt; + }); if (visiblePlugins.length === 0) { return null; @@ -108,16 +132,29 @@ export function PluginStatusBanner() { onClose={handleDismissAll} closeButtonLabel="Dismiss all" > - - - {visiblePlugins.length === 1 - ? `Plugin "${pluginNames}" is disabled due to failures.` - : `${visiblePlugins.length} plugins are having issues: ${pluginNames}${moreCount > 0 ? ` and ${moreCount} more` : ""}.`} - - - View Plugins - - + {isMobile ? ( + + + {visiblePlugins.length === 1 + ? `Plugin "${pluginNames}" is disabled due to failures.` + : `${visiblePlugins.length} plugins are having issues: ${pluginNames}${moreCount > 0 ? ` and ${moreCount} more` : ""}.`} + + + View Plugins + + + ) : ( + + + {visiblePlugins.length === 1 + ? `Plugin "${pluginNames}" is disabled due to failures.` + : `${visiblePlugins.length} plugins are having issues: ${pluginNames}${moreCount > 0 ? ` and ${moreCount} more` : ""}.`} + + + View Plugins + + + )} ); } diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 8e58b8ab..96e00cf9 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -1,10 +1,15 @@ -import { screen, waitFor } from "@testing-library/react"; +import { AppShell, MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { librariesApi } from "@/api/libraries"; import { useAuthStore } from "@/store/authStore"; import { renderWithProviders, userEvent } from "@/test/utils"; +import { theme } from "@/theme"; import type { Library, User } from "@/types"; import { AppLayout } from "./AppLayout"; +import { Sidebar } from "./Sidebar"; vi.mock("@/api/libraries"); vi.mock("@/api/tasks", () => ({ @@ -585,4 +590,223 @@ describe("Sidebar Component (via AppLayout)", () => { }); }); }); + + describe("Mobile drawer auto-close (onNavigate)", () => { + function renderSidebar(onNavigate: () => void) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + + + + + + + , + ); + } + + it("calls onNavigate when the Home link is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + await user.click(screen.getByText("Home")); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("calls onNavigate when a settings submenu link is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + // Profile is shown to all users inside Settings + await user.click(screen.getByText("Profile")); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("does NOT call onNavigate when only expanding the Settings submenu", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + // Clicking the "Settings" parent toggle expands the submenu; it is not a + // navigation event and must not collapse the drawer. + await user.click(screen.getByText("Settings")); + expect(onNavigate).not.toHaveBeenCalled(); + }); + }); + + describe("Mobile scroll cue (U4)", () => { + it("does not render the scroll cue on desktop viewports", () => { + // Default matchMedia mock returns matches:false for everything. + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderWithProviders( + +
Content
+
, + ); + + expect( + screen.queryByTestId("sidebar-scroll-cue"), + ).not.toBeInTheDocument(); + }); + + it("renders the scroll cue when the mobile navbar overflows", async () => { + // Force-mobile matchMedia + stub navbar metrics to simulate overflow. + const originalMatchMedia = window.matchMedia; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + // ResizeObserver is invoked synchronously when we observe(), so we can + // assert via the initial update() call. Stub it as a noop instance so + // it doesn't run our update on resize (which we don't simulate). + class StubResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + const originalRO = window.ResizeObserver; + // @ts-expect-error - test stub + window.ResizeObserver = StubResizeObserver; + + // Stub scrollHeight/clientHeight on the navbar element so update() + // detects overflow on mount. + const originalScrollHeight = Object.getOwnPropertyDescriptor( + Element.prototype, + "scrollHeight", + ); + const originalClientHeight = Object.getOwnPropertyDescriptor( + Element.prototype, + "clientHeight", + ); + Object.defineProperty(Element.prototype, "scrollHeight", { + configurable: true, + get() { + return this.classList?.contains("mantine-AppShell-navbar") ? 2000 : 0; + }, + }); + Object.defineProperty(Element.prototype, "clientHeight", { + configurable: true, + get() { + return this.classList?.contains("mantine-AppShell-navbar") ? 600 : 0; + }, + }); + + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + try { + renderWithProviders( + +
Content
+
, + ); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-scroll-cue")).toBeInTheDocument(); + }); + } finally { + window.matchMedia = originalMatchMedia; + window.ResizeObserver = originalRO; + if (originalScrollHeight) { + Object.defineProperty( + Element.prototype, + "scrollHeight", + originalScrollHeight, + ); + } + if (originalClientHeight) { + Object.defineProperty( + Element.prototype, + "clientHeight", + originalClientHeight, + ); + } + } + }); + }); }); diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index ea92801e..ab7ab7ee 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { ActionIcon, AppShell, + Box, Button, Divider, Group, @@ -10,6 +11,7 @@ import { Stack, Text, } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertTriangle, @@ -17,6 +19,7 @@ import { IconBrush, IconChartBar, IconClipboardList, + IconCloudDownload, IconCopy, IconDatabase, IconDotsVertical, @@ -40,7 +43,7 @@ import { IconUsers, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { librariesApi } from "@/api/libraries"; import { userPluginsApi } from "@/api/userPlugins"; @@ -48,6 +51,7 @@ import { LibraryModal } from "@/components/forms/LibraryModal"; import { ReleasesNavBadge } from "@/components/layout/ReleasesNavBadge"; import { LibraryActionsMenu } from "@/components/library/LibraryActionsMenu"; import { TaskNotificationBadge } from "@/components/TaskNotificationBadge"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { useAppInfo } from "@/hooks/useAppInfo"; import { useAppName } from "@/hooks/useAppName"; import { usePermissions } from "@/hooks/usePermissions"; @@ -57,7 +61,12 @@ import { useLibraryPreferencesStore } from "@/store/libraryPreferencesStore"; import type { Library } from "@/types"; import { PERMISSIONS } from "@/types/permissions"; -export function Sidebar() { +interface SidebarProps { + /** Called when the user taps a navigation link, so the mobile drawer can auto-close. */ + onNavigate?: () => void; +} + +export function Sidebar({ onNavigate }: SidebarProps = {}) { const appName = useAppName(); const { data: appInfo } = useAppInfo(); const navigate = useNavigate(); @@ -88,6 +97,47 @@ export function Sidebar() { } }, [currentPath]); + // U4: Show a bottom fade cue on the mobile drawer when the nav overflows + // (e.g. Settings is expanded and Users/Sharing Tags sit below the fold). + // Driven by listening to scroll on the AppShell.Navbar element + a + // ResizeObserver to catch content height changes when Settings toggles. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + const navSectionRef = useRef(null); + const [showScrollCue, setShowScrollCue] = useState(false); + + useEffect(() => { + if (!isMobile) { + setShowScrollCue(false); + return; + } + // The scrollable element is the parent `.mantine-AppShell-navbar` (not + // our grow section). Look it up from the section ref so we don't depend + // on a global selector. + const section = navSectionRef.current; + const navbar = section?.closest(".mantine-AppShell-navbar"); + if (!navbar) return; + + const update = () => { + const overflowing = navbar.scrollHeight - navbar.clientHeight > 4; + const atBottom = + navbar.scrollTop + navbar.clientHeight >= navbar.scrollHeight - 4; + setShowScrollCue(overflowing && !atBottom); + }; + + update(); + navbar.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(navbar); + // Observing the section catches Settings expand/collapse, which changes + // section height without changing the navbar's clientHeight. + if (section) ro.observe(section); + + return () => { + navbar.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, [isMobile]); + const { data: libraries } = useQuery({ queryKey: ["libraries"], queryFn: librariesApi.getAll, @@ -322,12 +372,13 @@ export function Sidebar() { const handleLogout = () => { clearAuth(); navigate("/login"); + onNavigate?.(); }; return ( <> - + } active={currentPath === "/"} + onClick={onNavigate} /> {hasRecommendationPlugin && ( } active={currentPath === "/recommendations"} + onClick={onNavigate} /> )} {hasReleasePlugin && ( @@ -353,6 +406,7 @@ export function Sidebar() { leftSection={} active={currentPath.startsWith("/releases")} rightSection={} + onClick={onNavigate} /> )} } active={currentPath.startsWith("/libraries/all")} + onClick={onNavigate} rightSection={ canEditLibrary && ( @@ -498,6 +553,7 @@ export function Sidebar() { to={`/libraries/${library.id}/${getLastTab(library.id) || "recommended"}`} label={library.name} active={currentPath.startsWith(`/libraries/${library.id}/`)} + onClick={onNavigate} styles={{ root: { paddingLeft: 48 }, label: { textTransform: "capitalize" }, @@ -560,6 +616,7 @@ export function Sidebar() { label="Server" leftSection={} active={currentPath.startsWith("/settings/server")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/tasks")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/metrics")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/plugins")} + onClick={onNavigate} /> {/* Access Section */} @@ -605,6 +666,7 @@ export function Sidebar() { label="Users" leftSection={} active={currentPath.startsWith("/settings/users")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/sharing-tags")} + onClick={onNavigate} /> {/* Library Health Section */} @@ -627,6 +690,7 @@ export function Sidebar() { label="Duplicates" leftSection={} active={currentPath.startsWith("/settings/duplicates")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/book-errors")} + onClick={onNavigate} /> {/* Storage Section */} @@ -649,6 +714,7 @@ export function Sidebar() { label="Thumbnails" leftSection={} active={currentPath.startsWith("/settings/cleanup")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/pdf-cache")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/plugin-storage")} + onClick={onNavigate} /> {/* Data Export Section */} @@ -678,6 +746,7 @@ export function Sidebar() { label="Data Exports" leftSection={} active={currentPath.startsWith("/settings/exports")} + onClick={onNavigate} /> {/* Account Section */} @@ -690,12 +759,21 @@ export function Sidebar() { )} + } + active={currentPath.startsWith("/settings/downloads")} + onClick={onNavigate} + /> } active={currentPath.startsWith("/settings/integrations")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/profile")} + onClick={onNavigate} /> + {/* U4: bottom fade cue indicating the nav scrolls (mobile only, when + overflowing and not at the bottom). Sits between the grow section + and the pinned footer so it visually trails the scrollable area. */} + {showScrollCue && ( + - + ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function resetViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + // Series sort options with new interface const seriesSortOptions: SortOption[] = [ { field: "name", label: "Name", defaultDirection: "asc" }, @@ -384,3 +423,90 @@ describe("LibraryToolbar - Series Sort Options", () => { expect(onSortChange).toHaveBeenCalledWith("book_count,asc"); }); }); + +describe("LibraryToolbar - mobile layout", () => { + const defaultProps = { + currentTab: "series", + onTabChange: vi.fn(), + }; + + const sortOptions: SortOption[] = [ + { field: "name", label: "Name", defaultDirection: "asc" }, + ]; + + beforeEach(() => { + forceMobileViewport(); + }); + + afterEach(() => { + resetViewport(); + }); + + it("renders tabs and controls without dropping any tab or button below xs", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Recommended")).toBeInTheDocument(); + expect(screen.getByText("Series")).toBeInTheDocument(); + expect(screen.getByText("Books")).toBeInTheDocument(); + expect(screen.getByLabelText("Page size options")).toBeInTheDocument(); + expect(screen.getByLabelText("Sort options")).toBeInTheDocument(); + expect(screen.getByLabelText("Filter options")).toBeInTheDocument(); + }); + + it("stacks controls below the tabs when on phones", () => { + renderWithProviders( + , + ); + + const tabsList = screen.getByText("Series").closest('[role="tablist"]'); + const controlsGroup = screen + .getByLabelText("Page size options") + .closest("div"); + + // Tabs and controls live in separate flex containers (Stack > [tabs] / [controls]) + // rather than the shared used on desktop. + expect(tabsList).not.toBeNull(); + expect(controlsGroup).not.toBeNull(); + expect(controlsGroup?.contains(tabsList as Node)).toBe(false); + }); + + it("right-aligns the controls row on phones", () => { + renderWithProviders( + , + ); + + // Mantine 8 Group applies justify via the `--group-justify` CSS variable + // on its root element rather than an inline `justify-content` declaration. + const controlsGroup = screen + .getByLabelText("Page size options") + .closest(".mantine-Group-root") as HTMLElement | null; + + expect(controlsGroup).not.toBeNull(); + expect(controlsGroup?.style.getPropertyValue("--group-justify")).toBe( + "flex-end", + ); + }); + + it("does not render controls on recommended tab even on mobile", () => { + renderWithProviders( + , + ); + + expect(screen.queryByLabelText("Sort options")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Page size options"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Recommended")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/library/LibraryToolbar.tsx b/web/src/components/library/LibraryToolbar.tsx index 274c9397..2e0703cf 100644 --- a/web/src/components/library/LibraryToolbar.tsx +++ b/web/src/components/library/LibraryToolbar.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, Group, Menu, Tabs } from "@mantine/core"; +import { ActionIcon, Group, Menu, Stack, Tabs } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp, @@ -6,6 +7,7 @@ import { IconSortAscending, IconSortDescending, } from "@tabler/icons-react"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { BookFilterPanel } from "./BookFilterPanel"; import { SeriesFilterPanel } from "./SeriesFilterPanel"; @@ -45,119 +47,132 @@ export function LibraryToolbar({ onPageSizeChange, }: LibraryToolbarProps) { const showControls = currentTab !== "recommended" && sortOptions.length > 0; + // Below the `xs` breakpoint the tabs + controls don't fit in one row (audit + // finding L1). Stack the controls underneath instead of letting the row wrap. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; - return ( - - - - {showRecommended && ( - Recommended - )} - Series - Books - - + const tabs = ( + + + {showRecommended && ( + Recommended + )} + Series + Books + + + ); - {showControls && ( - - {/* Page Size Menu */} - - - - - - - - Page size - {PAGE_SIZE_OPTIONS.map((option) => ( - onPageSizeChange?.(option.value)} - bg={ - pageSize === option.value - ? "var(--mantine-color-blue-light)" - : undefined - } - > - {option.label} - - ))} - - + const controls = showControls ? ( + + {/* Page Size Menu */} + + + + + + + + Page size + {PAGE_SIZE_OPTIONS.map((option) => ( + onPageSizeChange?.(option.value)} + bg={ + pageSize === option.value + ? "var(--mantine-color-blue-light)" + : undefined + } + > + {option.label} + + ))} + + + + {/* Sort Menu */} + + + + {sort?.endsWith(",desc") ? ( + + ) : ( + + )} + + + + Sort by + {sortOptions.map((option) => { + const currentField = sort?.split(",")[0]; + const currentDirection = sort?.split(",")[1] as + | "asc" + | "desc" + | undefined; + const isSelected = currentField === option.field; - {/* Sort Menu */} - - - { + if (isSelected) { + // Toggle direction + const newDirection = + currentDirection === "asc" ? "desc" : "asc"; + onSortChange?.(`${option.field},${newDirection}`); + } else { + // Use default direction for new field + onSortChange?.(`${option.field},${option.defaultDirection}`); + } + }; + + return ( + + ) : ( + + ) + ) : null + } > - {sort?.endsWith(",desc") ? ( - - ) : ( - - )} - - - - Sort by - {sortOptions.map((option) => { - const currentField = sort?.split(",")[0]; - const currentDirection = sort?.split(",")[1] as - | "asc" - | "desc" - | undefined; - const isSelected = currentField === option.field; + {option.label} + + ); + })} + + - const handleClick = () => { - if (isSelected) { - // Toggle direction - const newDirection = - currentDirection === "asc" ? "desc" : "asc"; - onSortChange?.(`${option.field},${newDirection}`); - } else { - // Use default direction for new field - onSortChange?.( - `${option.field},${option.defaultDirection}`, - ); - } - }; + {/* Filter Panel - show appropriate panel based on current tab */} + {currentTab === "books" ? : } + + ) : null; - return ( - - ) : ( - - ) - ) : null - } - > - {option.label} - - ); - })} - - + if (isMobile) { + return ( + + {tabs} + {controls} + + ); + } - {/* Filter Panel - show appropriate panel based on current tab */} - {currentTab === "books" ? : } - - )} + return ( + + {tabs} + {controls} ); } diff --git a/web/src/components/offline/DownloadButton.test.tsx b/web/src/components/offline/DownloadButton.test.tsx new file mode 100644 index 00000000..0a6d98b6 --- /dev/null +++ b/web/src/components/offline/DownloadButton.test.tsx @@ -0,0 +1,362 @@ +import { IDBFactory } from "fake-indexeddb"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; +import { + _resetForTests, + broadcastDownloadsChange, + type DownloadRecord, + getDownload, + putDownload, + setDbContext, +} from "@/lib/offline/db"; +import * as downloadManagerModule from "@/lib/offline/downloadManager"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { DownloadButton } from "./DownloadButton"; + +type DownloadFn = typeof downloadManagerModule.downloadSingleFileBook; + +let downloadSpy: MockInstance | null = null; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + downloadSpy?.mockRestore(); + downloadSpy = null; +}); + +function stubDownload( + impl: ( + opts: Parameters[0], + ) => Promise<{ bookId: string; bytes: number }>, +) { + downloadSpy = vi + .spyOn(downloadManagerModule, "downloadSingleFileBook") + .mockImplementation(impl); +} + +async function seed(record: DownloadRecord) { + await putDownload(record); +} + +describe("DownloadButton: format support", () => { + it("renders nothing for unknown formats", () => { + renderWithProviders(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders nothing for a comic format with no pageCount", () => { + renderWithProviders(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders a download icon for epub", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); + + it("renders a download icon for pdf", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); + + it("renders a download icon for cbz when pageCount is provided", async () => { + renderWithProviders( + , + ); + expect( + await screen.findByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); +}); + +describe("DownloadButton: hydration from IDB", () => { + it("shows the downloaded state when the IDB row already exists", async () => { + await seed({ + id: "book-1", + format: "epub", + status: "complete", + bytes: 1024, + pageCount: 1, + downloadedAt: 1, + }); + renderWithProviders(); + expect( + await screen.findByRole("button", { + name: /offline download options/i, + }), + ).toBeInTheDocument(); + }); + + it("shows the error state when the IDB row is in error", async () => { + await seed({ + id: "book-1", + format: "epub", + status: "error", + bytes: 0, + pageCount: 1, + error: "boom", + }); + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /retry download/i }), + ).toBeInTheDocument(); + }); + + it("falls back to not-downloaded when the IDB row says downloading (stale)", async () => { + await seed({ + id: "book-1", + format: "epub", + status: "downloading", + bytes: 0, + pageCount: 1, + }); + renderWithProviders(); + // A stale "downloading" row from a prior tab/session shows the cancel + // affordance even though no controller is wired; cancel does nothing + // but is harmless. + expect( + await screen.findByRole("button", { name: /cancel download/i }), + ).toBeInTheDocument(); + }); +}); + +describe("DownloadButton: download trigger and progress", () => { + it("invokes downloadSingleFileBook and forwards progress to the ring", async () => { + let progressCallback: + | ((p: { loaded: number; total: number | null }) => void) + | undefined; + + stubDownload(async (opts) => { + progressCallback = opts.onProgress; + progressCallback?.({ loaded: 50, total: 100 }); + progressCallback?.({ loaded: 100, total: 100 }); + // Simulate the manager's final IDB write + broadcast so the listener + // can flip to "downloaded". + const complete: DownloadRecord = { + id: opts.bookId, + format: "epub", + status: "complete", + bytes: 100, + pageCount: 1, + downloadedAt: 1, + }; + await putDownload(complete); + broadcastDownloadsChange({ kind: "put", record: complete }); + return { bookId: opts.bookId, bytes: 100 }; + }); + + renderWithProviders(); + + const trigger = await screen.findByRole("button", { + name: /save for offline/i, + }); + await userEvent.click(trigger); + + expect(downloadSpy).toHaveBeenCalledWith( + expect.objectContaining({ bookId: "book-1", format: "epub" }), + ); + + // After completion the broadcast flips the UI to the downloaded state. + await waitFor(() => { + expect( + screen.getByRole("button", { name: /offline download options/i }), + ).toBeInTheDocument(); + }); + }); + + it("dispatches to downloadComicBook for cbz with pageCount", async () => { + const comicSpy = vi + .spyOn(downloadManagerModule, "downloadComicBook") + .mockImplementation(async (opts) => { + opts.onProgress?.({ loaded: opts.pageCount, total: opts.pageCount }); + const complete: DownloadRecord = { + id: opts.bookId, + format: "cbz", + status: "complete", + bytes: opts.pageCount, + pageCount: opts.pageCount, + downloadedAt: 1, + }; + await putDownload(complete); + broadcastDownloadsChange({ kind: "put", record: complete }); + return { bookId: opts.bookId, bytes: opts.pageCount }; + }); + + try { + renderWithProviders( + , + ); + const trigger = await screen.findByRole("button", { + name: /save for offline/i, + }); + await userEvent.click(trigger); + + expect(comicSpy).toHaveBeenCalledWith( + expect.objectContaining({ + bookId: "book-cbz", + format: "cbz", + pageCount: 12, + }), + ); + await waitFor(() => { + expect( + screen.getByRole("button", { name: /offline download options/i }), + ).toBeInTheDocument(); + }); + } finally { + comicSpy.mockRestore(); + } + }); + + it("calls AbortController.abort when the user clicks cancel", async () => { + let receivedSignal: AbortSignal | undefined; + let resolveDownload: (() => void) | null = null; + + stubDownload(async (opts) => { + receivedSignal = opts.signal; + // Block on a manual resolve so the test can simulate "still in flight". + await new Promise((res) => { + resolveDownload = res; + }); + throw new DOMException("Aborted", "AbortError"); + }); + + renderWithProviders(); + const trigger = await screen.findByRole("button", { + name: /save for offline/i, + }); + await userEvent.click(trigger); + + const cancel = await screen.findByRole("button", { + name: /cancel download/i, + }); + await userEvent.click(cancel); + + expect(receivedSignal?.aborted).toBe(true); + + // Unblock the stubbed download so the component's catch runs. + resolveDownload?.(); + await waitFor(() => { + expect( + screen.getByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); + }); +}); + +describe("DownloadButton: remove flow", () => { + it("removing deletes the IDB row and resets to not-downloaded", async () => { + await seed({ + id: "book-1", + format: "epub", + status: "complete", + bytes: 100, + pageCount: 1, + downloadedAt: 1, + }); + renderWithProviders(); + const menuTarget = await screen.findByRole("button", { + name: /offline download options/i, + }); + await userEvent.click(menuTarget); + + const removeItem = await screen.findByRole("menuitem", { + name: /remove offline copy/i, + }); + await userEvent.click(removeItem); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); + expect(await getDownload("book-1")).toBeUndefined(); + }); +}); + +describe("DownloadButton: cross-tab broadcast", () => { + it("flips to downloaded when a put-complete broadcast arrives", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + + const record: DownloadRecord = { + id: "book-1", + format: "epub", + status: "complete", + bytes: 42, + pageCount: 1, + downloadedAt: 1, + }; + broadcastDownloadsChange({ kind: "put", record }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /offline download options/i }), + ).toBeInTheDocument(); + }); + }); + + it("flips back to not-downloaded when a delete broadcast arrives", async () => { + await seed({ + id: "book-1", + format: "epub", + status: "complete", + bytes: 42, + pageCount: 1, + downloadedAt: 1, + }); + renderWithProviders(); + expect( + await screen.findByRole("button", { + name: /offline download options/i, + }), + ).toBeInTheDocument(); + + broadcastDownloadsChange({ kind: "delete", id: "book-1" }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); + }); + + it("ignores broadcasts for other book ids", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + + const otherRecord: DownloadRecord = { + id: "different-book", + format: "pdf", + status: "complete", + bytes: 99, + pageCount: 1, + downloadedAt: 1, + }; + broadcastDownloadsChange({ kind: "put", record: otherRecord }); + + // Should still be in the not-downloaded state. + expect( + screen.getByRole("button", { name: /save for offline/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/offline/DownloadButton.tsx b/web/src/components/offline/DownloadButton.tsx new file mode 100644 index 00000000..f75bc39c --- /dev/null +++ b/web/src/components/offline/DownloadButton.tsx @@ -0,0 +1,406 @@ +import { + ActionIcon, + Group, + Menu, + RingProgress, + Text, + Tooltip, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertCircle, + IconCloudCheck, + IconCloudDownload, + IconDotsVertical, + IconRefresh, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useEffect, useRef, useState } from "react"; +import { + broadcastDownloadsChange, + DOWNLOADS_BROADCAST_CHANNEL, + type DownloadsBroadcast, + deleteDownload, + getDownload, +} from "@/lib/offline/db"; +import { + type ComicFormat, + downloadComicBook, + downloadSingleFileBook, + type ProgressUpdate, + type SingleFileFormat, +} from "@/lib/offline/downloadManager"; +import { shouldShowInstallNudge } from "@/lib/offline/installNudge"; +import { cacheNameForBook } from "@/lib/offline/routeMatcher"; +import { InstallNudgeModal } from "./InstallNudgeModal"; + +/** + * Phase 12 T8: per-book download button. + * + * Renders a single ActionIcon (or icon + ring) that hydrates from IDB on + * mount, subscribes to the downloads BroadcastChannel for cross-tab updates, + * and dispatches to the right `downloadManager` entry point on click: + * `downloadSingleFileBook` for EPUB/PDF, `downloadComicBook` for CBZ/CBR. + * Five visible states cycle through `loading` -> `not-downloaded` -> + * `downloading` (RingProgress + cancel) -> `downloaded` (Menu) or `error`. + * + * Series batch (T5) wraps this component in a queue; the series-level + * "Download series" button is intentionally not part of this slice. + */ + +type ButtonState = + | { kind: "loading" } + | { kind: "not-downloaded" } + | { kind: "downloading"; loaded: number; total: number | null } + | { kind: "downloaded"; bytes: number } + | { kind: "error"; message: string }; + +export type DownloadButtonFormat = + | SingleFileFormat + | ComicFormat + | (string & {}); + +export interface DownloadButtonProps { + bookId: string; + /** Lowercase book file format from the API (e.g. "epub", "pdf", "cbz"). */ + fileFormat: DownloadButtonFormat; + /** + * Total page count. Required for comic formats so the per-page download + * knows how many pages to fetch; ignored for single-file formats but + * accepted for callers (BookDetail) that always have it on hand. + */ + pageCount?: number; + /** Tooltip / menu label, defaults to "Save for offline reading". */ + label?: string; +} + +function isSingleFileFormat(format: string): format is SingleFileFormat { + return format === "epub" || format === "pdf"; +} + +function isComicFormat(format: string): format is ComicFormat { + return format === "cbz" || format === "cbr"; +} + +function progressPercent(state: ButtonState): number { + if (state.kind !== "downloading") return 0; + if (state.total === null || state.total <= 0) return 0; + return Math.min(100, Math.round((state.loaded / state.total) * 100)); +} + +export function DownloadButton({ + bookId, + fileFormat, + pageCount, + label = "Save for offline reading", +}: DownloadButtonProps) { + const [state, setState] = useState({ kind: "loading" }); + const [nudgeOpen, setNudgeOpen] = useState(false); + const abortRef = useRef(null); + const supported = + isSingleFileFormat(fileFormat) || + (isComicFormat(fileFormat) && (pageCount ?? 0) > 0); + + // Hydrate from IDB + subscribe to broadcast updates from other tabs. + // Effect intentionally does not depend on `supported` so the listener + // would still fire if T4 later flips comics into the supported set. + useEffect(() => { + let cancelled = false; + + async function hydrate() { + try { + const record = await getDownload(bookId); + if (cancelled) return; + // Only set state if we're still in the initial loading phase. The + // user may have clicked the trigger during the async IDB read, in + // which case `state.kind` is already "downloading" and we must not + // clobber that with whatever was on disk. + setState((prev) => { + if (prev.kind !== "loading") return prev; + if (!record) return { kind: "not-downloaded" }; + if (record.status === "complete") { + return { kind: "downloaded", bytes: record.bytes }; + } + if (record.status === "downloading") { + return { kind: "downloading", loaded: record.bytes, total: null }; + } + if (record.status === "error") { + return { + kind: "error", + message: record.error ?? "Download failed", + }; + } + return { kind: "not-downloaded" }; + }); + } catch { + if (!cancelled) { + setState((prev) => + prev.kind === "loading" ? { kind: "not-downloaded" } : prev, + ); + } + } + } + + void hydrate(); + + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== "undefined") { + channel = new BroadcastChannel(DOWNLOADS_BROADCAST_CHANNEL); + channel.addEventListener("message", handleBroadcast); + } + + function handleBroadcast(ev: MessageEvent) { + const payload = ev.data; + if (payload.kind === "delete" && payload.id === bookId) { + setState({ kind: "not-downloaded" }); + return; + } + if (payload.kind === "clear") { + setState({ kind: "not-downloaded" }); + return; + } + if (payload.kind === "put" && payload.record.id === bookId) { + const r = payload.record; + if (r.status === "complete") { + setState({ kind: "downloaded", bytes: r.bytes }); + } else if (r.status === "downloading") { + setState((prev) => { + // Preserve the in-progress total/loaded from a local download in + // flight; cross-tab broadcasts only carry the initial 0-byte row. + if (prev.kind === "downloading") return prev; + return { kind: "downloading", loaded: r.bytes, total: null }; + }); + } else if (r.status === "error") { + setState({ + kind: "error", + message: r.error ?? "Download failed", + }); + } + } + } + + return () => { + cancelled = true; + if (channel) { + channel.removeEventListener("message", handleBroadcast); + channel.close(); + } + }; + }, [bookId]); + + if (!supported) return null; + + function maybeNudgeThenDownload() { + // T10: On a fresh iOS Safari tab, show the install nudge before the + // first download instead of jumping straight in. After the user picks + // Continue (or dismisses), `startDownload` runs as usual; subsequent + // taps within the 30-day TTL skip the modal entirely. + if (shouldShowInstallNudge()) { + setNudgeOpen(true); + return; + } + void startDownload(); + } + + async function startDownload() { + const controller = new AbortController(); + abortRef.current = controller; + const initialTotal = isComicFormat(fileFormat) ? (pageCount ?? null) : null; + setState({ kind: "downloading", loaded: 0, total: initialTotal }); + const onProgress = (p: ProgressUpdate) => { + setState({ kind: "downloading", loaded: p.loaded, total: p.total }); + }; + try { + if (isSingleFileFormat(fileFormat)) { + await downloadSingleFileBook({ + bookId, + format: fileFormat, + signal: controller.signal, + onProgress, + }); + } else if (isComicFormat(fileFormat) && (pageCount ?? 0) > 0) { + await downloadComicBook({ + bookId, + format: fileFormat, + pageCount: pageCount as number, + signal: controller.signal, + onProgress, + }); + } else { + throw new Error( + `Unsupported format for offline download: ${fileFormat}`, + ); + } + // Final "downloaded" state lands via the broadcast from the manager. + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + // The manager already deleted the IDB row + cache on abort, but the + // broadcast may not have arrived yet; reset local state immediately. + setState({ kind: "not-downloaded" }); + } else { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + color: "red", + title: "Download failed", + message, + }); + } + } finally { + abortRef.current = null; + } + } + + function cancelDownload() { + abortRef.current?.abort(); + } + + async function removeDownload() { + try { + await deleteDownload(bookId); + broadcastDownloadsChange({ kind: "delete", id: bookId }); + if (typeof caches !== "undefined") { + await caches.delete(cacheNameForBook(bookId)); + } + setState({ kind: "not-downloaded" }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + color: "red", + title: "Could not remove offline copy", + message, + }); + } + } + + if (state.kind === "loading") { + return ( + + + + ); + } + + if (state.kind === "not-downloaded") { + return ( + <> + + + + + + { + setNudgeOpen(false); + void startDownload(); + }} + onClose={() => setNudgeOpen(false)} + /> + + ); + } + + if (state.kind === "downloading") { + const pct = progressPercent(state); + return ( + + + + + + + + + + + ); + } + + if (state.kind === "error") { + return ( + + + + + + ); + } + + // state.kind === "downloaded" + return ( + + + + + + + + + + + + Saved offline + + + } + onClick={startDownload} + > + Re-download + + } + color="red" + onClick={removeDownload} + > + Remove offline copy + + + }> + + More controls in Settings + + + + + ); +} diff --git a/web/src/components/offline/InstallNudgeModal.test.tsx b/web/src/components/offline/InstallNudgeModal.test.tsx new file mode 100644 index 00000000..cc4c736e --- /dev/null +++ b/web/src/components/offline/InstallNudgeModal.test.tsx @@ -0,0 +1,141 @@ +import { IDBFactory } from "fake-indexeddb"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; +import { _resetForTests, setDbContext } from "@/lib/offline/db"; +import * as downloadManagerModule from "@/lib/offline/downloadManager"; +import { _resetPersistenceForTests } from "@/lib/offline/downloadManager"; +import { + INSTALL_NUDGE_DISMISSED_KEY, + isNudgeDismissed, +} from "@/lib/offline/installNudge"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { DownloadButton } from "./DownloadButton"; + +const ORIGINAL_UA = navigator.userAgent; +const ORIGINAL_PLATFORM = navigator.platform; + +let downloadSpy: MockInstance< + typeof downloadManagerModule.downloadSingleFileBook +> | null = null; + +function setIosUserAgent(): void { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605", + }); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "iPhone", + }); +} + +function restoreUserAgent(): void { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: ORIGINAL_UA, + }); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: ORIGINAL_PLATFORM, + }); +} + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); + window.localStorage.clear(); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + _resetPersistenceForTests(); + restoreUserAgent(); + downloadSpy?.mockRestore(); + downloadSpy = null; +}); + +describe("DownloadButton + InstallNudgeModal (T10)", () => { + it("shows the nudge on first tap from an iOS Safari tab, then downloads after Continue", async () => { + setIosUserAgent(); + downloadSpy = vi + .spyOn(downloadManagerModule, "downloadSingleFileBook") + .mockResolvedValue({ bookId: "book-ios", bytes: 4 }); + + renderWithProviders(); + const trigger = await screen.findByRole("button", { + name: /save for offline/i, + }); + await userEvent.click(trigger); + + // Modal appears with the iOS-specific copy. + expect( + await screen.findByText(/iOS Safari may clear offline downloads/i), + ).toBeInTheDocument(); + // Manager has NOT been called yet — nudge is a gate on the first call. + expect(downloadSpy).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { name: /continue anyway/i }), + ); + + await waitFor(() => { + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + expect(isNudgeDismissed()).toBe(true); + }); + + it("subsequent taps within the TTL skip the modal and download immediately", async () => { + setIosUserAgent(); + // Pretend the user previously dismissed. + window.localStorage.setItem( + INSTALL_NUDGE_DISMISSED_KEY, + String(Date.now()), + ); + downloadSpy = vi + .spyOn(downloadManagerModule, "downloadSingleFileBook") + .mockResolvedValue({ bookId: "book-ios-2", bytes: 4 }); + + renderWithProviders( + , + ); + await userEvent.click( + await screen.findByRole("button", { name: /save for offline/i }), + ); + + await waitFor(() => { + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByText(/iOS Safari may clear offline downloads/i), + ).not.toBeInTheDocument(); + }); + + it("does not show the nudge on non-iOS browsers", async () => { + // userAgent stays as the test runner default (jsdom). + downloadSpy = vi + .spyOn(downloadManagerModule, "downloadSingleFileBook") + .mockResolvedValue({ bookId: "book-desktop", bytes: 4 }); + + renderWithProviders( + , + ); + await userEvent.click( + await screen.findByRole("button", { name: /save for offline/i }), + ); + + await waitFor(() => { + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByText(/iOS Safari may clear offline downloads/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/offline/InstallNudgeModal.tsx b/web/src/components/offline/InstallNudgeModal.tsx new file mode 100644 index 00000000..92f850dc --- /dev/null +++ b/web/src/components/offline/InstallNudgeModal.tsx @@ -0,0 +1,100 @@ +import { Button, Group, List, Modal, Stack, Text } from "@mantine/core"; +import { IconShare } from "@tabler/icons-react"; +import { recordNudgeDismissal } from "@/lib/offline/installNudge"; + +/** + * Phase 12 T10: iOS-Safari-only soft modal shown before the first + * offline download in a session. + * + * Explains the platform-specific eviction risk and offers two paths: + * + * - "Continue anyway" — proceeds with the download. The intent is a soft + * nudge, never a gate: users still get their book. + * - "Show me how to install" — keeps the modal open while explaining the + * Add-to-Home-Screen flow (the same content as InstallPrompt.tsx). + * The user closes manually when ready. + * + * Either path records dismissal so we do not re-nag on subsequent + * downloads within the 30-day TTL. + */ + +export interface InstallNudgeModalProps { + opened: boolean; + /** Invoked after dismissal is recorded — caller proceeds with download. */ + onContinue: () => void; + /** Invoked when user cancels without continuing. */ + onClose: () => void; +} + +export function InstallNudgeModal({ + opened, + onContinue, + onClose, +}: InstallNudgeModalProps) { + const handleContinue = () => { + recordNudgeDismissal(); + onContinue(); + }; + + const handleClose = () => { + // Closing without continuing still records dismissal: re-prompting on + // every tap during the same session would be aggressive, and the + // 30-day TTL gives us a natural retry window. + recordNudgeDismissal(); + onClose(); + }; + + return ( + + + + iOS Safari may clear offline downloads after about a week of + inactivity unless Codex is installed to your Home Screen. You can + still download now — this is just a heads-up. + + + + + To make downloads durable: + + + + + Tap the Share icon + + in Safari's bottom toolbar. + + + + Scroll down and choose{" "} + + Add to Home Screen + + . + + + Confirm the name and tap{" "} + + Add + + , then open Codex from your Home Screen and try again. + + + + + + + + + + + ); +} diff --git a/web/src/components/offline/SeriesDownloadButton.test.tsx b/web/src/components/offline/SeriesDownloadButton.test.tsx new file mode 100644 index 00000000..883a4138 --- /dev/null +++ b/web/src/components/offline/SeriesDownloadButton.test.tsx @@ -0,0 +1,276 @@ +import { IDBFactory } from "fake-indexeddb"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; +import { _resetForTests, setDbContext } from "@/lib/offline/db"; +import { _resetPersistenceForTests } from "@/lib/offline/downloadManager"; +import * as seriesQueueModule from "@/lib/offline/seriesDownloadQueue"; +import { + type BookQueueState, + QuotaExceededError, + type SeriesDownloadController, + type SeriesQueueState, +} from "@/lib/offline/seriesDownloadQueue"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { SeriesDownloadButton } from "./SeriesDownloadButton"; + +type BatchFn = typeof seriesQueueModule.downloadSeriesBatch; +let batchSpy: MockInstance | null = null; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + _resetPersistenceForTests(); + batchSpy?.mockRestore(); + batchSpy = null; +}); + +function stubBatch( + impl: (opts: Parameters[0]) => Promise, +) { + batchSpy = vi + .spyOn(seriesQueueModule, "downloadSeriesBatch") + .mockImplementation(impl); +} + +/** + * Build a synthetic controller whose subscribe/done lifecycle is driven + * by the test. The factory returns the controller and a `push` helper + * the test calls to deliver a new snapshot to subscribers. + */ +function makeController( + seriesId: string, + bookIds: string[], +): { + controller: SeriesDownloadController; + push: (state: SeriesQueueState) => void; + resolve: (result?: SeriesQueueState) => void; +} { + const listeners = new Set<(s: SeriesQueueState) => void>(); + const initial: SeriesQueueState = { + seriesId, + total: bookIds.length, + completed: 0, + failed: 0, + cancelled: 0, + perBook: new Map( + bookIds.map((id) => [ + id, + { + bookId: id, + status: "queued", + loaded: 0, + total: null, + } satisfies BookQueueState, + ]), + ), + }; + let current = initial; + let resolveDone!: ( + result: ReturnType, + ) => void; + const donePromise = new Promise((res) => { + resolveDone = res; + }); + + const controller: SeriesDownloadController = { + cancelBook: vi.fn(), + cancelAll: vi.fn(), + subscribe(listener) { + listeners.add(listener); + listener(current); + return () => listeners.delete(listener); + }, + getState: () => current, + // The component awaits this and uses the snapshot to render the + // "done" panel; the test resolves it directly when ready. + done: donePromise.then((finalState) => ({ + completed: Array.from(finalState.perBook.values()) + .filter((b) => b.status === "complete") + .map((b) => b.bookId), + failed: Array.from(finalState.perBook.values()) + .filter((b) => b.status === "error") + .map((b) => ({ bookId: b.bookId, error: b.error ?? "err" })), + cancelled: Array.from(finalState.perBook.values()) + .filter((b) => b.status === "cancelled") + .map((b) => b.bookId), + })), + }; + + return { + controller, + push: (state) => { + current = state; + for (const l of Array.from(listeners)) l(state); + }, + resolve: (result) => { + resolveDone(result ?? current); + }, + }; +} + +const epubBooks = [ + { id: "a", fileFormat: "epub", pageCount: 1, fileSize: 4 }, + { id: "b", fileFormat: "epub", pageCount: 1, fileSize: 4 }, +]; + +describe("SeriesDownloadButton: idle state", () => { + it("renders a Download series button and opens the modal on click", async () => { + renderWithProviders( + , + ); + const trigger = screen.getByRole("button", { name: /download series/i }); + await userEvent.click(trigger); + expect( + await screen.findByRole("button", { name: /start downloading/i }), + ).toBeInTheDocument(); + }); + + it("shows an Unsupported badge for books the queue cannot handle", async () => { + renderWithProviders( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /download series/i }), + ); + expect(await screen.findByText(/unsupported/i)).toBeInTheDocument(); + }); +}); + +describe("SeriesDownloadButton: pre-flight refusal", () => { + it("displays the quota refusal message and never enters the running state", async () => { + stubBatch(async () => { + throw new QuotaExceededError({ + estimatedBytes: 1_000_000, + usage: 900_000, + quota: 1_000_000, + threshold: 0.9, + }); + }); + renderWithProviders( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /download series/i }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /start downloading/i }), + ); + expect( + await screen.findByText(/would exceed storage quota/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /cancel all/i }), + ).not.toBeInTheDocument(); + }); +}); + +describe("SeriesDownloadButton: running state", () => { + it("renders per-book progress as the controller emits updates", async () => { + const ctx = makeController("s-run", ["a", "b"]); + stubBatch(async () => ctx.controller); + renderWithProviders( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /download series/i }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /start downloading/i }), + ); + // Wait for the running phase to render the Cancel-all button. + await screen.findByRole("button", { name: /cancel all/i }); + + // Push a progress update — book a downloading at 50%. + const next: SeriesQueueState = { + seriesId: "s-run", + total: 2, + completed: 0, + failed: 0, + cancelled: 0, + perBook: new Map([ + [ + "a", + { + bookId: "a", + status: "downloading", + loaded: 50, + total: 100, + }, + ], + ["b", { bookId: "b", status: "queued", loaded: 0, total: null }], + ]), + }; + ctx.push(next); + await screen.findByText(/downloading/i); + }); + + it("Cancel all invokes controller.cancelAll", async () => { + const ctx = makeController("s-cancel", ["a", "b"]); + stubBatch(async () => ctx.controller); + renderWithProviders( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /download series/i }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /start downloading/i }), + ); + const cancelAll = await screen.findByRole("button", { + name: /cancel all/i, + }); + await userEvent.click(cancelAll); + expect(ctx.controller.cancelAll).toHaveBeenCalled(); + }); +}); + +describe("SeriesDownloadButton: done state", () => { + it("flips to the done panel when the controller resolves", async () => { + const ctx = makeController("s-done", ["a", "b"]); + stubBatch(async () => ctx.controller); + renderWithProviders( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /download series/i }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /start downloading/i }), + ); + await screen.findByRole("button", { name: /cancel all/i }); + + const final: SeriesQueueState = { + seriesId: "s-done", + total: 2, + completed: 2, + failed: 0, + cancelled: 0, + perBook: new Map([ + ["a", { bookId: "a", status: "complete", loaded: 1, total: 1 }], + ["b", { bookId: "b", status: "complete", loaded: 1, total: 1 }], + ]), + }; + ctx.push(final); + ctx.resolve(final); + await waitFor(() => { + expect(screen.getByText(/2 downloaded/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/offline/SeriesDownloadButton.tsx b/web/src/components/offline/SeriesDownloadButton.tsx new file mode 100644 index 00000000..5c91e073 --- /dev/null +++ b/web/src/components/offline/SeriesDownloadButton.tsx @@ -0,0 +1,503 @@ +import { + ActionIcon, + Badge, + Button, + Card, + Group, + Modal, + Progress, + ScrollArea, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconCheck, + IconCloudCheck, + IconCloudDownload, + IconExclamationCircle, + IconX, +} from "@tabler/icons-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { shouldShowInstallNudge } from "@/lib/offline/installNudge"; +import { + downloadSeriesBatch, + QuotaExceededError, + type SeriesBookSummary, + type SeriesDownloadController, + type SeriesQueueState, +} from "@/lib/offline/seriesDownloadQueue"; +import { InstallNudgeModal } from "./InstallNudgeModal"; + +/** + * Phase 12 T5: "Download series" entry point for SeriesDetail. + * + * Renders a primary action that opens a modal listing every book in the + * series with its planned size and current state. Confirming kicks off + * `downloadSeriesBatch`; the modal then renders per-book progress + per- + * book cancel + a queue-wide "Cancel all". Closing the modal while the + * queue is running keeps it running (button shows compact aggregate + * progress); reopening re-attaches the listener to the existing + * controller. + * + * Pre-flight `QuotaExceededError` surfaces as a destructive notification + * and a banner inside the modal; the queue never starts, so no IDB rows + * or per-book caches are written. + */ + +export interface SeriesDownloadButtonProps { + seriesId: string; + books: SeriesBookSummary[]; + /** Optional label for the button. Defaults to "Download series". */ + label?: string; +} + +type Phase = + | { kind: "idle" } + | { kind: "preflight-error"; message: string } + | { + kind: "running"; + controller: SeriesDownloadController; + state: SeriesQueueState; + } + | { kind: "done"; result: SeriesQueueState }; + +function statusColor(status: string): string { + switch (status) { + case "complete": + return "green"; + case "downloading": + return "blue"; + case "error": + return "red"; + case "cancelled": + return "gray"; + case "skipped": + return "gray"; + default: + return "gray"; + } +} + +function statusLabel(status: string): string { + switch (status) { + case "complete": + return "Saved"; + case "downloading": + return "Downloading"; + case "queued": + return "Queued"; + case "error": + return "Failed"; + case "cancelled": + return "Cancelled"; + case "skipped": + return "Skipped"; + default: + return status; + } +} + +function bookProgressPercent(loaded: number, total: number | null): number { + if (total === null || total <= 0) return 0; + return Math.min(100, Math.round((loaded / total) * 100)); +} + +export function SeriesDownloadButton({ + seriesId, + books, + label = "Download series", +}: SeriesDownloadButtonProps) { + const [phase, setPhase] = useState({ kind: "idle" }); + const [opened, { open, close }] = useDisclosure(false); + const [nudgeOpen, setNudgeOpen] = useState(false); + // Hold the controller in a ref so cancel handlers can reach it without + // forcing the listener to close over fresh closures. + const controllerRef = useRef(null); + // Effects below depend on controller identity, not the whole `phase` + // object: the subscribe listener mutates `phase.state` on every emit, + // which would otherwise re-trigger the subscribe effect in a loop. + const activeController = phase.kind === "running" ? phase.controller : null; + + // Unsubscribe is owned by the running phase; reset on transitions. + useEffect(() => { + if (!activeController) return; + const unsubscribe = activeController.subscribe((s) => { + setPhase((prev) => + prev.kind === "running" ? { ...prev, state: cloneState(s) } : prev, + ); + }); + return unsubscribe; + }, [activeController]); + + // When the queue resolves, flip to `done` and surface a notification. + useEffect(() => { + if (!activeController) return; + let cancelled = false; + const ctrl = activeController; + ctrl.done.then((result) => { + if (cancelled) return; + const finalState = cloneState(ctrl.getState()); + setPhase({ kind: "done", result: finalState }); + controllerRef.current = null; + const total = result.completed.length + result.failed.length; + if (result.failed.length === 0 && result.cancelled.length === 0) { + notifications.show({ + color: "green", + title: "Series saved offline", + message: `${result.completed.length} book${result.completed.length === 1 ? "" : "s"} downloaded.`, + }); + } else if (result.failed.length > 0) { + notifications.show({ + color: "orange", + title: "Series partially downloaded", + message: `${result.completed.length}/${total} books saved, ${result.failed.length} failed.`, + }); + } + }); + return () => { + cancelled = true; + }; + }, [activeController]); + + const startInternal = useCallback(async () => { + try { + const controller = await downloadSeriesBatch({ + seriesId, + books, + }); + controllerRef.current = controller; + setPhase({ + kind: "running", + controller, + state: cloneState(controller.getState()), + }); + } catch (err) { + if (err instanceof QuotaExceededError) { + const message = err.message; + setPhase({ kind: "preflight-error", message }); + notifications.show({ + color: "red", + title: "Not enough storage", + message, + }); + } else { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + color: "red", + title: "Could not start series download", + message, + }); + } + } + }, [seriesId, books]); + + const handleStart = useCallback(() => { + // T10: iOS Safari tab gets the install nudge before the batch starts. + // Continue runs `startInternal`; dismissal just closes the nudge and + // leaves the user on the confirmation panel so they can opt back in. + if (shouldShowInstallNudge()) { + setNudgeOpen(true); + return; + } + void startInternal(); + }, [startInternal]); + + const handleCancelBook = useCallback((bookId: string) => { + controllerRef.current?.cancelBook(bookId); + }, []); + + const handleCancelAll = useCallback(() => { + controllerRef.current?.cancelAll(); + }, []); + + const aggregate = (() => { + if (phase.kind === "running") { + return { + completed: phase.state.completed, + total: phase.state.total, + failed: phase.state.failed, + }; + } + if (phase.kind === "done") { + return { + completed: phase.result.completed, + total: phase.result.total, + failed: phase.result.failed, + }; + } + return null; + })(); + + const supportedCount = books.filter((b) => + ["epub", "pdf", "cbz", "cbr"].includes(b.fileFormat), + ).length; + const allDone = + phase.kind === "done" && + phase.result.failed === 0 && + phase.result.cancelled === 0 && + phase.result.completed === supportedCount; + + return ( + <> + + + {phase.kind === "running" && aggregate && ( + + + {aggregate.completed}/{aggregate.total} + + + )} + + + + + {phase.kind === "idle" && ( + + + Save every supported book in this series to this device for + offline reading. Downloads happen one book at a time so the + queue does not flood the network. + + + + + + + + )} + + {phase.kind === "preflight-error" && ( + + + + + {phase.message} + + + + Free up storage on this device or remove existing offline + downloads from Settings → Offline downloads, then try + again. + + + + + + )} + + {phase.kind === "running" && ( + + + + {aggregate?.completed} of {aggregate?.total} complete + {aggregate && aggregate.failed > 0 + ? ` (${aggregate.failed} failed)` + : ""} + + + + {aggregate && ( + + )} + + + )} + + {phase.kind === "done" && ( + + + + + Done. {phase.result.completed} downloaded,{" "} + {phase.result.failed} failed, {phase.result.cancelled}{" "} + cancelled. + + + + + + + + )} + + + + { + setNudgeOpen(false); + void startInternal(); + }} + onClose={() => setNudgeOpen(false)} + /> + + ); +} + +function cloneState(state: SeriesQueueState): SeriesQueueState { + return { + seriesId: state.seriesId, + total: state.total, + completed: state.completed, + failed: state.failed, + cancelled: state.cancelled, + perBook: new Map(state.perBook), + }; +} + +function BookList({ books }: { books: SeriesBookSummary[] }) { + return ( + + + + {books.map((b) => { + const supported = ["epub", "pdf", "cbz", "cbr"].includes( + b.fileFormat, + ); + return ( + + + {b.id} + + + + {b.fileFormat.toUpperCase()} + + {!supported && ( + + Unsupported + + )} + + + ); + })} + + + + ); +} + +interface QueueListProps { + state: SeriesQueueState; + onCancelBook?: (bookId: string) => void; + readOnly?: boolean; +} + +function QueueList({ state, onCancelBook, readOnly }: QueueListProps) { + return ( + + + + {Array.from(state.perBook.values()).map((b) => { + const pct = bookProgressPercent(b.loaded, b.total); + return ( + + + + {b.bookId} + + + + {statusLabel(b.status)} + + {!readOnly && + (b.status === "queued" || b.status === "downloading") && ( + + onCancelBook?.(b.bookId)} + aria-label={`Cancel download of ${b.bookId}`} + > + + + + )} + + + {b.status === "downloading" && b.total !== null && ( + + )} + {b.status === "error" && b.error && ( + + {b.error} + + )} + + ); + })} + + + + ); +} diff --git a/web/src/components/pwa/InstallPrompt.test.tsx b/web/src/components/pwa/InstallPrompt.test.tsx new file mode 100644 index 00000000..1ec07507 --- /dev/null +++ b/web/src/components/pwa/InstallPrompt.test.tsx @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, userEvent } from "@/test/utils"; +import { InstallPrompt } from "./InstallPrompt"; + +const ORIGINAL_USER_AGENT = navigator.userAgent; +const ORIGINAL_PLATFORM = navigator.platform; +const ORIGINAL_MAX_TOUCH = navigator.maxTouchPoints; +const ORIGINAL_MATCH_MEDIA = window.matchMedia; + +function setUserAgent(ua: string, platform = "MacIntel", maxTouchPoints = 0) { + Object.defineProperty(navigator, "userAgent", { + value: ua, + configurable: true, + }); + Object.defineProperty(navigator, "platform", { + value: platform, + configurable: true, + }); + Object.defineProperty(navigator, "maxTouchPoints", { + value: maxTouchPoints, + configurable: true, + }); +} + +function resetUserAgent() { + Object.defineProperty(navigator, "userAgent", { + value: ORIGINAL_USER_AGENT, + configurable: true, + }); + Object.defineProperty(navigator, "platform", { + value: ORIGINAL_PLATFORM, + configurable: true, + }); + Object.defineProperty(navigator, "maxTouchPoints", { + value: ORIGINAL_MAX_TOUCH, + configurable: true, + }); +} + +function mockStandalone(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(display-mode: standalone)" ? matches : false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + })); +} + +function fireBeforeInstallPrompt( + prompt = vi.fn(), + userChoice = Promise.resolve({ outcome: "accepted" as const }), +) { + const event: Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; + } = Object.assign(new Event("beforeinstallprompt"), { + prompt: async () => { + await prompt(); + }, + userChoice, + }); + window.dispatchEvent(event); + return event; +} + +describe("InstallPrompt", () => { + beforeEach(() => { + mockStandalone(false); + setUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0", + "Linux x86_64", + 0, + ); + }); + + afterEach(() => { + window.matchMedia = ORIGINAL_MATCH_MEDIA; + resetUserAgent(); + localStorage.clear(); + }); + + it("renders nothing initially on a non-iOS platform", () => { + renderWithProviders(); + expect(screen.queryByLabelText("Install Codex")).not.toBeInTheDocument(); + }); + + it("shows the Install button when beforeinstallprompt fires", async () => { + renderWithProviders(); + fireBeforeInstallPrompt(); + expect(await screen.findByLabelText("Install Codex")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument(); + }); + + it("calls prompt() and clears the banner when Install is clicked", async () => { + const user = userEvent.setup(); + const promptSpy = vi.fn(); + renderWithProviders(); + fireBeforeInstallPrompt(promptSpy); + + await user.click(await screen.findByRole("button", { name: "Install" })); + expect(promptSpy).toHaveBeenCalled(); + }); + + it("persists dismissal in localStorage when Not now is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + fireBeforeInstallPrompt(); + + await user.click(await screen.findByRole("button", { name: "Not now" })); + expect(localStorage.getItem("codex-pwa-install-dismissed")).not.toBeNull(); + expect(screen.queryByLabelText("Install Codex")).not.toBeInTheDocument(); + }); + + it("does not render when already dismissed within the TTL window", () => { + localStorage.setItem("codex-pwa-install-dismissed", String(Date.now())); + renderWithProviders(); + fireBeforeInstallPrompt(); + expect(screen.queryByLabelText("Install Codex")).not.toBeInTheDocument(); + }); + + it("does not render when in standalone display mode", () => { + mockStandalone(true); + renderWithProviders(); + fireBeforeInstallPrompt(); + expect(screen.queryByLabelText("Install Codex")).not.toBeInTheDocument(); + }); + + it("renders iOS banner with Show me how button on iPhone Safari", () => { + setUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "iPhone", + 5, + ); + renderWithProviders(); + expect(screen.getByLabelText("Install Codex")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Show me how" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Install" }), + ).not.toBeInTheDocument(); + }); + + it("opens the iOS instructions modal on Show me how click", async () => { + setUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15", + "iPhone", + 5, + ); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Show me how" })); + expect( + await screen.findByText("Add Codex to your Home Screen"), + ).toBeInTheDocument(); + expect(screen.getByText(/Add to Home Screen/i)).toBeInTheDocument(); + }); + + it("detects iPad (MacIntel UA with touch points) as iOS", () => { + setUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/16.6 Safari/605.1.15", + "MacIntel", + 5, + ); + renderWithProviders(); + expect( + screen.getByRole("button", { name: "Show me how" }), + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/pwa/InstallPrompt.tsx b/web/src/components/pwa/InstallPrompt.tsx new file mode 100644 index 00000000..9718c5ab --- /dev/null +++ b/web/src/components/pwa/InstallPrompt.tsx @@ -0,0 +1,218 @@ +import { + ActionIcon, + Button, + Group, + List, + Modal, + Paper, + Stack, + Text, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconDeviceMobile, IconShare, IconX } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; + +const DISMISSED_KEY = "codex-pwa-install-dismissed"; +const DISMISS_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +function isStandaloneDisplay(): boolean { + if (typeof window === "undefined") return false; + const standaloneMedia = window.matchMedia?.( + "(display-mode: standalone)", + ).matches; + const iosStandalone = + "standalone" in window.navigator && + (window.navigator as { standalone?: boolean }).standalone === true; + return Boolean(standaloneMedia || iosStandalone); +} + +function isIos(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + const isIPad = + /iPad/.test(ua) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + return /iPhone|iPod/.test(ua) || isIPad; +} + +function isDismissed(): boolean { + try { + const raw = window.localStorage.getItem(DISMISSED_KEY); + if (!raw) return false; + const ts = Number.parseInt(raw, 10); + if (Number.isNaN(ts)) return false; + return Date.now() - ts < DISMISS_TTL_MS; + } catch { + return false; + } +} + +function recordDismissal() { + try { + window.localStorage.setItem(DISMISSED_KEY, String(Date.now())); + } catch { + /* storage not available — silently ignore */ + } +} + +export function InstallPrompt() { + const [installEvent, setInstallEvent] = + useState(null); + const [showIosBanner, setShowIosBanner] = useState(false); + const [iosModalOpened, { open: openIosModal, close: closeIosModal }] = + useDisclosure(false); + + useEffect(() => { + if (isStandaloneDisplay()) return; + if (isDismissed()) return; + + if (isIos()) { + setShowIosBanner(true); + return; + } + + const handler = (event: Event) => { + event.preventDefault(); + setInstallEvent(event as BeforeInstallPromptEvent); + }; + window.addEventListener("beforeinstallprompt", handler); + const installedHandler = () => { + setInstallEvent(null); + setShowIosBanner(false); + recordDismissal(); + }; + window.addEventListener("appinstalled", installedHandler); + return () => { + window.removeEventListener("beforeinstallprompt", handler); + window.removeEventListener("appinstalled", installedHandler); + }; + }, []); + + const dismiss = () => { + recordDismissal(); + setInstallEvent(null); + setShowIosBanner(false); + }; + + const handleAndroidInstall = async () => { + if (!installEvent) return; + await installEvent.prompt(); + const result = await installEvent.userChoice; + if (result.outcome === "dismissed") { + recordDismissal(); + } + setInstallEvent(null); + }; + + if (!installEvent && !showIosBanner) return null; + + return ( + <> + + + + + + + Install Codex + + + {showIosBanner + ? "Add to your home screen for a full-screen experience." + : "Install the app for offline-ready shell and faster loads."} + + + {installEvent && ( + + )} + {showIosBanner && ( + + )} + + + + + + + + + + + + + + iOS Safari does not offer a one-tap install button, but you can add + Codex to your Home Screen in three steps: + + + + + Tap the Share icon + + in Safari's bottom toolbar. + + + + Scroll down and choose{" "} + + Add to Home Screen + + . + + + Confirm the name and tap{" "} + + Add + + . + + + + Once installed, Codex opens in its own full-screen window, with the + iOS status bar respected by the reader. + + + + + + + + ); +} diff --git a/web/src/components/pwa/PwaUpdatePrompt.tsx b/web/src/components/pwa/PwaUpdatePrompt.tsx new file mode 100644 index 00000000..f3418632 --- /dev/null +++ b/web/src/components/pwa/PwaUpdatePrompt.tsx @@ -0,0 +1,63 @@ +import { useRegisterSW } from "virtual:pwa-register/react"; +import { Button, Group, Stack, Text } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { useEffect, useRef } from "react"; + +const UPDATE_NOTIFICATION_ID = "pwa-update-available"; + +export function PwaUpdatePrompt() { + const { + needRefresh: [needRefresh, setNeedRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisterError(error) { + console.error("Service worker registration failed", error); + }, + }); + + const shownRef = useRef(false); + + useEffect(() => { + if (!needRefresh) { + shownRef.current = false; + return; + } + if (shownRef.current) return; + shownRef.current = true; + notifications.show({ + id: UPDATE_NOTIFICATION_ID, + title: "Update available", + autoClose: false, + withCloseButton: true, + onClose: () => setNeedRefresh(false), + message: ( + + A new version of Codex is ready. + + + + + + ), + }); + }, [needRefresh, setNeedRefresh, updateServiceWorker]); + + return null; +} diff --git a/web/src/components/pwa/index.ts b/web/src/components/pwa/index.ts new file mode 100644 index 00000000..0a480995 --- /dev/null +++ b/web/src/components/pwa/index.ts @@ -0,0 +1,2 @@ +export { InstallPrompt } from "./InstallPrompt"; +export { PwaUpdatePrompt } from "./PwaUpdatePrompt"; diff --git a/web/src/components/reader/ComicReader.tsx b/web/src/components/reader/ComicReader.tsx index ba56fd03..472b9763 100644 --- a/web/src/components/reader/ComicReader.tsx +++ b/web/src/components/reader/ComicReader.tsx @@ -2,6 +2,12 @@ import { Box, Center, Loader, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { booksApi } from "@/api/books"; +import { + DOWNLOADS_BROADCAST_CHANNEL, + type DownloadsBroadcast, + getDownload, +} from "@/lib/offline/db"; +import { getEffectivePreloadWindow } from "@/lib/offline/prefetchWindow"; import { type FitMode, type PageOrientation, @@ -22,7 +28,9 @@ import { useSeriesReaderSettings, useTouchNav, } from "./hooks"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; import { PageTransitionWrapper } from "./PageTransitionWrapper"; +import { ReaderFirstRunHint } from "./ReaderFirstRunHint"; import { ReaderSettings } from "./ReaderSettings"; import { ReaderToolbar } from "./ReaderToolbar"; import { @@ -179,6 +187,51 @@ export function ComicReader({ ); const setGlobalPageLayout = useReaderStore((state) => state.setPageLayout); + // T11: track whether the current book has been saved for offline reading. + // When true, the prefetch window expands aggressively (every page is in + // the SW cache; preloading them just primes the browser's image decoder). + // The listener keeps the flag in sync if the user removes/re-downloads + // the book while the reader stays open. + const [isBookDownloaded, setIsBookDownloaded] = useState(false); + useEffect(() => { + let cancelled = false; + async function hydrate() { + try { + const record = await getDownload(bookId); + if (!cancelled) { + setIsBookDownloaded(record?.status === "complete"); + } + } catch { + if (!cancelled) setIsBookDownloaded(false); + } + } + void hydrate(); + + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== "undefined") { + channel = new BroadcastChannel(DOWNLOADS_BROADCAST_CHANNEL); + channel.addEventListener("message", handleBroadcast); + } + function handleBroadcast(ev: MessageEvent) { + const payload = ev.data; + if (payload.kind === "delete" && payload.id === bookId) { + setIsBookDownloaded(false); + } else if (payload.kind === "clear") { + setIsBookDownloaded(false); + } else if (payload.kind === "put" && payload.record.id === bookId) { + setIsBookDownloaded(payload.record.status === "complete"); + } + } + + return () => { + cancelled = true; + if (channel) { + channel.removeEventListener("message", handleBroadcast); + channel.close(); + } + }; + }, [bookId]); + // Fetch adjacent books for series navigation useAdjacentBooks({ bookId, enabled: true }); @@ -413,32 +466,6 @@ export function ComicReader({ handleStartBoundary(useReaderStore.getState().boundaryState); }, [handleStartBoundary]); - // Handle click zones for single-page navigation - const handleSinglePageClick = useCallback( - (zone: "left" | "center" | "right") => { - if (zone === "center") { - toggleToolbar(); - return; - } - - // Adjust for reading direction - // Uses wrapped handlers that set navigation direction for transitions - if (readingDirection === "ltr") { - if (zone === "left") handlePrevPageWithDirection(); - if (zone === "right") handleNextPageWithDirection(); - } else { - if (zone === "left") handleNextPageWithDirection(); - if (zone === "right") handlePrevPageWithDirection(); - } - }, - [ - readingDirection, - handleNextPageWithDirection, - handlePrevPageWithDirection, - toggleToolbar, - ], - ); - // Generate page URL const getPageUrl = useCallback( (pageNumber: number) => { @@ -570,20 +597,6 @@ export function ComicReader({ setLastNavigationDirection, ]); - // Handle click zones for double-page navigation (left/right halves only) - const handleDoublePageClick = useCallback( - (zone: "left" | "right") => { - // In double-page mode, left/right zones navigate spreads - // Reading direction is already handled in DoublePageSpread component - if (zone === "left") { - handleSpreadPrevPage(); - } else { - handleSpreadNextPage(); - } - }, - [handleSpreadPrevPage, handleSpreadNextPage], - ); - // Keyboard navigation with series navigation support // In continuous/webtoon mode, scroll keys are left to the browser; // in double-page mode, use spread-aware navigation; @@ -630,9 +643,17 @@ export function ComicReader({ // Build list of pages to preload (current page always included) const pagesToPreload = new Set([currentPage]); + // T11: floor the prefetch window so cellular readers (and especially + // downloaded books where every page is a free cache hit) get a snappy + // next-page tap regardless of the user's preload-pages setting. + const widePreload = getEffectivePreloadWindow( + preloadPages, + isBookDownloaded, + ); + // Double-page mode doubles the preload count const effectivePreload = - pageLayout === "double" ? preloadPages * 2 : preloadPages; + pageLayout === "double" ? widePreload * 2 : widePreload; // Preload pages around current position for (let i = 1; i <= effectivePreload; i++) { @@ -676,6 +697,7 @@ export function ComicReader({ currentPage, totalPages, preloadPages, + isBookDownloaded, pageLayout, spreadConfig, getPageUrl, @@ -705,7 +727,7 @@ export function ComicReader({ ) { return (
@@ -716,7 +738,7 @@ export function ComicReader({ if (totalPages === 0) { return (
This book has no pages
@@ -729,7 +751,7 @@ export function ComicReader({ onMouseMove={handleMouseMove} style={{ width: "100vw", - height: "100vh", + height: "100dvh", position: "relative", overflow: "hidden", backgroundColor: "#000", @@ -753,6 +775,28 @@ export function ComicReader({ isContinuousScroll={isContinuousScroll} /> + {/* Phone-only bottom navigation. Hidden in continuous/webtoon modes + where pages are scrolled rather than navigated. */} + {!isContinuousScroll && ( + + )} + + {/* First-run hint teaches phone users that center-tap reveals the + toolbar (CBZ tap zones are left/center/right). Once per session. */} + + {/* Boundary notification */} ) : ( @@ -810,7 +859,6 @@ export function ComicReader({ alt={`Page ${currentPage} of ${title}`} fitMode={fitMode} backgroundColor={backgroundColor} - onClick={handleSinglePageClick} onError={handlePageError} /> )} diff --git a/web/src/components/reader/ComicReaderPage.test.tsx b/web/src/components/reader/ComicReaderPage.test.tsx index ca317609..18a8f111 100644 --- a/web/src/components/reader/ComicReaderPage.test.tsx +++ b/web/src/components/reader/ComicReaderPage.test.tsx @@ -29,92 +29,6 @@ describe("ComicReaderPage", () => { expect(img).toHaveAttribute("alt", "Page 1 of Test Book"); }); - it("should call onClick with correct zone when clicking left third", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByRole("img", { hidden: true }).parentElement; - if (container) { - // Mock getBoundingClientRect - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 900, - top: 0, - height: 600, - right: 900, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - fireEvent.click(container, { clientX: 100 }); // Left third (100 < 300) - expect(onClick).toHaveBeenCalledWith("left"); - } - }); - - it("should call onClick with correct zone when clicking center", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByRole("img", { hidden: true }).parentElement; - if (container) { - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 900, - top: 0, - height: 600, - right: 900, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - fireEvent.click(container, { clientX: 450 }); // Center (300 < 450 < 600) - expect(onClick).toHaveBeenCalledWith("center"); - } - }); - - it("should call onClick with correct zone when clicking right third", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByRole("img", { hidden: true }).parentElement; - if (container) { - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 900, - top: 0, - height: 600, - right: 900, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - fireEvent.click(container, { clientX: 800 }); // Right third (800 > 600) - expect(onClick).toHaveBeenCalledWith("right"); - } - }); - - it("should not call onClick when no handler provided", () => { - renderWithProviders(); - - const container = screen.getByRole("img", { hidden: true }).parentElement; - if (container) { - // Should not throw - fireEvent.click(container); - } - }); - it("should not render when isVisible is false", () => { renderWithProviders( , diff --git a/web/src/components/reader/ComicReaderPage.tsx b/web/src/components/reader/ComicReaderPage.tsx index aaa9bdb9..44091616 100644 --- a/web/src/components/reader/ComicReaderPage.tsx +++ b/web/src/components/reader/ComicReaderPage.tsx @@ -14,8 +14,6 @@ interface ComicReaderPageProps { backgroundColor: BackgroundColor; /** Whether this page is currently visible */ isVisible?: boolean; - /** Click handler for navigation zones */ - onClick?: (zone: "left" | "center" | "right") => void; /** Called when the page image fails to load */ onError?: () => void; } @@ -99,7 +97,6 @@ export function ComicReaderPage({ fitMode, backgroundColor, isVisible = true, - onClick, onError, }: ComicReaderPageProps) { // Check if this image is already preloaded to avoid showing loader @@ -124,24 +121,6 @@ export function ComicReaderPage({ onError?.(); }; - const handleClick = (event: React.MouseEvent) => { - if (!onClick) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const width = rect.width; - - // Divide into thirds: left, center, right - const third = width / 3; - if (x < third) { - onClick("left"); - } else if (x > 2 * third) { - onClick("right"); - } else { - onClick("center"); - } - }; - if (!isVisible) { return null; } @@ -161,11 +140,9 @@ export function ComicReaderPage({ display: "flex", alignItems: "center", justifyContent: "center", - cursor: onClick ? "pointer" : "default", userSelect: "none", position: "relative", }} - onClick={handleClick} > {hasError ? (
Failed to load page
diff --git a/web/src/components/reader/ContinuousScrollReader.tsx b/web/src/components/reader/ContinuousScrollReader.tsx index bc36c7e7..624556d6 100644 --- a/web/src/components/reader/ContinuousScrollReader.tsx +++ b/web/src/components/reader/ContinuousScrollReader.tsx @@ -439,7 +439,7 @@ export function ContinuousScrollReader({ if (totalPages === 0) { return ( -
+
This book has no pages
); @@ -451,7 +451,7 @@ export function ContinuousScrollReader({ data-testid="continuous-scroll-container" style={{ width: "100%", - height: "100vh", + height: "100dvh", overflow: "auto", backgroundColor: BACKGROUND_COLORS[backgroundColor], }} diff --git a/web/src/components/reader/DoublePageSpread.test.tsx b/web/src/components/reader/DoublePageSpread.test.tsx index a13d5fc2..bd708b8e 100644 --- a/web/src/components/reader/DoublePageSpread.test.tsx +++ b/web/src/components/reader/DoublePageSpread.test.tsx @@ -10,7 +10,6 @@ describe("DoublePageSpread", () => { ], fitMode: "screen" as const, backgroundColor: "black" as const, - readingDirection: "ltr" as const, }; beforeEach(() => { @@ -79,9 +78,7 @@ describe("DoublePageSpread", () => { describe("reading direction", () => { it("should display pages in the order provided (LTR)", () => { - renderWithProviders( - , - ); + renderWithProviders(); const images = screen.getAllByRole("img", { hidden: true }); // Pages are displayed in the order provided by parent @@ -104,7 +101,6 @@ describe("DoublePageSpread", () => { { pageNumber: 3, src: "/api/v1/books/book-123/pages/3" }, { pageNumber: 2, src: "/api/v1/books/book-123/pages/2" }, ], - readingDirection: "rtl" as const, }; renderWithProviders(); @@ -124,7 +120,6 @@ describe("DoublePageSpread", () => { const singlePageProps = { ...defaultProps, pages: [{ pageNumber: 5, src: "/api/v1/books/book-123/pages/5" }], - readingDirection: "rtl" as const, }; renderWithProviders(); @@ -137,129 +132,8 @@ describe("DoublePageSpread", () => { }); }); - // ========================================================================== - // Click zones - // ========================================================================== - - describe("click zones", () => { - it("should call onClick with 'left' when clicking left half in LTR mode", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByTestId("double-page-spread"); - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 1000, - top: 0, - height: 600, - right: 1000, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - fireEvent.click(container, { clientX: 200 }); // Left half (200 < 500) - expect(onClick).toHaveBeenCalledWith("left"); - }); - - it("should call onClick with 'right' when clicking right half in LTR mode", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByTestId("double-page-spread"); - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 1000, - top: 0, - height: 600, - right: 1000, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - fireEvent.click(container, { clientX: 800 }); // Right half (800 > 500) - expect(onClick).toHaveBeenCalledWith("right"); - }); - - it("should swap click zones for RTL mode - left click advances (right)", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByTestId("double-page-spread"); - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 1000, - top: 0, - height: 600, - right: 1000, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - // In RTL mode, clicking left half should trigger "right" (advance) - fireEvent.click(container, { clientX: 200 }); - expect(onClick).toHaveBeenCalledWith("right"); - }); - - it("should swap click zones for RTL mode - right click goes back (left)", () => { - const onClick = vi.fn(); - renderWithProviders( - , - ); - - const container = screen.getByTestId("double-page-spread"); - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - left: 0, - width: 1000, - top: 0, - height: 600, - right: 1000, - bottom: 600, - x: 0, - y: 0, - toJSON: () => {}, - }); - - // In RTL mode, clicking right half should trigger "left" (go back) - fireEvent.click(container, { clientX: 800 }); - expect(onClick).toHaveBeenCalledWith("left"); - }); - - it("should not call onClick when no handler provided", () => { - renderWithProviders(); - - const container = screen.getByTestId("double-page-spread"); - // Should not throw - fireEvent.click(container); - }); - }); + // Click-zone navigation moved out of DoublePageSpread into the shared + // `useTouchNav` hook (see useTouchNav.test.ts for zone-based tap coverage). // ========================================================================== // Background colors @@ -449,9 +323,7 @@ describe("DoublePageSpread", () => { }); it("should render page containers in correct order for RTL", () => { - renderWithProviders( - , - ); + renderWithProviders(); const pageContainers = [ screen.getByTestId("spread-page-3"), diff --git a/web/src/components/reader/DoublePageSpread.tsx b/web/src/components/reader/DoublePageSpread.tsx index 84aaa2ae..1c5e2018 100644 --- a/web/src/components/reader/DoublePageSpread.tsx +++ b/web/src/components/reader/DoublePageSpread.tsx @@ -4,7 +4,6 @@ import type { BackgroundColor, FitMode, PageOrientation, - ReadingDirection, } from "@/store/readerStore"; import { useReaderStore } from "@/store/readerStore"; import { detectPageOrientation } from "./utils/spreadCalculation"; @@ -22,12 +21,8 @@ interface DoublePageSpreadProps { fitMode: FitMode; /** Background color */ backgroundColor: BackgroundColor; - /** Reading direction (affects page order in display) */ - readingDirection: ReadingDirection; /** Whether this spread is currently visible */ isVisible?: boolean; - /** Click handler for navigation zones */ - onClick?: (zone: "left" | "right") => void; /** Callback when a page's dimensions are detected */ onPageOrientationDetected?: ( pageNumber: number, @@ -167,9 +162,7 @@ export function DoublePageSpread({ pages, fitMode, backgroundColor, - readingDirection, isVisible = true, - onClick, onPageOrientationDetected, }: DoublePageSpreadProps) { // Subscribe to preloadedImages changes @@ -239,26 +232,6 @@ export function DoublePageSpread({ } }, []); - const handleClick = (event: React.MouseEvent) => { - if (!onClick) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const width = rect.width; - - // For double-page mode, divide into halves: left half = prev, right half = next - // In RTL mode, this is reversed - const isLeftHalf = x < width / 2; - - if (readingDirection === "rtl") { - // RTL: left half advances (next), right half goes back (prev) - onClick(isLeftHalf ? "right" : "left"); - } else { - // LTR: left half goes back (prev), right half advances (next) - onClick(isLeftHalf ? "left" : "right"); - } - }; - if (!isVisible) { return null; } @@ -285,11 +258,9 @@ export function DoublePageSpread({ alignItems: "center", justifyContent: "center", gap: 0, - cursor: onClick ? "pointer" : "default", userSelect: "none", position: "relative", }} - onClick={handleClick} data-testid="double-page-spread" > {displayPages.map((page, index) => { diff --git a/web/src/components/reader/EpubReader.test.tsx b/web/src/components/reader/EpubReader.test.tsx index ffde0547..cb459dc5 100644 --- a/web/src/components/reader/EpubReader.test.tsx +++ b/web/src/components/reader/EpubReader.test.tsx @@ -2,6 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useReaderStore } from "@/store/readerStore"; import { renderWithProviders, screen } from "@/test/utils"; import { EpubReader } from "./EpubReader"; +import { useTouchNav } from "./hooks/useTouchNav"; + +// Mock useTouchNav so we can drive its callbacks directly in tests (R7-1). +// Returning a no-op ref keeps the production wiring code happy. +vi.mock("./hooks/useTouchNav", () => ({ + useTouchNav: vi.fn(() => ({ touchRef: vi.fn() })), +})); + +// Captures the per-event handlers `EpubReader` registers on the rendition, +// so tests can fire (e.g.) the "click" handler to verify R7-1 toolbar toggle. +const renditionHandlers: Record void> = {}; +// Captures hooks.content.register callbacks so R10-1 tests can drive the +// inside-iframe pointer hook with a fake `contents` document. +const contentHookCallbacks: Array<(contents: { document: Document }) => void> = + []; +// Stash the latest readerStyles ReactReader received so R7-3 tests can assert +// the side-arrow `display: none` override is applied on mobile viewports. +let lastReaderStyles: Record> | null = null; // Mock react-reader since it's a complex library that requires actual EPUB files vi.mock("react-reader", () => ({ @@ -11,8 +29,10 @@ vi.mock("react-reader", () => ({ location: _location, locationChanged: _locationChanged, getRendition, + readerStyles, showToc, }) => { + lastReaderStyles = readerStyles ?? null; // Simulate getting rendition on mount const mockRendition = { themes: { @@ -35,8 +55,21 @@ vi.mock("react-reader", () => ({ get: vi.fn(), }, }, - on: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + renditionHandlers[event] = handler; + }), + hooks: { + content: { + register: vi.fn( + (callback: (contents: { document: Document }) => void) => { + contentHookCallbacks.push(callback); + }, + ), + }, + }, display: vi.fn(), + next: vi.fn(), + prev: vi.fn(), }; // Call getRendition callback if provided @@ -144,6 +177,22 @@ const defaultProps = { describe("EpubReader", () => { beforeEach(() => { vi.clearAllMocks(); + for (const k of Object.keys(renditionHandlers)) { + delete renditionHandlers[k]; + } + contentHookCallbacks.length = 0; + lastReaderStyles = null; + // Default matchMedia: not mobile. Individual tests can override. + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); useReaderStore.setState({ settings: { ...defaultSettings }, ...defaultSessionState, @@ -339,6 +388,252 @@ describe("EpubReader", () => { }); }); + describe("mobile tap-to-toggle toolbar (R7-1)", () => { + it("wires useTouchNav with onTap that toggles the toolbar", () => { + renderWithProviders(); + + expect(useTouchNav).toHaveBeenCalled(); + const opts = vi.mocked(useTouchNav).mock.calls.at(-1)?.[0]; + expect(opts?.onTap).toBe(useReaderStore.getState().toggleToolbar); + + // Drive the captured onTap to verify it flips toolbarVisible + useReaderStore.setState({ toolbarVisible: true }); + opts?.onTap?.(); + expect(useReaderStore.getState().toolbarVisible).toBe(false); + opts?.onTap?.(); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("registers a content hook that wires pointer events on the iframe doc (R10-1)", async () => { + renderWithProviders(); + + // Rendition is wired asynchronously via setTimeout in the mock + await new Promise((r) => setTimeout(r, 0)); + + expect(contentHookCallbacks.length).toBeGreaterThan(0); + }); + }); + + describe("EPUB iframe pointer navigation (R10-1)", () => { + // Helpers for the R10-1 test suite. They build the same fake-iframe doc + // and dispatch pointer events against it so the inside-iframe hook (which + // can't see real epub.js iframes in JSDOM) gets exercised. + const dispatchPointerEvent = ( + doc: Document, + type: "pointerdown" | "pointerup" | "pointercancel", + x: number, + y: number, + init: { + pointerType?: "touch" | "mouse" | "pen"; + pointerId?: number; + isPrimary?: boolean; + button?: number; + timeStamp?: number; + target?: Element; + } = {}, + ) => { + const { + pointerType = "touch", + pointerId = 1, + isPrimary = true, + button = 0, + timeStamp = 0, + target, + } = init; + const event = new MouseEvent(type, { + clientX: x, + clientY: y, + button, + bubbles: true, + cancelable: true, + }) as MouseEvent & { + pointerId: number; + pointerType: string; + isPrimary: boolean; + }; + Object.defineProperty(event, "pointerId", { value: pointerId }); + Object.defineProperty(event, "pointerType", { value: pointerType }); + Object.defineProperty(event, "isPrimary", { value: isPrimary }); + Object.defineProperty(event, "timeStamp", { value: timeStamp }); + const dispatchTarget = target ?? doc.body; + dispatchTarget.dispatchEvent(event); + }; + + const mountAndGetIframeDoc = async () => { + renderWithProviders(); + await new Promise((r) => setTimeout(r, 0)); + expect(contentHookCallbacks.length).toBeGreaterThan(0); + + const fakeIframeDoc = document.implementation.createHTMLDocument("epub"); + // Drive every registered content callback so the hook attaches its + // pointer listeners to this fake document. + for (const cb of contentHookCallbacks) { + cb({ document: fakeIframeDoc }); + } + return fakeIframeDoc; + }; + + it("calls rendition.next on a left swipe inside the iframe (LTR)", async () => { + const doc = await mountAndGetIframeDoc(); + + useReaderStore.setState({ toolbarVisible: false }); + dispatchPointerEvent(doc, "pointerdown", 300, 200, { timeStamp: 0 }); + dispatchPointerEvent(doc, "pointerup", 100, 200, { timeStamp: 100 }); + + // Toolbar should not toggle from a swipe + expect(useReaderStore.getState().toolbarVisible).toBe(false); + }); + + it("calls toggleToolbar on a tap inside the iframe", async () => { + const doc = await mountAndGetIframeDoc(); + + useReaderStore.setState({ toolbarVisible: true }); + dispatchPointerEvent(doc, "pointerdown", 200, 200, { timeStamp: 0 }); + dispatchPointerEvent(doc, "pointerup", 201, 200, { timeStamp: 80 }); + expect(useReaderStore.getState().toolbarVisible).toBe(false); + + dispatchPointerEvent(doc, "pointerdown", 200, 200, { timeStamp: 100 }); + dispatchPointerEvent(doc, "pointerup", 200, 201, { timeStamp: 150 }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("routes iframe taps through tap zones (LTR)", async () => { + const doc = await mountAndGetIframeDoc(); + // The fake iframe doc is detached, so view-derived sizes are 0; stub + // documentElement.clientWidth/Height to give the hook a real geometry. + Object.defineProperty(doc.documentElement, "clientWidth", { + configurable: true, + value: 900, + }); + Object.defineProperty(doc.documentElement, "clientHeight", { + configurable: true, + value: 600, + }); + + const visibleBefore = useReaderStore.getState().toolbarVisible; + + // Left third tap → prev page (LTR). + dispatchPointerEvent(doc, "pointerdown", 100, 300, { timeStamp: 0 }); + dispatchPointerEvent(doc, "pointerup", 100, 300, { timeStamp: 50 }); + + // Right third tap → next page. + dispatchPointerEvent(doc, "pointerdown", 800, 300, { timeStamp: 100 }); + dispatchPointerEvent(doc, "pointerup", 800, 300, { timeStamp: 150 }); + + // Toolbar visibility is unchanged because both taps landed in nav zones. + expect(useReaderStore.getState().toolbarVisible).toBe(visibleBefore); + }); + + it("ignores pointer interactions starting on links and form controls", async () => { + const doc = await mountAndGetIframeDoc(); + + const link = doc.createElement("a"); + doc.body.appendChild(link); + const input = doc.createElement("input"); + doc.body.appendChild(input); + + useReaderStore.setState({ toolbarVisible: true }); + dispatchPointerEvent(doc, "pointerdown", 50, 50, { + target: link, + timeStamp: 0, + }); + dispatchPointerEvent(doc, "pointerup", 51, 51, { + target: link, + timeStamp: 50, + }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + + dispatchPointerEvent(doc, "pointerdown", 50, 50, { + target: input, + timeStamp: 100, + }); + dispatchPointerEvent(doc, "pointerup", 51, 51, { + target: input, + timeStamp: 150, + }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("ignores non-primary pointers (multi-touch)", async () => { + const doc = await mountAndGetIframeDoc(); + + useReaderStore.setState({ toolbarVisible: true }); + dispatchPointerEvent(doc, "pointerdown", 200, 200, { + isPrimary: false, + pointerId: 2, + timeStamp: 0, + }); + dispatchPointerEvent(doc, "pointerup", 200, 200, { + isPrimary: false, + pointerId: 2, + timeStamp: 50, + }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("aborts when pointercancel fires before pointerup", async () => { + const doc = await mountAndGetIframeDoc(); + + useReaderStore.setState({ toolbarVisible: true }); + dispatchPointerEvent(doc, "pointerdown", 300, 200, { timeStamp: 0 }); + dispatchPointerEvent(doc, "pointercancel", 200, 200, { timeStamp: 50 }); + dispatchPointerEvent(doc, "pointerup", 100, 200, { timeStamp: 100 }); + // Cancel cleared the gesture; pointerup should not register as a swipe + // or a tap. + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + }); + + describe("mobile chapter pill (U2)", () => { + function forceMobileViewport() { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + } + + it("does not render the chapter pill until the TOC and location are known", () => { + forceMobileViewport(); + renderWithProviders(); + + // Initial mount: TOC is empty in the mock; chapter pill should not appear. + expect( + screen.queryByLabelText("Open table of contents"), + ).not.toBeInTheDocument(); + }); + }); + + describe("mobile reader styles (R7-3)", () => { + it("does not hide side arrows on non-mobile viewports", () => { + renderWithProviders(); + + expect(lastReaderStyles?.arrow?.display).toBeUndefined(); + }); + + it("hides react-reader side arrows on mobile viewports", () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + renderWithProviders(); + + expect(lastReaderStyles?.arrow?.display).toBe("none"); + }); + }); + describe("background color", () => { it("should apply theme-based background color", () => { renderWithProviders(); diff --git a/web/src/components/reader/EpubReader.tsx b/web/src/components/reader/EpubReader.tsx index 12b696d4..c58de303 100644 --- a/web/src/components/reader/EpubReader.tsx +++ b/web/src/components/reader/EpubReader.tsx @@ -1,5 +1,20 @@ -import { ActionIcon, Box, Center, Group, Loader, Tooltip } from "@mantine/core"; -import { IconPlayerSkipBack, IconPlayerSkipForward } from "@tabler/icons-react"; +import { + ActionIcon, + Box, + Center, + Group, + Loader, + Menu, + Tooltip, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { + IconBookmark, + IconList, + IconPlayerSkipBack, + IconPlayerSkipForward, + IconSearch, +} from "@tabler/icons-react"; import type { Location, NavItem, Rendition } from "epubjs"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -9,18 +24,26 @@ import { } from "react-reader"; import { booksApi } from "@/api/books"; -import { useReaderStore } from "@/store/readerStore"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; +import { + selectEffectiveReadingDirection, + useReaderStore, +} from "@/store/readerStore"; import { BoundaryNotification } from "./BoundaryNotification"; import { EpubBookmarks } from "./EpubBookmarks"; import { EpubReaderSettings } from "./EpubReaderSettings"; import { EpubSearch, type SearchResult } from "./EpubSearch"; import { EpubTableOfContents } from "./EpubTableOfContents"; +import { classifySwipe, classifyTapZone } from "./hooks/swipeGesture"; import { useAdjacentBooks } from "./hooks/useAdjacentBooks"; import { useBoundaryNotification } from "./hooks/useBoundaryNotification"; import { useEpubBookmarks } from "./hooks/useEpubBookmarks"; import { useEpubProgress } from "./hooks/useEpubProgress"; import { useSeriesNavigation } from "./hooks/useSeriesNavigation"; +import { useTouchNav } from "./hooks/useTouchNav"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; +import { ReaderFirstRunHint } from "./ReaderFirstRunHint"; import { ReaderToolbar } from "./ReaderToolbar"; // EPUB theme definitions @@ -102,8 +125,16 @@ const EPUB_FONT_FAMILIES = { /** * Generate ReactReader container styles based on the current theme. * This ensures the reader container background matches the EPUB content theme. + * + * On mobile (`isMobile = true`), the side-overlay chevron arrows are hidden; + * touch users rely on the tap-to-toolbar (R7-1) and swipe gestures for nav. + * The arrow buttons are inline-styled in react-reader (no class names to + * target), so the only reliable hook is the `readerStyles.arrow` override. */ -function getReaderStyles(theme: EpubTheme): IReactReaderStyle { +function getReaderStyles( + theme: EpubTheme, + isMobile: boolean, +): IReactReaderStyle { const themeColors = EPUB_THEMES[theme] ?? EPUB_THEMES.light; const isDark = theme === "dark" || theme === "slate"; @@ -117,6 +148,7 @@ function getReaderStyles(theme: EpubTheme): IReactReaderStyle { arrow: { ...ReactReaderStyle.arrow, color: isDark ? "#e0e0e0" : "#333", + ...(isMobile ? { display: "none" } : {}), }, arrowHover: { ...ReactReaderStyle.arrowHover, @@ -278,12 +310,21 @@ export function EpubReader({ epubLineHeightRef.current = epubLineHeight; epubMarginRef.current = epubMargin; - // Memoize reader styles based on theme - const readerStyles = useMemo(() => getReaderStyles(epubTheme), [epubTheme]); + // Detect mobile viewport for touch-friendly tweaks (R7-1/R7-3): + // hide the side-arrow chevrons that overlap text below `xs`, and wire + // tap-to-toggle on the outer container as a fallback for the iframe boundary. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + + // Memoize reader styles based on theme + viewport (mobile hides side arrows) + const readerStyles = useMemo( + () => getReaderStyles(epubTheme, isMobile), + [epubTheme, isMobile], + ); // Reader store state const toolbarVisible = useReaderStore((state) => state.toolbarVisible); const isFullscreen = useReaderStore((state) => state.isFullscreen); + const adjacentBooks = useReaderStore((state) => state.adjacentBooks); const autoHideToolbar = useReaderStore( (state) => state.settings.autoHideToolbar, ); @@ -296,6 +337,21 @@ export function EpubReader({ const setFullscreen = useReaderStore((state) => state.setFullscreen); const toggleToolbar = useReaderStore((state) => state.toggleToolbar); + // Stable ref for toggleToolbar so the rendition pointer hook installed + // inside the (stable) `handleGetRendition` callback always sees the latest + // action without re-creating the rendition setup. + const toggleToolbarRef = useRef(toggleToolbar); + toggleToolbarRef.current = toggleToolbar; + + // Reading direction needs to be read from inside the iframe pointer hook, + // which is installed once. Mirror the latest value into a ref so the hook + // always reads the user's current preference. + const effectiveReadingDirection = useReaderStore( + selectEffectiveReadingDirection, + ); + const readingDirectionRef = useRef(effectiveReadingDirection); + readingDirectionRef.current = effectiveReadingDirection; + // Generate EPUB file URL const epubUrl = `/api/v1/books/${bookId}/file`; @@ -602,6 +658,109 @@ export function EpubReader({ : location.start.href; saveLocationRef.current(cfi, percentage, fullHref); }); + + // R10-1: bind tap + swipe pointer events *inside* the iframe document. + // The outer-container `useTouchNav` listeners can't see touches that land + // inside the epub.js iframe — they don't bubble across the iframe + // boundary. Registering on `rendition.hooks.content` fires once per + // chapter document the renderer mounts, giving us a fresh + // `contents.document` each time. We share `classifySwipe` with the outer + // hook so tap/swipe semantics stay consistent. + rendition.hooks.content.register((contents: { document: Document }) => { + const doc = contents.document; + if (!doc) return; + + let pointerId: number | null = null; + let startX = 0; + let startY = 0; + let startTime = 0; + + const onPointerDown = (event: PointerEvent) => { + if (!event.isPrimary) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + const target = event.target as Element | null; + // Don't intercept clicks on interactive elements — epub.js's own link + // handler needs to see them, and form controls need their default + // behavior. + if (target?.closest("a, button, input, textarea, select, label")) { + pointerId = null; + return; + } + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + startTime = event.timeStamp || Date.now(); + }; + + const onPointerUp = (event: PointerEvent) => { + if (pointerId === null || event.pointerId !== pointerId) return; + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + const deltaTime = (event.timeStamp || Date.now()) - startTime; + pointerId = null; + + // Prefer the EPUB's metadata-declared direction (e.g. manga marked + // RTL by the publisher) and fall back to the user's reader setting. + // `direction` isn't in epub.js's type definitions but is present at + // runtime when the OPF declares `page-progression-direction`. + const metadataDirection = ( + renditionRef.current?.book.packaging?.metadata as + | { direction?: string } + | undefined + )?.direction; + const readingDirection = + metadataDirection === "rtl" ? "rtl" : readingDirectionRef.current; + + const gesture = classifySwipe(deltaX, deltaY, deltaTime, { + readingDirection, + }); + + switch (gesture) { + case "tap": { + // Classify by zone so a center tap reveals the toolbar while + // edge taps page forward/back, matching the outer-container + // useTouchNav behavior (left/center/right for horizontal flow, + // top/center/bottom for TTB). Use the iframe's viewport because + // pointer coords are relative to the iframe document. + const view = doc.defaultView; + const width = view?.innerWidth ?? doc.documentElement.clientWidth; + const height = + view?.innerHeight ?? doc.documentElement.clientHeight; + const zone = classifyTapZone( + event.clientX, + event.clientY, + width, + height, + { readingDirection }, + ); + if (zone === "center") { + toggleToolbarRef.current(); + } else if (zone === "next") { + renditionRef.current?.next(); + } else { + renditionRef.current?.prev(); + } + break; + } + case "next": + renditionRef.current?.next(); + break; + case "prev": + renditionRef.current?.prev(); + break; + case "none": + break; + } + }; + + const onPointerCancel = (event: PointerEvent) => { + if (pointerId === event.pointerId) pointerId = null; + }; + + doc.addEventListener("pointerdown", onPointerDown); + doc.addEventListener("pointerup", onPointerUp); + doc.addEventListener("pointercancel", onPointerCancel); + }); }, []); // Handle TOC navigation @@ -730,6 +889,48 @@ export function EpubReader({ renditionRef.current?.display(cfi); }, []); + // R7-1: tap-to-toggle toolbar on the outer container. Most touches happen + // inside the epub.js iframe and never reach these listeners (handled by + // the rendition `click` handler in `handleGetRendition`); this covers the + // margin areas above/below the iframe and lets swipes here drive page nav. + // Listeners are passive so they don't interfere with epub.js's own gestures. + const handleNextPage = useCallback(() => { + renditionRef.current?.next(); + }, []); + const handlePrevPage = useCallback(() => { + renditionRef.current?.prev(); + }, []); + + // U2: Compute current chapter index against the top-level TOC for the + // mobile bottom bar's chapter pill. Matches the same fuzzy href comparison + // that `findChapterTitle` uses inside the relocated handler (a TOC entry's + // href can include a fragment, so we strip it before comparing). Returns + // `null` until both the TOC and the current location are known. + const epubChapter = useMemo<{ + currentIndex: number; + total: number; + } | null>(() => { + if (toc.length === 0 || !currentHref) return null; + const index = toc.findIndex((item) => { + const itemHref = item.href.split("#")[0]; + return ( + item.href === currentHref || + currentHref === itemHref || + currentHref.startsWith(itemHref) + ); + }); + // Clamp to 1 so the pill never shows "Ch 0 / N" while the location is + // resolving between chapter boundaries. + return { currentIndex: Math.max(1, index + 1), total: toc.length }; + }, [toc, currentHref]); + + const { touchRef } = useTouchNav({ + enabled: !settingsOpened && !tocOpened && !bookmarksOpened && !searchOpened, + onNextPage: handleNextPage, + onPrevPage: handlePrevPage, + onTap: toggleToolbar, + }); + // Keyboard navigation // Note: Arrow key navigation is handled by ReactReader/epub.js internally via the iframe, // so we only handle other shortcuts here to avoid double navigation. @@ -867,13 +1068,24 @@ export function EpubReader({ return theme.body.background; }; + // Combined ref callback: keep `containerRef` for fullscreen handling and + // attach the useTouchNav listeners (R7-1) on the same outer container. + const setContainerRef = useCallback( + (element: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject).current = + element; + touchRef(element); + }, + [touchRef], + ); + return ( setSettingsOpened(true)} showPageNavigation={false} + prevBook={adjacentBooks?.prev} + nextBook={adjacentBooks?.next} + onPrevBook={canGoPrevBook ? goToPrevBook : undefined} + onNextBook={canGoNextBook ? goToNextBook : undefined} leftActions={ } + mobileMenuItems={ + <> + + EPUB + } + onClick={() => setTocOpened(true)} + > + Table of contents + + } + onClick={() => setBookmarksOpened(true)} + > + Bookmarks + + } + onClick={() => setSearchOpened(true)} + > + Search + + + } /> + {/* U2: Phone-only bottom bar with a tappable chapter pill (opens TOC). + EPUB pagination is reflowable, so we render the chapter-variant + layout (no slider, just prev / chapter / next). */} + {epubChapter && ( + setTocOpened(true), + }} + /> + )} + + {/* First-run hint: teaches phone users that center-tap reveals the + toolbar. Once per session across all reader formats. */} + + {/* Boundary notification for series navigation */} ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +const DEFAULT_SETTINGS = { + fitMode: "screen" as const, + webtoonFitMode: "width" as const, + pageLayout: "single" as const, + readingDirection: "ltr" as const, + backgroundColor: "black" as const, + pdfMode: "streaming" as const, + pdfSpreadMode: "single" as const, + pdfContinuousScroll: false, + autoHideToolbar: true, + toolbarHideDelay: 3000, + epubTheme: "light" as const, + epubFontSize: 100, + epubFontFamily: "default" as const, + epubLineHeight: 150, + epubMargin: 10, + epubSpread: "auto" as const, + preloadPages: 1, + doublePageShowWideAlone: true, + doublePageStartOnOdd: true, + pageTransition: "slide" as const, + transitionDuration: 200, + webtoonSidePadding: 0, + webtoonPageGap: 0, + autoAdvanceToNextBook: false, +}; + +function resetStore(overrides: Record = {}) { + useReaderStore.setState({ + settings: DEFAULT_SETTINGS, + currentPage: 5, + totalPages: 20, + isLoading: false, + toolbarVisible: true, + isFullscreen: false, + currentBookId: "book-123", + readingDirectionOverride: null, + adjacentBooks: null, + boundaryState: "none", + pageOrientations: {}, + lastNavigationDirection: null, + preloadedImages: new Set(), + ...overrides, + }); +} + +describe("MobileReaderBottomBar", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + describe("desktop viewport", () => { + beforeEach(() => { + forceDesktopViewport(); + }); + + it("renders nothing above the xs breakpoint", () => { + renderWithProviders(); + + // Top-bar slider is the only one in desktop ReaderToolbar; this + // component should bail out entirely so it doesn't duplicate it. + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + expect(screen.queryByText("5 / 20")).not.toBeInTheDocument(); + }); + }); + + describe("phone viewport", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("renders the page counter and slider", () => { + renderWithProviders(); + + expect(screen.getByText("5 / 20")).toBeInTheDocument(); + expect(screen.getByRole("slider")).toBeInTheDocument(); + }); + + it("renders nothing when totalPages is 0", () => { + resetStore({ totalPages: 0, currentPage: 0 }); + renderWithProviders(); + + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + }); + + it("calls the provided onNextPage when right chevron is tapped", () => { + const onNextPage = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Next page")); + + expect(onNextPage).toHaveBeenCalledTimes(1); + }); + + it("calls the provided onPrevPage when left chevron is tapped", () => { + const onPrevPage = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Previous page")); + + expect(onPrevPage).toHaveBeenCalledTimes(1); + }); + + it("falls back to the store actions when no handlers are provided", () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Next page")); + + // Store's nextPage clamps at totalPages, so currentPage 5 → 6. + expect(useReaderStore.getState().currentPage).toBe(6); + }); + + it("disables the prev chevron on page 1", () => { + resetStore({ currentPage: 1 }); + renderWithProviders(); + + expect(screen.getByLabelText("Previous page")).toBeDisabled(); + }); + + it("disables the next chevron on the last page", () => { + resetStore({ currentPage: 20 }); + renderWithProviders(); + + expect(screen.getByLabelText("Next page")).toBeDisabled(); + }); + + it("swaps prev/next semantics in RTL reading mode", () => { + resetStore({ + settings: { ...DEFAULT_SETTINGS, readingDirection: "rtl" }, + }); + const onNextPage = vi.fn(); + const onPrevPage = vi.fn(); + renderWithProviders( + , + ); + + // In RTL the visual "previous page" chevron is on the right, so the + // left chevron should advance to the next page. + fireEvent.click(screen.getByLabelText("Next page")); + expect(onNextPage).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByLabelText("Previous page")); + expect(onPrevPage).toHaveBeenCalledTimes(1); + }); + + it("opens the jump-to-page modal when the page counter is tapped", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + + await waitFor(() => { + // Modal renders a heading with the title "Go to page". + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + }); + + it("jumps to the page entered in the modal when Go is pressed", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "12" } }); + fireEvent.click(screen.getByRole("button", { name: "Go" })); + + expect(useReaderStore.getState().currentPage).toBe(12); + }); + + describe("EPUB chapter variant (U2)", () => { + it("renders a chapter pill instead of the page-counter slider", () => { + // EPUB doesn't drive the reader store's currentPage/totalPages. + resetStore({ totalPages: 0, currentPage: 0 }); + renderWithProviders( + , + ); + + expect(screen.getByText("Ch 3 / 12")).toBeInTheDocument(); + // No slider in EPUB layout (pagination is reflowable). + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + // No page-jump button either. + expect(screen.queryByLabelText("Jump to page")).not.toBeInTheDocument(); + }); + + it("opens the TOC drawer when the chapter pill is tapped", () => { + resetStore({ totalPages: 0, currentPage: 0 }); + const onTap = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Open table of contents")); + + expect(onTap).toHaveBeenCalledTimes(1); + }); + + it("still wires prev/next chevrons in EPUB layout", () => { + resetStore({ totalPages: 0, currentPage: 0 }); + const onPrevPage = vi.fn(); + const onNextPage = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Previous page")); + fireEvent.click(screen.getByLabelText("Next page")); + + expect(onPrevPage).toHaveBeenCalledTimes(1); + expect(onNextPage).toHaveBeenCalledTimes(1); + }); + }); + + it("clamps the jump value to the valid page range", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + + const input = screen.getByRole("textbox"); + // Try to jump way past the end of the book. + fireEvent.change(input, { target: { value: "999" } }); + fireEvent.click(screen.getByRole("button", { name: "Go" })); + + expect(useReaderStore.getState().currentPage).toBe(20); + }); + }); +}); diff --git a/web/src/components/reader/MobileReaderBottomBar.tsx b/web/src/components/reader/MobileReaderBottomBar.tsx new file mode 100644 index 00000000..eaf1def8 --- /dev/null +++ b/web/src/components/reader/MobileReaderBottomBar.tsx @@ -0,0 +1,321 @@ +import { + ActionIcon, + Box, + Button, + Group, + Modal, + NumberInput, + Slider, + Text, + Transition, +} from "@mantine/core"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { + IconChevronLeft, + IconChevronRight, + IconKeyboardShow, + IconList, +} from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { + selectEffectiveReadingDirection, + selectProgressPercent, + useReaderStore, +} from "@/store/readerStore"; + +/** + * Optional chapter context for EPUB reflowable books. When provided, the + * bar renders an EPUB-specific layout (no slider; a tappable chapter pill + * in the center that opens the TOC drawer) instead of the default + * page-counter + slider layout used by CBZ and PDF. + * + * EPUB pagination is reflowable, so a 1..N page slider isn't meaningful; + * the TOC is the natural mobile navigation surface. The chapter index is + * computed by the parent reader from `rendition.location.start.href` matched + * against the top-level TOC array. + */ +export interface MobileBottomBarEpubChapter { + /** 1-based index of the current chapter in the top-level TOC. */ + currentIndex: number; + /** Total number of top-level TOC entries. */ + total: number; + /** Tap handler. Opens the TOC drawer. */ + onTap: () => void; +} + +interface MobileReaderBottomBarProps { + /** Whether the bar is visible (mirrors the toolbar's visibility). */ + visible: boolean; + /** + * Optional custom prev/next handlers. When omitted we fall back to the + * reader store actions, matching the same default used by `ReaderToolbar`. + * Comic / PDF readers pass their spread- or boundary-aware variants here. + */ + onPrevPage?: () => void; + onNextPage?: () => void; + /** + * When set, the bar switches to its EPUB layout: chapter pill (instead of + * page counter) and no slider. See `MobileBottomBarEpubChapter`. + */ + epubChapter?: MobileBottomBarEpubChapter; +} + +/** + * Bottom navigation bar shown below the `xs` breakpoint (phones). + * + * The desktop `ReaderToolbar` packs nine controls into a single row plus a + * full-width slider beneath, which overflows on a 390px viewport. On phones + * the toolbar drops the slider and most controls; this bar restores them in + * the standard mobile-reader pattern: prev / page-count tap / next / slider. + * + * Tap on the page-count opens a "Go to page" modal with a numeric input — + * faster than dragging the slider when jumping a long distance. + */ +export function MobileReaderBottomBar({ + visible, + onPrevPage, + onNextPage, + epubChapter, +}: MobileReaderBottomBarProps) { + const currentPage = useReaderStore((state) => state.currentPage); + const totalPages = useReaderStore((state) => state.totalPages); + const progressPercent = useReaderStore(selectProgressPercent); + const readingDirection = useReaderStore(selectEffectiveReadingDirection); + const setPage = useReaderStore((state) => state.setPage); + const storeNextPage = useReaderStore((state) => state.nextPage); + const storePrevPage = useReaderStore((state) => state.prevPage); + + const handleNext = onNextPage ?? storeNextPage; + const handlePrev = onPrevPage ?? storePrevPage; + + // EPUB layout uses page-style prev/next (epub.js viewports) and ignores + // the store's currentPage entirely (reflowable books don't have one). + const isEpub = epubChapter !== undefined; + + // Chevrons mirror the reading direction so the visual cue matches the + // direction of progression (RTL keeps "next" on the left). + const isRtl = readingDirection === "rtl"; + const onLeftClick = isRtl ? handleNext : handlePrev; + const onRightClick = isRtl ? handlePrev : handleNext; + // EPUB can't easily report "first/last viewport" without tracking it in + // the parent. Leave chevrons enabled and rely on epub.js to no-op at the + // boundaries. + const leftDisabled = isEpub + ? false + : isRtl + ? currentPage >= totalPages + : currentPage <= 1; + const rightDisabled = isEpub + ? false + : isRtl + ? currentPage <= 1 + : currentPage >= totalPages; + + const [jumpOpened, jumpHandlers] = useDisclosure(false); + const [jumpValue, setJumpValue] = useState(currentPage); + + // Reset the modal input each time it opens so it always reflects the + // current page rather than a stale value from a previous open. + useEffect(() => { + if (jumpOpened) { + setJumpValue(currentPage); + } + }, [jumpOpened, currentPage]); + + const submitJump = () => { + const target = Math.max(1, Math.min(totalPages, Math.round(jumpValue))); + setPage(target); + jumpHandlers.close(); + }; + + // Phone-only: above the xs breakpoint the desktop `ReaderToolbar` already + // shows the slider, so this bar would be duplicative. xs = 30.125em. + const isMobile = useMediaQuery("(max-width: 30.0625em)") ?? false; + + // For CBZ/PDF, bail if there's no page data. EPUB doesn't drive the store, + // so totalPages will be 0; but if we have chapter context, we should still + // render the bar. + if (!isMobile) { + return null; + } + if (!isEpub && totalPages <= 0) { + return null; + } + + return ( + <> + + {(styles) => ( + + + + + + + + {isEpub ? ( + // EPUB layout: centered chapter pill, tap → TOC drawer. No + // slider because reflowable EPUB pages don't form a discrete + // 1..N sequence; the TOC is the right nav surface. + + + + ) : ( + + + + setPage(isRtl ? totalPages + 1 - val : val) + } + onChangeEnd={() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }} + size="md" + style={{ + flex: 1, + minWidth: 0, + transform: isRtl ? "scaleX(-1)" : "none", + }} + label={(value) => `Page ${value}`} + styles={{ + track: { + backgroundColor: "var(--mantine-color-dark-4)", + }, + bar: { + backgroundColor: "var(--mantine-color-blue-6)", + }, + thumb: { + backgroundColor: "var(--mantine-color-blue-6)", + borderColor: "var(--mantine-color-blue-6)", + }, + label: { + transform: isRtl ? "scaleX(-1)" : "none", + }, + }} + /> + + {progressPercent}% + + + )} + + + + + + + + )} + + + + + setJumpValue(typeof val === "number" ? val : Number(val) || 1) + } + min={1} + max={totalPages} + autoFocus + data-autofocus + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitJump(); + } + }} + /> + + Page 1–{totalPages} + + + + + + + + ); +} diff --git a/web/src/components/reader/PdfContinuousScrollReader.tsx b/web/src/components/reader/PdfContinuousScrollReader.tsx index 0f08a32f..eb08cc19 100644 --- a/web/src/components/reader/PdfContinuousScrollReader.tsx +++ b/web/src/components/reader/PdfContinuousScrollReader.tsx @@ -336,7 +336,7 @@ export function PdfContinuousScrollReader({ if (totalPages === 0) { return ( -
+
This PDF has no pages
); @@ -362,7 +362,7 @@ export function PdfContinuousScrollReader({ style={{ width: "100%", height: "100%", - minHeight: "calc(100vh - 128px)", + minHeight: "calc(100dvh - 128px)", backgroundColor: "transparent", }} > @@ -376,7 +376,7 @@ export function PdfContinuousScrollReader({ style={{ width: "100%", height: "100%", - minHeight: "calc(100vh - 128px)", + minHeight: "calc(100dvh - 128px)", backgroundColor: "transparent", }} > diff --git a/web/src/components/reader/PdfReader.test.tsx b/web/src/components/reader/PdfReader.test.tsx index b6081780..daaadfe2 100644 --- a/web/src/components/reader/PdfReader.test.tsx +++ b/web/src/components/reader/PdfReader.test.tsx @@ -97,10 +97,25 @@ describe("PdfReader", () => { onClose: vi.fn(), }; + const setMatchMedia = (matches: boolean) => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + }; + beforeEach(() => { vi.clearAllMocks(); // Reset store state useReaderStore.getState().resetSession(); + // Default to non-mobile viewport; mobile-specific tests override. + setMatchMedia(false); // Setup ResizeObserver mock (class-based for vitest v4 compatibility) global.ResizeObserver = class MockResizeObserver { @@ -210,6 +225,37 @@ describe("PdfReader", () => { }); }); + describe("mobile default zoom (R7-2)", () => { + it("defaults to fit-page on non-mobile viewports", async () => { + // Default beforeEach sets matchMedia matches=false (non-mobile) + renderWithProviders(); + + await waitFor(() => { + const page = screen.getByTestId("pdf-page"); + const scale = Number(page.getAttribute("data-scale")); + // fit-page is height-constrained for a 612x792 page in an 800x600 + // container (after toolbar + padding), producing scale ~0.63. + expect(scale).toBeGreaterThan(0); + expect(scale).toBeLessThan(1); + }); + }); + + it("defaults to fit-width on mobile viewports", async () => { + setMatchMedia(true); + + renderWithProviders(); + + await waitFor(() => { + const page = screen.getByTestId("pdf-page"); + const scale = Number(page.getAttribute("data-scale")); + // fit-width uses the available width only (~1.24 for a 612-wide page + // in an 800-wide container after padding) — strictly larger than the + // fit-page result above, confirming the mobile default kicked in. + expect(scale).toBeGreaterThan(1); + }); + }); + }); + describe("click zones", () => { it("should navigate on left zone click", async () => { // Validate hook availability for click zone navigation diff --git a/web/src/components/reader/PdfReader.tsx b/web/src/components/reader/PdfReader.tsx index bd28abf2..5afcfdf7 100644 --- a/web/src/components/reader/PdfReader.tsx +++ b/web/src/components/reader/PdfReader.tsx @@ -1,5 +1,5 @@ import { Box, Center, Loader, Text, TextInput } from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; +import { useDebouncedValue, useMediaQuery } from "@mantine/hooks"; import { IconSearch, IconX } from "@tabler/icons-react"; import { type CSSProperties, @@ -11,6 +11,7 @@ import { } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import { booksApi } from "@/api/books"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { useReaderStore } from "@/store/readerStore"; import { BoundaryNotification } from "./BoundaryNotification"; import { @@ -21,8 +22,10 @@ import { useSeriesNavigation, useTouchNav, } from "./hooks"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; import { PdfContinuousScrollReader } from "./PdfContinuousScrollReader"; import { PdfReaderSettings } from "./PdfReaderSettings"; +import { ReaderFirstRunHint } from "./ReaderFirstRunHint"; import { ReaderToolbar } from "./ReaderToolbar"; // Import CSS for text layer and annotation layer @@ -104,8 +107,18 @@ export function PdfReader({ height: number; } | null>(null); - // PDF zoom state (local, not in global store since it's PDF-specific) - const [zoomLevel, setZoomLevel] = useState("fit-page"); + // R7-2: on a phone-sized viewport, `fit-page` produces an unreadably small + // page (portrait PDF scaled to a portrait viewport — text near ~33% width). + // Default to `fit-width` on first render below `xs`; persisted per-book + // choices would still win once we surface them, but zoom is currently local. + // `getInitialValueInEffect: false` makes useMediaQuery match synchronously + // on first render so the useState initializer below sees the real viewport. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY, false, { + getInitialValueInEffect: false, + }); + const [zoomLevel, setZoomLevel] = useState(() => + isMobile ? "fit-width" : "fit-page", + ); // Cycle through PDF zoom levels (for toolbar fit button) const cyclePdfZoom = useCallback(() => { @@ -510,27 +523,6 @@ export function PdfReader({ [pdfPageDimensions], ); - // Page click handler - const handlePageClick = useCallback( - (e: React.MouseEvent) => { - const rect = pageContainerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const width = rect.width; - const relativeX = x / width; - - if (relativeX < 0.3) { - handlePrevPage(); - } else if (relativeX > 0.7) { - handleNextPage(); - } else { - toggleToolbar(); - } - }, - [handlePrevPage, handleNextPage, toggleToolbar], - ); - // Sync URL query parameter with current page useEffect(() => { if (currentPage > 0 && initializedBookIdRef.current !== null) { @@ -646,7 +638,7 @@ export function PdfReader({ const containerStyle: CSSProperties = useMemo( () => ({ width: "100vw", - height: "100vh", + height: "100dvh", position: "relative", overflow: "hidden", backgroundColor: bgColor, @@ -676,7 +668,7 @@ export function PdfReader({ if (progressLoading && numPages === 0) { return (
@@ -703,6 +695,20 @@ export function PdfReader({ onCycleFitMode={cyclePdfZoom} /> + {/* Phone-only bottom navigation. Hidden in continuous scroll mode + where the page-counter / slider don't apply (user scrolls). */} + {!pdfContinuousScroll && ( + + )} + + {/* First-run hint: teaches phone users that center-tap reveals the + toolbar. Once per session across all reader formats. */} + + {/* Boundary notification */} setSearchText(e.target.value)} - style={{ width: 300 }} + style={{ width: "min(300px, calc(100vw - 32px))" }} autoFocus onKeyDown={(e) => { if (e.key === "Escape") { @@ -777,8 +783,16 @@ export function PdfReader({ ) : ( {pageError ? (
@@ -793,7 +807,7 @@ export function PdfReader({
@@ -806,7 +820,7 @@ export function PdfReader({
diff --git a/web/src/components/reader/ReaderFirstRunHint.test.tsx b/web/src/components/reader/ReaderFirstRunHint.test.tsx new file mode 100644 index 00000000..4708580e --- /dev/null +++ b/web/src/components/reader/ReaderFirstRunHint.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, renderWithProviders, screen, waitFor } from "@/test/utils"; +import { + __resetReaderFirstRunHintForTests, + ReaderFirstRunHint, +} from "./ReaderFirstRunHint"; + +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe("ReaderFirstRunHint", () => { + beforeEach(() => { + __resetReaderFirstRunHintForTests(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows on first mount on a phone viewport", () => { + forceMobileViewport(); + renderWithProviders(); + + expect( + screen.getByText(/tap the center to show controls/i), + ).toBeInTheDocument(); + }); + + it("does not show on the next mount in the same session", () => { + forceMobileViewport(); + const { unmount } = renderWithProviders(); + expect( + screen.getByText(/tap the center to show controls/i), + ).toBeInTheDocument(); + unmount(); + + renderWithProviders(); + expect( + screen.queryByText(/tap the center to show controls/i), + ).not.toBeInTheDocument(); + }); + + it("does not show on desktop viewports", () => { + forceDesktopViewport(); + renderWithProviders(); + + expect( + screen.queryByText(/tap the center to show controls/i), + ).not.toBeInTheDocument(); + }); + + it("dismisses when the hint is clicked", async () => { + forceMobileViewport(); + vi.useRealTimers(); + renderWithProviders(); + + fireEvent.click( + screen.getByRole("button", { name: /dismiss reader hint/i }), + ); + + // Mantine `Transition` plays a fade-out, then removes the node. Use + // waitFor (with real timers) rather than fake-timer advancement because + // the fade is driven by requestAnimationFrame, which doesn't move under + // vi.advanceTimersByTime in jsdom. + await waitFor(() => { + expect( + screen.queryByText(/tap the center to show controls/i), + ).not.toBeInTheDocument(); + }); + }); + + it("schedules an auto-dismiss timer on mount", () => { + forceMobileViewport(); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + renderWithProviders(); + + // useEffect schedules a setTimeout with the auto-hide delay. We can't + // reliably observe the unmount under jsdom because Mantine's Transition + // exit is driven by requestAnimationFrame, which doesn't advance with + // vi.advanceTimersByTime. Asserting the schedule is sufficient; the + // dismiss path itself is covered by the click test above. + const found = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 4000); + expect(found).toBeDefined(); + }); + + it("respects the enabled prop", () => { + forceMobileViewport(); + renderWithProviders(); + + expect( + screen.queryByText(/tap the center to show controls/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/reader/ReaderFirstRunHint.tsx b/web/src/components/reader/ReaderFirstRunHint.tsx new file mode 100644 index 00000000..ef7eede6 --- /dev/null +++ b/web/src/components/reader/ReaderFirstRunHint.tsx @@ -0,0 +1,139 @@ +import { Box, Group, Text, Transition } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { IconHandFinger } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; + +const STORAGE_KEY = "codex:reader-hint-shown"; +const AUTO_HIDE_MS = 4000; + +/** + * Returns true once the hint has been shown in this session. Reads + * sessionStorage on each call; safe under SSR / private mode where the API + * can throw. + */ +function hasBeenShown(): boolean { + try { + return sessionStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +/** + * Mark the hint as shown for the remainder of this session. + */ +function markShown(): void { + try { + sessionStorage.setItem(STORAGE_KEY, "1"); + } catch { + // Ignore storage errors (private mode, quota, etc.) + } +} + +/** + * Test-only: reset the session flag so the hint shows again. Used by tests + * that need to verify first-run vs. subsequent-mount behavior. + */ +export function __resetReaderFirstRunHintForTests(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore + } +} + +interface ReaderFirstRunHintProps { + /** + * When false, the hint won't render even on first visit. Readers use this + * to suppress the hint while loading or while another overlay is showing. + */ + enabled?: boolean; +} + +/** + * One-time mobile reader hint: "Tap the center to show controls." + * + * The auto-hiding toolbar is hidden by default 3s after mount, leaving a + * first-time mobile user with no obvious way to bring it back (CBZ/PDF tap + * zones split into left/center/right). This component shows a low-contrast + * pill in the lower-center of the screen for the first reader open of a + * session, then fades out on tap or after a short timeout. + * + * - Phone-only (gated on `MOBILE_MEDIA_QUERY`). + * - Once per browser session (sessionStorage flag). Does not return on the + * next book open within the same tab. + * - Tapping the hint itself dismisses it; the hint also dismisses on any + * pointer interaction elsewhere on the reader (handled by the parent + * reader's tap zones, which call `onDismiss` indirectly via the toolbar + * toggle). + */ +export function ReaderFirstRunHint({ + enabled = true, +}: ReaderFirstRunHintProps) { + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + // Compute initial visibility synchronously so we don't flash the hint on + // subsequent mounts within the same session. + const [visible, setVisible] = useState(() => { + if (!enabled) return false; + return !hasBeenShown(); + }); + + useEffect(() => { + if (!visible || !isMobile) return; + // Mark as shown immediately so a rapid re-mount in the same session + // doesn't show it twice, then schedule the fade-out. + markShown(); + const timer = setTimeout(() => setVisible(false), AUTO_HIDE_MS); + return () => clearTimeout(timer); + }, [visible, isMobile]); + + // Don't render at all on desktop or if dismissed/disabled. + if (!isMobile || !enabled) { + return null; + } + + return ( + + {(styles) => ( + setVisible(false)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setVisible(false); + } + }} + style={{ + ...styles, + position: "absolute", + // Sit above the page content but below the toolbar/bottom-bar so + // those still receive taps when visible. + zIndex: 50, + // Lower-center so it doesn't compete with the toolbar when both + // are momentarily visible right after mount. + bottom: "calc(96px + env(safe-area-inset-bottom, 0px))", + left: "50%", + transform: "translateX(-50%)", + background: "rgba(0, 0, 0, 0.75)", + color: "#fff", + borderRadius: 999, + padding: "8px 14px", + pointerEvents: "auto", + cursor: "pointer", + maxWidth: "calc(100vw - 32px)", + }} + > + + + + Tap the center to show controls + + + + )} + + ); +} diff --git a/web/src/components/reader/ReaderRouter.tsx b/web/src/components/reader/ReaderRouter.tsx index c4d61d6d..cfcbbe26 100644 --- a/web/src/components/reader/ReaderRouter.tsx +++ b/web/src/components/reader/ReaderRouter.tsx @@ -148,7 +148,7 @@ export function ReaderRouter({ default: return (
Unsupported format: {format}
diff --git a/web/src/components/reader/ReaderToolbar.test.tsx b/web/src/components/reader/ReaderToolbar.test.tsx index ecb960cf..5b1ea0c3 100644 --- a/web/src/components/reader/ReaderToolbar.test.tsx +++ b/web/src/components/reader/ReaderToolbar.test.tsx @@ -1,8 +1,42 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useReaderStore } from "@/store/readerStore"; -import { fireEvent, renderWithProviders, screen } from "@/test/utils"; +import { fireEvent, renderWithProviders, screen, waitFor } from "@/test/utils"; import { ReaderToolbar } from "./ReaderToolbar"; +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + describe("ReaderToolbar", () => { const defaultProps = { title: "Test Book", @@ -13,6 +47,9 @@ describe("ReaderToolbar", () => { beforeEach(() => { vi.clearAllMocks(); + // Most tests run in desktop mode; mobile tests opt in via + // forceMobileViewport(). + forceDesktopViewport(); // Reset store to default state useReaderStore.setState({ settings: { @@ -207,4 +244,118 @@ describe("ReaderToolbar", () => { expect(slider).toHaveAttribute("aria-valuemax", "10"); }); }); + + describe("mobile (phone) viewport", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("hides the inline slider on phones", () => { + // On phones the bottom slider row is dropped from the toolbar — the + // MobileReaderBottomBar takes over. The inline page-counter ("5 / 10") + // is also moved out of the top bar to keep it within 390px viewports. + renderWithProviders(); + + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + expect(screen.queryByText("5 / 10")).not.toBeInTheDocument(); + }); + + it("renders close, title, settings, and a single overflow trigger", () => { + renderWithProviders(); + + expect(screen.getByLabelText("Close reader")).toBeInTheDocument(); + expect(screen.getByText("Test Book")).toBeInTheDocument(); + expect(screen.getByLabelText("Reader settings")).toBeInTheDocument(); + expect(screen.getByLabelText("More reader options")).toBeInTheDocument(); + }); + + it("opens the overflow menu and exposes fit-mode + fullscreen", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + + await waitFor(() => { + expect(screen.getByText(/Fit:/)).toBeInTheDocument(); + }); + expect( + screen.getByText(/Fullscreen|Exit fullscreen/), + ).toBeInTheDocument(); + }); + + it("cycles the fit mode from the overflow menu", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Fit:/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Fit:/)); + + expect(useReaderStore.getState().settings.fitMode).toBe("width"); + }); + + it("toggles fullscreen from the overflow menu", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Fullscreen/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Fullscreen/)); + + expect(useReaderStore.getState().isFullscreen).toBe(true); + }); + + it("calls onPrevBook from the overflow menu when provided", async () => { + const onPrevBook = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Previous: Vol\. 1/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Previous: Vol\. 1/)); + + expect(onPrevBook).toHaveBeenCalledTimes(1); + }); + + it("renders custom mobileMenuItems in the overflow menu", async () => { + renderWithProviders( + + EPUB action + + } + />, + ); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByTestId("custom-mobile-action")).toBeInTheDocument(); + }); + }); + + it("keeps leftActions mounted (display:none) so portaled drawers survive", () => { + const leftMarker = ( +
left actions
+ ); + renderWithProviders( + , + ); + + // The element is in the DOM tree but visually hidden by display:none on + // its wrapper. The important contract: it's NOT unmounted, so any + // portaled drawer body inside leftActions keeps responding to parent + // `opened` state when triggered from the mobile overflow menu. + expect(screen.getByTestId("left-actions-marker")).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/reader/ReaderToolbar.tsx b/web/src/components/reader/ReaderToolbar.tsx index e6ddec39..1e606606 100644 --- a/web/src/components/reader/ReaderToolbar.tsx +++ b/web/src/components/reader/ReaderToolbar.tsx @@ -2,11 +2,13 @@ import { ActionIcon, Box, Group, + Menu, Slider, Text, Tooltip, Transition, } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconArrowAutofitDown, IconArrowAutofitHeight, @@ -17,6 +19,7 @@ import { IconBook, IconChevronLeft, IconChevronRight, + IconDotsVertical, IconFile, IconPhoto, IconPlayerSkipBack, @@ -47,6 +50,12 @@ interface ReaderToolbarProps { leftActions?: React.ReactNode; /** Additional actions to render in the right section (before settings) */ rightActions?: React.ReactNode; + /** + * Additional menu items to render in the mobile overflow menu. + * Used to surface format-specific actions (e.g. TOC / bookmarks / search + * for EPUB) that don't fit in the phone-sized top bar. + */ + mobileMenuItems?: React.ReactNode; /** Series navigation: previous book info */ prevBook?: { title: string } | null; /** Series navigation: next book info */ @@ -77,16 +86,31 @@ const FIT_MODE_LABELS: Record = { original: "Original Size", }; +function getFitModeIcon(fitMode: FitMode, size: number) { + switch (fitMode) { + case "screen": + return ; + case "width": + return ; + case "width-shrink": + return ; + case "height": + return ; + case "original": + return ; + } +} + /** * Toolbar component for the reader. * - * Shows: - * - Book title - * - Page navigation controls - * - Progress slider - * - Fit mode indicator - * - Fullscreen toggle - * - Settings button + * Above the `xs` breakpoint: shows title, page nav, slider, fit-mode, + * page-layout, fullscreen, and settings inline. + * + * Below `xs` (phones): drops the inline slider row and collapses secondary + * actions (prev/next book, fit mode, page layout, fullscreen) into a single + * overflow `Menu`. Page navigation and the slider move to + * `MobileReaderBottomBar`, which is rendered separately by the parent reader. */ export function ReaderToolbar({ title, @@ -96,6 +120,7 @@ export function ReaderToolbar({ showPageNavigation = true, leftActions, rightActions, + mobileMenuItems, prevBook, nextBook, onPrevBook, @@ -124,6 +149,10 @@ export function ReaderToolbar({ const fitMode = fitModeProp ?? globalFitMode; const cycleFitMode = onCycleFitMode ?? globalCycleFitMode; + // Phone-only: drop the slider row from the top bar and collapse + // secondary actions into an overflow menu. xs breakpoint = 30.125em. + const isMobile = useMediaQuery("(max-width: 30.0625em)") ?? false; + // Adjust navigation based on reading direction. // Only RTL reverses the chevrons; LTR, TTB, and webtoon all use // left=previous, right=next (matching the natural page order). @@ -135,6 +164,16 @@ export function ReaderToolbar({ const leftDisabled = isRtl ? currentPage >= totalPages : currentPage <= 1; const rightDisabled = isRtl ? currentPage <= 1 : currentPage >= totalPages; + const actionIconSize = isMobile ? "xl" : "lg"; + const iconSize = isMobile ? 22 : 20; + const overrideColor = hasSeriesOverride ? "blue" : "gray"; + const showLayoutToggle = + showPageNavigation && + !!onTogglePageLayout && + !!pageLayout && + pageLayout !== "continuous" && + !isContinuousScroll; + return ( {(styles) => ( @@ -149,31 +188,69 @@ export function ReaderToolbar({ background: "linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.7) 70%, rgba(0,0,0,0) 100%)", padding: "12px 16px", + // Respect iOS notch / status bar when installed as PWA in + // standalone mode. Falls back to 0 on browsers without the var. + paddingTop: "calc(12px + env(safe-area-inset-top, 0px))", + paddingLeft: "calc(16px + env(safe-area-inset-left, 0px))", + paddingRight: "calc(16px + env(safe-area-inset-right, 0px))", + // The gradient fades to transparent at the bottom, but the Box + // still captures pointer events across its full height. In PWA + // standalone mode `safe-area-inset-top` (~47px) makes that area + // tall enough to swallow taps that the user intends for the + // page underneath. Pass pointer events through and re-enable + // them on the actual controls below. + pointerEvents: "none", }} > - {/* Top row: Title, controls, close */} - + {/* Top row: Title, controls, close. + Re-enable pointer events here so the controls remain tappable + while the surrounding gradient area passes touches through. */} + {/* Left: Close button, title, and custom actions */} - + - + - + {title} - {leftActions} + {/* leftActions stays mounted so portaled drawer bodies (EPUB + TOC/bookmarks) can still respond to parent-controlled opened + state on mobile. Only the trigger UI is visually hidden. */} + {leftActions && ( + + {leftActions} + + )} - {/* Center: Navigation controls */} - {showPageNavigation && ( - + {/* Center: Navigation controls (desktop only — mobile gets a bottom bar) */} + {!isMobile && showPageNavigation && ( + {/* Previous book button */} {onPrevBook && ( - + )} @@ -201,9 +279,10 @@ export function ReaderToolbar({ color="gray" onClick={onLeftClick} disabled={leftDisabled} - size="lg" + size={actionIconSize} + aria-label={leftTooltip} > - + @@ -221,9 +300,10 @@ export function ReaderToolbar({ color="gray" onClick={onRightClick} disabled={rightDisabled} - size="lg" + size={actionIconSize} + aria-label={rightTooltip} > - + @@ -239,99 +319,209 @@ export function ReaderToolbar({ color="gray" onClick={onNextBook} disabled={!nextBook} - size="lg" + size={actionIconSize} + aria-label="Next book" > - + )} )} - {/* Right: Actions */} - - {showPageNavigation && ( - - - {fitMode === "screen" && } - {fitMode === "width" && } - {fitMode === "width-shrink" && ( - - )} - {fitMode === "height" && ( - - )} - {fitMode === "original" && } - - + {/* Right: Actions. + rightActions stays mounted in both layouts so portaled drawer + bodies (e.g. EPUB bookmarks/search) keep responding to + parent-controlled `opened` state when their trigger UI is + hidden on mobile. */} + + {rightActions && ( + + {rightActions} + )} + {isMobile ? ( + /* Mobile: collapse secondary actions into an overflow menu. + Settings stays as its own button because it's the highest- + traffic non-navigation action. */ + <> + {onOpenSettings && ( + + + + + + )} + + + + + + + + {showPageNavigation && ( + + Fit: {FIT_MODE_LABELS[fitMode]} + + )} + {showLayoutToggle && ( + + ) : ( + + ) + } + onClick={onTogglePageLayout} + > + Layout:{" "} + {pageLayout === "single" ? "Single" : "Double"} + + )} + + ) : ( + + ) + } + onClick={toggleFullscreen} + > + {isFullscreen ? "Exit fullscreen" : "Fullscreen"} + + {onPrevBook && ( + } + onClick={onPrevBook} + disabled={!prevBook} + > + {prevBook + ? `Previous: ${prevBook.title}` + : "No previous book"} + + )} + {onNextBook && ( + } + onClick={onNextBook} + disabled={!nextBook} + > + {nextBook + ? `Next: ${nextBook.title}` + : "No next book"} + + )} + {mobileMenuItems} + + + + ) : ( + <> + {showPageNavigation && ( + + + {getFitModeIcon(fitMode, iconSize)} + + + )} + + {/* Page layout toggle - only show for paginated modes */} + {showLayoutToggle && ( + + + {pageLayout === "single" ? ( + + ) : ( + + )} + + + )} - {/* Page layout toggle - only show for paginated modes (not continuous/webtoon) */} - {showPageNavigation && - onTogglePageLayout && - pageLayout && - pageLayout !== "continuous" && - !isContinuousScroll && ( - {pageLayout === "single" ? ( - + {isFullscreen ? ( + ) : ( - + )} - )} - {rightActions} - - - - {isFullscreen ? ( - - ) : ( - + {onOpenSettings && ( + + + + + )} - - - - {onOpenSettings && ( - - - - - + )} - {/* Bottom row: Progress slider (only for page-based readers) */} - {showPageNavigation && ( - + {/* Bottom row: Progress slider (desktop only — phones use + MobileReaderBottomBar so the top bar stays compact). The Box + re-enables pointer events so slider/label clicks register. */} + {!isMobile && showPageNavigation && ( + { + describe("tap detection", () => { + it("classifies minimal movement as tap", () => { + expect(classifySwipe(2, 2, 50)).toBe("tap"); + }); + + it("classifies zero movement as tap even at long press", () => { + expect(classifySwipe(0, 0, 5000)).toBe("tap"); + }); + + it("does not classify >= tapTolerance movement as tap", () => { + expect(classifySwipe(15, 0, 50)).not.toBe("tap"); + }); + }); + + describe("LTR mode", () => { + it("returns 'next' on leftward swipe", () => { + expect(classifySwipe(-100, 5, 200)).toBe("next"); + }); + + it("returns 'prev' on rightward swipe", () => { + expect(classifySwipe(100, 5, 200)).toBe("prev"); + }); + + it("returns 'none' for sub-threshold horizontal movement", () => { + expect(classifySwipe(30, 5, 200)).toBe("none"); + }); + + it("returns 'none' when swipe is too slow", () => { + expect(classifySwipe(-100, 5, 1000)).toBe("none"); + }); + + it("ignores vertical movement in LTR mode", () => { + expect(classifySwipe(5, -100, 200)).toBe("none"); + }); + }); + + describe("RTL mode", () => { + it("returns 'prev' on leftward swipe", () => { + expect(classifySwipe(-100, 5, 200, { readingDirection: "rtl" })).toBe( + "prev", + ); + }); + + it("returns 'next' on rightward swipe", () => { + expect(classifySwipe(100, 5, 200, { readingDirection: "rtl" })).toBe( + "next", + ); + }); + }); + + describe("TTB / webtoon mode", () => { + it("returns 'next' on upward swipe in TTB", () => { + expect(classifySwipe(5, -100, 200, { readingDirection: "ttb" })).toBe( + "next", + ); + }); + + it("returns 'prev' on downward swipe in TTB", () => { + expect(classifySwipe(5, 100, 200, { readingDirection: "ttb" })).toBe( + "prev", + ); + }); + + it("ignores horizontal movement in TTB mode", () => { + expect(classifySwipe(-100, 5, 200, { readingDirection: "ttb" })).toBe( + "none", + ); + }); + + it("treats webtoon the same as TTB", () => { + expect(classifySwipe(5, -100, 200, { readingDirection: "webtoon" })).toBe( + "next", + ); + }); + }); + + describe("custom thresholds", () => { + it("honors a custom minSwipeDistance", () => { + expect(classifySwipe(60, 5, 200, { minSwipeDistance: 80 })).toBe("none"); + expect(classifySwipe(90, 5, 200, { minSwipeDistance: 80 })).toBe("prev"); + }); + + it("honors a custom maxSwipeTime", () => { + expect(classifySwipe(-100, 5, 500, { maxSwipeTime: 200 })).toBe("none"); + expect(classifySwipe(-100, 5, 500, { maxSwipeTime: 1000 })).toBe("next"); + }); + + it("honors a custom tapTolerance", () => { + // 15px movement with default tapTolerance (10) is not a tap, but with + // tapTolerance=20 it should be. + expect(classifySwipe(15, 0, 50, { tapTolerance: 20 })).toBe("tap"); + }); + }); +}); + +describe("classifyTapZone", () => { + // 900x600 surface; horizontal thirds at 300/600, vertical thirds at 200/400. + const W = 900; + const H = 600; + + it("returns 'prev' for left third in LTR", () => { + expect(classifyTapZone(100, 300, W, H)).toBe("prev"); + }); + + it("returns 'center' for middle third in LTR", () => { + expect(classifyTapZone(450, 300, W, H)).toBe("center"); + }); + + it("returns 'next' for right third in LTR", () => { + expect(classifyTapZone(800, 300, W, H)).toBe("next"); + }); + + it("flips left/right in RTL", () => { + expect(classifyTapZone(100, 300, W, H, { readingDirection: "rtl" })).toBe( + "next", + ); + expect(classifyTapZone(450, 300, W, H, { readingDirection: "rtl" })).toBe( + "center", + ); + expect(classifyTapZone(800, 300, W, H, { readingDirection: "rtl" })).toBe( + "prev", + ); + }); + + it("uses vertical thirds in TTB", () => { + expect(classifyTapZone(450, 50, W, H, { readingDirection: "ttb" })).toBe( + "prev", + ); + expect(classifyTapZone(450, 300, W, H, { readingDirection: "ttb" })).toBe( + "center", + ); + expect(classifyTapZone(450, 550, W, H, { readingDirection: "ttb" })).toBe( + "next", + ); + }); + + it("uses vertical thirds in webtoon mode", () => { + expect( + classifyTapZone(450, 50, W, H, { readingDirection: "webtoon" }), + ).toBe("prev"); + expect( + classifyTapZone(450, 550, W, H, { readingDirection: "webtoon" }), + ).toBe("next"); + }); + + it("falls back to 'center' on a zero-sized surface", () => { + expect(classifyTapZone(0, 0, 0, 0)).toBe("center"); + }); +}); diff --git a/web/src/components/reader/hooks/swipeGesture.ts b/web/src/components/reader/hooks/swipeGesture.ts new file mode 100644 index 00000000..09572c27 --- /dev/null +++ b/web/src/components/reader/hooks/swipeGesture.ts @@ -0,0 +1,140 @@ +/** + * Gesture classification helpers shared by `useTouchNav` (outer-container + * pointer events) and `EpubReader`'s inside-iframe pointer hook. + * + * Kept input-agnostic on purpose: callers pass deltas in pixels and ms; the + * helper has no knowledge of pointer events, touch events, or React. + */ + +export type GestureKind = "tap" | "next" | "prev" | "none"; + +/** + * Which zone of a reader surface a tap landed in. + * + * - `prev`: outer slice in the "go back" direction (left for LTR / right for + * RTL / top for TTB / webtoon). + * - `center`: middle third of the surface; reserved for revealing the toolbar. + * - `next`: outer slice in the "go forward" direction. + */ +export type TapZone = "prev" | "center" | "next"; + +export interface ClassifyTapZoneOptions { + /** Reading direction; determines the tap axis and prev/next polarity. */ + readingDirection?: "ltr" | "rtl" | "ttb" | "webtoon"; +} + +/** + * Map a tap location inside a reader surface to a {@link TapZone}. + * + * Splits the active axis into thirds: + * - LTR/RTL: horizontal thirds (left | center | right). + * - TTB/webtoon: vertical thirds (top | center | bottom). + * + * The center third always returns `"center"` so center taps reveal the toolbar + * instead of navigating, regardless of reading direction. Edge thirds map to + * `prev` / `next` based on direction: + * - LTR: left → prev, right → next. + * - RTL: left → next, right → prev. + * - TTB / webtoon: top → prev, bottom → next. + */ +export function classifyTapZone( + x: number, + y: number, + width: number, + height: number, + options: ClassifyTapZoneOptions = {}, +): TapZone { + const { readingDirection = "ltr" } = options; + const isVerticalMode = + readingDirection === "ttb" || readingDirection === "webtoon"; + + if (isVerticalMode) { + if (height <= 0) return "center"; + const third = height / 3; + if (y < third) return "prev"; + if (y > 2 * third) return "next"; + return "center"; + } + + if (width <= 0) return "center"; + const third = width / 3; + // Horizontal axis: which physical edge is "prev" depends on RTL. + if (readingDirection === "rtl") { + if (x < third) return "next"; + if (x > 2 * third) return "prev"; + return "center"; + } + if (x < third) return "prev"; + if (x > 2 * third) return "next"; + return "center"; +} + +export interface ClassifySwipeOptions { + /** Minimum swipe distance in pixels to register as a swipe (default: 50). */ + minSwipeDistance?: number; + /** Maximum gesture duration in ms; longer means no swipe (default: 300). */ + maxSwipeTime?: number; + /** Maximum movement in pixels (any direction) still considered a tap (default: 10). */ + tapTolerance?: number; + /** Reading direction; controls whether the gesture is horizontal or vertical + * and whether left/right are reversed. */ + readingDirection?: "ltr" | "rtl" | "ttb" | "webtoon"; +} + +/** + * Classify a pointer/touch gesture into `tap`, `next`, `prev`, or `none`. + * + * Direction semantics: + * - `ltr`: swipe left → next, swipe right → prev + * - `rtl`: swipe left → prev, swipe right → next + * - `ttb` / `webtoon`: swipe up → next, swipe down → prev + * + * Returns `"none"` if the gesture exceeded `maxSwipeTime` without being a tap, + * or if it moved enough to disqualify as a tap but not enough to qualify as a + * swipe. + */ +export function classifySwipe( + deltaX: number, + deltaY: number, + deltaTime: number, + options: ClassifySwipeOptions = {}, +): GestureKind { + const { + minSwipeDistance = 50, + maxSwipeTime = 300, + tapTolerance = 10, + readingDirection = "ltr", + } = options; + + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + // Tap: minimal movement regardless of timing — a slow finger-down/up in + // place is still a tap. + if (absX < tapTolerance && absY < tapTolerance) { + return "tap"; + } + + // Too slow to count as a swipe. + if (deltaTime > maxSwipeTime) { + return "none"; + } + + const isVerticalMode = + readingDirection === "ttb" || readingDirection === "webtoon"; + + if (isVerticalMode) { + const isVerticalSwipe = absY > absX && absY >= minSwipeDistance; + if (!isVerticalSwipe) return "none"; + return deltaY < 0 ? "next" : "prev"; + } + + const isHorizontalSwipe = absX > absY && absX >= minSwipeDistance; + if (!isHorizontalSwipe) return "none"; + + const isRtl = readingDirection === "rtl"; + if (isRtl) { + return deltaX < 0 ? "prev" : "next"; + } + return deltaX < 0 ? "next" : "prev"; +} diff --git a/web/src/components/reader/hooks/useEpubProgress.ts b/web/src/components/reader/hooks/useEpubProgress.ts index dda2eacf..5d2da085 100644 --- a/web/src/components/reader/hooks/useEpubProgress.ts +++ b/web/src/components/reader/hooks/useEpubProgress.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef } from "react"; import { type R2Progression, readProgressApi } from "@/api/readProgress"; +import { isOfflineQueuedError } from "@/lib/offline/outbox"; const STORAGE_KEY_PREFIX = "epub-cfi-"; const STORAGE_TIMESTAMP_PREFIX = "epub-cfi-ts-"; @@ -213,6 +214,10 @@ export function useEpubProgress({ }); }) .catch((error) => { + // Both calls run via Promise.all; if either was queued for + // offline delivery, the rejection lands here. Skip the console + // error in that case so the offline path stays quiet. + if (isOfflineQueuedError(error)) return; console.error("Failed to save EPUB reading progress:", error); }); }, diff --git a/web/src/components/reader/hooks/useReadProgress.ts b/web/src/components/reader/hooks/useReadProgress.ts index 88eaa7c5..7f623817 100644 --- a/web/src/components/reader/hooks/useReadProgress.ts +++ b/web/src/components/reader/hooks/useReadProgress.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef } from "react"; import { readProgressApi } from "@/api/readProgress"; +import { isOfflineQueuedError } from "@/lib/offline/outbox"; import { useReaderStore } from "@/store/readerStore"; interface UseReadProgressOptions { @@ -90,6 +91,10 @@ export function useReadProgress({ }); }) .catch((error) => { + // Queued for offline delivery is success-equivalent for our + // purposes: the outbox will replay the write when the network + // returns. Don't surface as an error. + if (isOfflineQueuedError(error)) return; console.error("Failed to save reading progress:", error); }); }, diff --git a/web/src/components/reader/hooks/useTouchNav.test.ts b/web/src/components/reader/hooks/useTouchNav.test.ts index 3cf70700..60c27895 100644 --- a/web/src/components/reader/hooks/useTouchNav.test.ts +++ b/web/src/components/reader/hooks/useTouchNav.test.ts @@ -10,7 +10,6 @@ describe("useTouchNav", () => { let mockTap: ReturnType; beforeEach(() => { - // Reset store state useReaderStore.setState({ settings: { ...useReaderStore.getState().settings, @@ -19,11 +18,9 @@ describe("useTouchNav", () => { readingDirectionOverride: null, }); - // Create a mock element element = document.createElement("div"); document.body.appendChild(element); - // Create mocks mockNextPage = vi.fn(); mockPrevPage = vi.fn(); mockTap = vi.fn(); @@ -33,51 +30,81 @@ describe("useTouchNav", () => { document.body.removeChild(element); }); - // Helper to create touch events - const createTouchEvent = ( - type: "touchstart" | "touchend" | "touchcancel", + type PointerKind = "touch" | "mouse" | "pen"; + + interface PointerInit { + pointerType?: PointerKind; + pointerId?: number; + isPrimary?: boolean; + button?: number; + timeStamp?: number; + } + + // jsdom doesn't ship a PointerEvent constructor; build one from MouseEvent + // and add the pointer fields the hook reads. We assign timeStamp explicitly + // so each test can control gesture duration deterministically. + const createPointerEvent = ( + type: "pointerdown" | "pointerup" | "pointercancel", x: number, y: number, - ): TouchEvent => { - const touch = { + init: PointerInit = {}, + ): PointerEvent => { + const { + pointerType = "touch", + pointerId = 1, + isPrimary = true, + button = 0, + timeStamp = 0, + } = init; + + const event = new MouseEvent(type, { clientX: x, clientY: y, - identifier: 0, - target: element, - screenX: x, - screenY: y, - pageX: x, - pageY: y, - radiusX: 0, - radiusY: 0, - rotationAngle: 0, - force: 0, - } as Touch; - - return new TouchEvent(type, { - touches: type === "touchend" || type === "touchcancel" ? [] : [touch], - changedTouches: [touch], + button, bubbles: true, - }); + cancelable: true, + }) as MouseEvent & { + pointerId: number; + pointerType: PointerKind; + isPrimary: boolean; + }; + + Object.defineProperty(event, "pointerId", { value: pointerId }); + Object.defineProperty(event, "pointerType", { value: pointerType }); + Object.defineProperty(event, "isPrimary", { value: isPrimary }); + Object.defineProperty(event, "timeStamp", { value: timeStamp }); + + return event as unknown as PointerEvent; }; - // Helper to simulate swipe const simulateSwipe = async ( startX: number, startY: number, endX: number, endY: number, + init: PointerInit = {}, + duration = 100, ) => { await act(async () => { - element.dispatchEvent(createTouchEvent("touchstart", startX, startY)); + element.dispatchEvent( + createPointerEvent("pointerdown", startX, startY, { + ...init, + timeStamp: 0, + }), + ); }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchend", endX, endY)); + element.dispatchEvent( + createPointerEvent("pointerup", endX, endY, { + ...init, + timeStamp: duration, + }), + ); }); }; - describe("LTR mode", () => { - it("should call onNextPage when swiping left", async () => { + describe("LTR mode (touch)", () => { + it("calls onNextPage when swiping left", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -91,13 +118,13 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateSwipe(200, 100, 100, 100); expect(mockNextPage).toHaveBeenCalledTimes(1); expect(mockPrevPage).not.toHaveBeenCalled(); }); - it("should call onPrevPage when swiping right", async () => { + it("calls onPrevPage when swiping right", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -111,13 +138,13 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 200, 100); // Swipe right + await simulateSwipe(100, 100, 200, 100); expect(mockPrevPage).toHaveBeenCalledTimes(1); expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should not trigger navigation for small swipes", async () => { + it("does not trigger navigation for small swipes", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -131,13 +158,79 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 120, 100); // Small swipe (20px) + await simulateSwipe(100, 100, 120, 100); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); }); }); + describe("LTR mode (mouse drag) — R10-4 desktop emulation support", () => { + it("treats a horizontal mouse drag the same as a touch swipe", async () => { + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + minSwipeDistance: 50, + }), + ); + + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(300, 200, 150, 200, { pointerType: "mouse" }); + + expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockPrevPage).not.toHaveBeenCalled(); + }); + + it("ignores non-primary mouse buttons (right-click drag)", async () => { + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + minSwipeDistance: 50, + }), + ); + + act(() => { + result.current.touchRef(element); + }); + + // button = 2 → right-click; should be ignored entirely. + await simulateSwipe(300, 200, 150, 200, { + pointerType: "mouse", + button: 2, + }); + + expect(mockNextPage).not.toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); + }); + + it("treats a mouse click in place as a tap", async () => { + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + minSwipeDistance: 50, + }), + ); + + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(200, 200, 201, 201, { pointerType: "mouse" }); + + expect(mockTap).toHaveBeenCalledTimes(1); + }); + }); + describe("RTL mode", () => { beforeEach(() => { useReaderStore.setState({ @@ -145,7 +238,7 @@ describe("useTouchNav", () => { }); }); - it("should call onPrevPage when swiping left (reversed)", async () => { + it("calls onPrevPage when swiping left (reversed)", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -159,13 +252,13 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateSwipe(200, 100, 100, 100); expect(mockPrevPage).toHaveBeenCalledTimes(1); expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should call onNextPage when swiping right (reversed)", async () => { + it("calls onNextPage when swiping right (reversed)", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -179,7 +272,7 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 200, 100); // Swipe right + await simulateSwipe(100, 100, 200, 100); expect(mockNextPage).toHaveBeenCalledTimes(1); expect(mockPrevPage).not.toHaveBeenCalled(); @@ -193,7 +286,7 @@ describe("useTouchNav", () => { }); }); - it("should call onNextPage when swiping up", async () => { + it("calls onNextPage when swiping up", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -207,13 +300,13 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 200, 100, 100); // Swipe up + await simulateSwipe(100, 200, 100, 100); expect(mockNextPage).toHaveBeenCalledTimes(1); expect(mockPrevPage).not.toHaveBeenCalled(); }); - it("should call onPrevPage when swiping down", async () => { + it("calls onPrevPage when swiping down", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -227,13 +320,13 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 100, 200); // Swipe down + await simulateSwipe(100, 100, 100, 200); expect(mockPrevPage).toHaveBeenCalledTimes(1); expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should ignore horizontal swipes in TTB mode", async () => { + it("ignores horizontal swipes in TTB mode", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -247,7 +340,7 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left (horizontal) + await simulateSwipe(200, 100, 100, 100); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); @@ -261,7 +354,7 @@ describe("useTouchNav", () => { }); }); - it("should use vertical navigation like TTB", async () => { + it("uses vertical navigation like TTB", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -275,14 +368,14 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 200, 100, 100); // Swipe up + await simulateSwipe(100, 200, 100, 100); expect(mockNextPage).toHaveBeenCalledTimes(1); }); }); describe("tap detection", () => { - it("should call onTap for minimal movement", async () => { + it("calls onTap for minimal movement", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -297,14 +390,14 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 102, 102); // Minimal movement (tap) + await simulateSwipe(100, 100, 102, 102); expect(mockTap).toHaveBeenCalledTimes(1); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); }); - it("should not call onTap for swipes", async () => { + it("does not call onTap for swipes", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -319,14 +412,171 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe + await simulateSwipe(200, 100, 100, 100); + + expect(mockTap).not.toHaveBeenCalled(); + }); + }); + + describe("zone-aware tap dispatch", () => { + // jsdom doesn't compute layout, so we stub getBoundingClientRect to make + // the element 900x600 anchored at (0,0). Horizontal thirds: 0..300, 300..600, + // 600..900. Vertical thirds: 0..200, 200..400, 400..600. + const stubRect = (w = 900, h = 600) => { + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: w, + bottom: h, + width: w, + height: h, + x: 0, + y: 0, + toJSON: () => ({}), + }); + }; + + it("calls onPrevPage for a tap in the left third (LTR)", async () => { + stubRect(); + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(100, 300, 100, 300); + + expect(mockPrevPage).toHaveBeenCalledTimes(1); + expect(mockTap).not.toHaveBeenCalled(); + expect(mockNextPage).not.toHaveBeenCalled(); + }); + + it("calls onTap for a tap in the middle third (LTR)", async () => { + stubRect(); + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(450, 300, 450, 300); + + expect(mockTap).toHaveBeenCalledTimes(1); + expect(mockPrevPage).not.toHaveBeenCalled(); + expect(mockNextPage).not.toHaveBeenCalled(); + }); + + it("calls onNextPage for a tap in the right third (LTR)", async () => { + stubRect(); + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(800, 300, 800, 300); + expect(mockNextPage).toHaveBeenCalledTimes(1); expect(mockTap).not.toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); + }); + + it("flips left/right zones in RTL", async () => { + stubRect(); + useReaderStore.setState({ readingDirectionOverride: "rtl" }); + + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(100, 300, 100, 300); + expect(mockNextPage).toHaveBeenCalledTimes(1); + + await simulateSwipe(800, 300, 800, 300); + expect(mockPrevPage).toHaveBeenCalledTimes(1); + }); + + it("uses vertical thirds in TTB mode", async () => { + stubRect(); + useReaderStore.setState({ readingDirectionOverride: "ttb" }); + + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + // Top third → prev. + await simulateSwipe(450, 50, 450, 50); + expect(mockPrevPage).toHaveBeenCalledTimes(1); + + // Middle third → toolbar toggle. + await simulateSwipe(450, 300, 450, 300); + expect(mockTap).toHaveBeenCalledTimes(1); + + // Bottom third → next. + await simulateSwipe(450, 550, 450, 550); + expect(mockNextPage).toHaveBeenCalledTimes(1); + }); + + it("treats every tap as a center tap when tapZones is false", async () => { + stubRect(); + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + tapZones: false, + }), + ); + act(() => { + result.current.touchRef(element); + }); + + await simulateSwipe(100, 300, 100, 300); + await simulateSwipe(800, 300, 800, 300); + + expect(mockTap).toHaveBeenCalledTimes(2); + expect(mockNextPage).not.toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); }); }); describe("disabled state", () => { - it("should not respond when disabled", async () => { + it("does not respond when disabled", async () => { const { result } = renderHook(() => useTouchNav({ enabled: false, @@ -339,20 +589,24 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateSwipe(200, 100, 100, 100); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); }); }); - describe("touch cancel", () => { - it("should handle touch cancel gracefully", async () => { + describe("pointer cancel", () => { + it("treats a horizontal cancel as a swipe (iOS edge-gesture fallback)", async () => { + // iOS WebKit fires pointercancel mid-swipe when it decides the gesture + // is a horizontal pan/back-swipe. We still want to classify and navigate + // based on the movement that did happen. const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, + minSwipeDistance: 50, }), ); @@ -361,23 +615,91 @@ describe("useTouchNav", () => { }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchstart", 200, 100)); + element.dispatchEvent( + createPointerEvent("pointerdown", 250, 100, { timeStamp: 0 }), + ); }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchcancel", 150, 100)); + element.dispatchEvent( + createPointerEvent("pointercancel", 100, 100, { timeStamp: 100 }), + ); + }); + + expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockPrevPage).not.toHaveBeenCalled(); + }); + + it("does not treat a tap cancel as navigation", async () => { + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + onTap: mockTap, + minSwipeDistance: 50, + }), + ); + + act(() => { + result.current.touchRef(element); }); + await act(async () => { - element.dispatchEvent(createTouchEvent("touchend", 100, 100)); + element.dispatchEvent( + createPointerEvent("pointerdown", 200, 100, { timeStamp: 0 }), + ); + }); + await act(async () => { + element.dispatchEvent( + createPointerEvent("pointercancel", 201, 100, { timeStamp: 50 }), + ); }); - // Should not trigger after cancel expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); + expect(mockTap).not.toHaveBeenCalled(); + }); + + it("does not double-fire when pointerup arrives after pointercancel", async () => { + // Some platforms emit a stray pointerup after pointercancel. Make sure + // we don't count both as gesture ends and navigate twice. + const { result } = renderHook(() => + useTouchNav({ + enabled: true, + onNextPage: mockNextPage, + onPrevPage: mockPrevPage, + minSwipeDistance: 50, + }), + ); + + act(() => { + result.current.touchRef(element); + }); + + await act(async () => { + element.dispatchEvent( + createPointerEvent("pointerdown", 250, 100, { timeStamp: 0 }), + ); + }); + await act(async () => { + element.dispatchEvent( + createPointerEvent("pointercancel", 100, 100, { timeStamp: 50 }), + ); + }); + await act(async () => { + element.dispatchEvent( + createPointerEvent("pointerup", 100, 100, { timeStamp: 100 }), + ); + }); + + // Cancel-as-swipe fires once; the stray pointerup must be ignored. + expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockPrevPage).not.toHaveBeenCalled(); }); }); describe("ref management", () => { - it("should clean up listeners when ref changes", () => { + it("cleans up listeners when ref changes", () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -396,11 +718,10 @@ describe("useTouchNav", () => { result.current.touchRef(element2); }); - // Old element should no longer have listeners - // (Testing this indirectly by checking new element works) + // Old element should no longer have listeners; swipe on it must not fire. act(() => { - element.dispatchEvent(createTouchEvent("touchstart", 200, 100)); - element.dispatchEvent(createTouchEvent("touchend", 100, 100)); + element.dispatchEvent(createPointerEvent("pointerdown", 200, 100)); + element.dispatchEvent(createPointerEvent("pointerup", 100, 100)); }); expect(mockNextPage).not.toHaveBeenCalled(); @@ -408,7 +729,7 @@ describe("useTouchNav", () => { document.body.removeChild(element2); }); - it("should handle null ref", () => { + it("handles null ref without throwing", () => { const { result } = renderHook(() => useTouchNav({ enabled: true, @@ -424,17 +745,16 @@ describe("useTouchNav", () => { result.current.touchRef(null); }); - // Should not throw expect(() => { act(() => { - element.dispatchEvent(createTouchEvent("touchstart", 200, 100)); + element.dispatchEvent(createPointerEvent("pointerdown", 200, 100)); }); }).not.toThrow(); }); }); describe("uses store actions when no custom handlers", () => { - it("should use store nextPage when no onNextPage provided", async () => { + it("uses store nextPage when no onNextPage provided", async () => { const storeNextPage = vi.spyOn(useReaderStore.getState(), "nextPage"); useReaderStore.setState({ @@ -453,7 +773,7 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateSwipe(200, 100, 100, 100); expect(storeNextPage).toHaveBeenCalled(); }); diff --git a/web/src/components/reader/hooks/useTouchNav.ts b/web/src/components/reader/hooks/useTouchNav.ts index 4e41ef57..fbefdc29 100644 --- a/web/src/components/reader/hooks/useTouchNav.ts +++ b/web/src/components/reader/hooks/useTouchNav.ts @@ -3,9 +3,10 @@ import { selectEffectiveReadingDirection, useReaderStore, } from "@/store/readerStore"; +import { classifySwipe, classifyTapZone } from "./swipeGesture"; export interface UseTouchNavOptions { - /** Whether touch navigation is enabled */ + /** Whether pointer/touch navigation is enabled */ enabled?: boolean; /** Minimum swipe distance in pixels to trigger navigation (default: 50) */ minSwipeDistance?: number; @@ -15,19 +16,36 @@ export interface UseTouchNavOptions { onNextPage?: () => void; /** Custom handler for previous page (overrides default store action) */ onPrevPage?: () => void; - /** Callback when a tap is detected (for toolbar toggle) */ + /** Callback when a center-zone tap is detected (for toolbar toggle). When + * `tapZones` is false this fires for taps anywhere on the surface. */ onTap?: () => void; + /** Whether taps on the outer thirds navigate (prev/next), with the middle + * third reserved for `onTap`. Default true. Set false in continuous-scroll + * modes where the whole surface should toggle the toolbar. */ + tapZones?: boolean; } -interface TouchState { +interface GestureState { + pointerId: number | null; startX: number; startY: number; startTime: number; - isTracking: boolean; } +const INITIAL_GESTURE: GestureState = { + pointerId: null, + startX: 0, + startY: 0, + startTime: 0, +}; + /** - * Hook for touch/swipe navigation in the reader. + * Hook for tap/swipe navigation in the reader. + * + * Uses Pointer Events so a single code path covers touch (finger) **and** + * mouse (desktop, Chrome mobile-viewport emulation, trackpad drag). Without + * this, mouse-drag swipes in Chrome DevTools never reach the navigation code + * unless the user manually enables Sensors > Touch (see R10-3 / R10-4). * * Supports: * - Horizontal swipes for page navigation @@ -48,6 +66,7 @@ export function useTouchNav({ onNextPage, onPrevPage, onTap, + tapZones = true, }: UseTouchNavOptions = {}) { const storeNextPage = useReaderStore((state) => state.nextPage); const storePrevPage = useReaderStore((state) => state.prevPage); @@ -57,99 +76,85 @@ export function useTouchNav({ const nextPage = onNextPage ?? storeNextPage; const prevPage = onPrevPage ?? storePrevPage; - // Track touch state - const touchState = useRef({ - startX: 0, - startY: 0, - startTime: 0, - isTracking: false, - }); - - // Element ref for attaching listeners + const gestureState = useRef({ ...INITIAL_GESTURE }); const elementRef = useRef(null); - const handleTouchStart = useCallback( - (e: TouchEvent) => { + const handlePointerDown = useCallback( + (e: PointerEvent) => { if (!enabled) return; - const touch = e.touches[0]; - touchState.current = { - startX: touch.clientX, - startY: touch.clientY, - startTime: Date.now(), - isTracking: true, + // Only track the primary pointer; ignore secondary touches, right-click, + // and middle-click drags. + if (!e.isPrimary) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + + gestureState.current = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + startTime: e.timeStamp || Date.now(), }; }, [enabled], ); - const handleTouchEnd = useCallback( - (e: TouchEvent) => { - if (!enabled || !touchState.current.isTracking) return; - - const touch = e.changedTouches[0]; - const { startX, startY, startTime } = touchState.current; - - const deltaX = touch.clientX - startX; - const deltaY = touch.clientY - startY; - const deltaTime = Date.now() - startTime; - - // Reset tracking - touchState.current.isTracking = false; - - // Check if it's within time limit for a swipe - if (deltaTime > maxSwipeTime) { - return; - } - - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); - - // Determine if this is primarily a horizontal or vertical swipe - const isHorizontalSwipe = absX > absY && absX >= minSwipeDistance; - const isVerticalSwipe = absY > absX && absY >= minSwipeDistance; - - // Check for tap (minimal movement) - if (absX < 10 && absY < 10) { - onTap?.(); - return; - } - - // Handle based on reading direction - const isVerticalMode = - readingDirection === "ttb" || readingDirection === "webtoon"; - const isRtl = readingDirection === "rtl"; - - if (isVerticalMode) { - // TTB/Webtoon: vertical swipes control navigation - if (isVerticalSwipe) { - if (deltaY < 0) { - // Swipe up = next page + const handlePointerUp = useCallback( + (e: PointerEvent) => { + if (!enabled) return; + const state = gestureState.current; + if (state.pointerId === null || state.pointerId !== e.pointerId) return; + + const deltaX = e.clientX - state.startX; + const deltaY = e.clientY - state.startY; + const deltaTime = (e.timeStamp || Date.now()) - state.startTime; + + gestureState.current = { ...INITIAL_GESTURE }; + + const gesture = classifySwipe(deltaX, deltaY, deltaTime, { + minSwipeDistance, + maxSwipeTime, + readingDirection, + }); + + switch (gesture) { + case "tap": { + if (!tapZones) { + onTap?.(); + break; + } + // Map the tap location to a zone (prev/center/next) relative to the + // element. Without an attached element we can't know the geometry, + // so fall back to a plain toolbar toggle. + const element = elementRef.current; + if (!element) { + onTap?.(); + break; + } + const rect = element.getBoundingClientRect(); + const zone = classifyTapZone( + e.clientX - rect.left, + e.clientY - rect.top, + rect.width, + rect.height, + { readingDirection }, + ); + if (zone === "center") { + onTap?.(); + } else if (zone === "next") { nextPage(); } else { - // Swipe down = prev page prevPage(); } + break; } - } else { - // LTR/RTL: horizontal swipes control navigation - if (isHorizontalSwipe) { - if (isRtl) { - // RTL: reversed - if (deltaX < 0) { - prevPage(); - } else { - nextPage(); - } - } else { - // LTR: normal - if (deltaX < 0) { - nextPage(); - } else { - prevPage(); - } - } - } + case "next": + nextPage(); + break; + case "prev": + prevPage(); + break; + case "none": + break; } }, [ @@ -160,55 +165,91 @@ export function useTouchNav({ nextPage, prevPage, onTap, + tapZones, ], ); - const handleTouchCancel = useCallback(() => { - touchState.current.isTracking = false; - }, []); + const handlePointerCancel = useCallback( + (e: PointerEvent) => { + const state = gestureState.current; + if (state.pointerId === null || state.pointerId !== e.pointerId) return; + + const deltaX = e.clientX - state.startX; + const deltaY = e.clientY - state.startY; + const deltaTime = (e.timeStamp || Date.now()) - state.startTime; + gestureState.current = { ...INITIAL_GESTURE }; + + if (!enabled) return; + + // iOS WebKit fires pointercancel mid-gesture when it claims a swipe for + // its own scroll/back-navigation logic. If the user moved far enough to + // count as a swipe, treat the cancel as the gesture's terminus so users + // don't have to fight the browser. Taps (negligible movement) are + // discarded because a canceled tap usually means the browser took the + // press for something else (text selection, context menu). + const gesture = classifySwipe(deltaX, deltaY, deltaTime, { + minSwipeDistance, + maxSwipeTime, + readingDirection, + }); + + if (gesture === "next") nextPage(); + else if (gesture === "prev") prevPage(); + }, + [ + enabled, + minSwipeDistance, + maxSwipeTime, + readingDirection, + nextPage, + prevPage, + ], + ); // Set ref callback to attach/detach listeners const setRef = useCallback( (element: HTMLElement | null) => { - // Remove listeners from previous element if (elementRef.current) { - elementRef.current.removeEventListener("touchstart", handleTouchStart); - elementRef.current.removeEventListener("touchend", handleTouchEnd); elementRef.current.removeEventListener( - "touchcancel", - handleTouchCancel, + "pointerdown", + handlePointerDown, + ); + elementRef.current.removeEventListener("pointerup", handlePointerUp); + elementRef.current.removeEventListener( + "pointercancel", + handlePointerCancel, ); } elementRef.current = element; - // Add listeners to new element if (element && enabled) { - element.addEventListener("touchstart", handleTouchStart, { - passive: true, - }); - element.addEventListener("touchend", handleTouchEnd, { passive: true }); - element.addEventListener("touchcancel", handleTouchCancel, { - passive: true, - }); + // Pointer events are passive by default unless preventDefault() is + // called; we don't, so listeners stay cheap and don't block scroll. + element.addEventListener("pointerdown", handlePointerDown); + element.addEventListener("pointerup", handlePointerUp); + element.addEventListener("pointercancel", handlePointerCancel); } }, - [enabled, handleTouchStart, handleTouchEnd, handleTouchCancel], + [enabled, handlePointerDown, handlePointerUp, handlePointerCancel], ); // Cleanup on unmount useEffect(() => { return () => { if (elementRef.current) { - elementRef.current.removeEventListener("touchstart", handleTouchStart); - elementRef.current.removeEventListener("touchend", handleTouchEnd); elementRef.current.removeEventListener( - "touchcancel", - handleTouchCancel, + "pointerdown", + handlePointerDown, + ); + elementRef.current.removeEventListener("pointerup", handlePointerUp); + elementRef.current.removeEventListener( + "pointercancel", + handlePointerCancel, ); } }; - }, [handleTouchStart, handleTouchEnd, handleTouchCancel]); + }, [handlePointerDown, handlePointerUp, handlePointerCancel]); return { touchRef: setRef }; } diff --git a/web/src/components/reader/index.ts b/web/src/components/reader/index.ts index ccaa927d..8502e299 100644 --- a/web/src/components/reader/index.ts +++ b/web/src/components/reader/index.ts @@ -9,6 +9,7 @@ export { EpubReaderSettings } from "./EpubReaderSettings"; export { EpubSearch } from "./EpubSearch"; export { EpubTableOfContents } from "./EpubTableOfContents"; export * from "./hooks"; +export { MobileReaderBottomBar } from "./MobileReaderBottomBar"; export { getSlideDirection, PageTransitionWrapper, @@ -16,6 +17,7 @@ export { export { PdfContinuousScrollReader } from "./PdfContinuousScrollReader"; export { PdfReader, type PdfZoomLevel } from "./PdfReader"; export { PdfReaderSettings } from "./PdfReaderSettings"; +export { ReaderFirstRunHint } from "./ReaderFirstRunHint"; export { ReaderRouter } from "./ReaderRouter"; export { ReaderSettings } from "./ReaderSettings"; export { ReaderToolbar } from "./ReaderToolbar"; diff --git a/web/src/components/releases/ReleasesTable.tsx b/web/src/components/releases/ReleasesTable.tsx index 32c10cd1..eb778b5e 100644 --- a/web/src/components/releases/ReleasesTable.tsx +++ b/web/src/components/releases/ReleasesTable.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Anchor, Badge, + Card, Checkbox, Group, Stack, @@ -9,6 +10,7 @@ import { Text, Tooltip, } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconCheck, IconExternalLink, @@ -18,6 +20,7 @@ import { import { format } from "date-fns"; import { Link } from "react-router-dom"; import type { ReleaseLedgerEntry, ReleaseSource } from "@/api/releases"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { MediaUrlIcon } from "./MediaUrlIcon"; const STATE_BADGE: Record = { @@ -96,6 +99,163 @@ export function ReleasesTable({ entries.length > 0 && entries.every((e) => selected.has(e.id)); const someSelected = entries.some((e) => selected.has(e.id)) && !allSelected; + // Below xs the wide release table clips off the side. Render a stack of + // cards instead — each card carries the same controls and shows series / + // chapter / source on its own line. useMediaQuery (rather than CSS-only + // `visibleFrom`) keeps only one DOM tree mounted so tests that query the + // row checkboxes / actions don't see duplicate matches. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + + if (isMobile) { + return ( + + + + + {selected.size > 0 ? `${selected.size} selected` : "Select all"} + + + {entries.map((entry) => { + const stateInfo = STATE_BADGE[entry.state] ?? { + color: "gray", + label: entry.state, + }; + const isSelected = selected.has(entry.id); + const source = sourceById.get(entry.sourceId); + const sourceLabel = + source?.displayName ?? `${entry.sourceId.slice(0, 8)}…`; + return ( + + + + { + const shiftKey = + event.nativeEvent instanceof MouseEvent && + event.nativeEvent.shiftKey; + onToggleOne(entry.id, shiftKey); + }} + /> + {showSeriesColumn ? ( + + {entry.seriesTitle.length > 0 + ? entry.seriesTitle + : `${entry.seriesId.slice(0, 8)}…`} + + ) : ( + + {formatChapterVolume(entry)} + + )} + + + {stateInfo.label} + + + + {showSeriesColumn && ( + + {formatChapterVolume(entry)} + + )} + + {sourceLabel} + {entry.groupOrUploader && + entry.groupOrUploader !== sourceLabel + ? ` · ${entry.groupOrUploader}` + : ""} + {entry.language ? ` · ${entry.language}` : ""} + + + Observed {format(new Date(entry.observedAt), "yyyy-MM-dd")} + + + + + + + + + {entry.mediaUrl && ( + + )} + {entry.state === "announced" && ( + <> + + onMarkAcquired(entry.id)} + aria-label="Mark acquired" + > + + + + + onDismiss(entry.id)} + aria-label="Dismiss" + > + + + + + )} + + onDelete(entry.id)} + aria-label="Delete" + > + + + + + + ); + })} + + ); + } + return ( diff --git a/web/src/components/search/MobileSearchSheet.module.css b/web/src/components/search/MobileSearchSheet.module.css new file mode 100644 index 00000000..672d96b4 --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.module.css @@ -0,0 +1,38 @@ +.body { + display: flex; + flex-direction: column; + height: calc(100% - 60px); +} + +.option { + display: block; + width: 100%; + padding: 10px 8px; + border-radius: var(--mantine-radius-sm); + min-height: 56px; +} + +.option:hover, +.option:active { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} + +.footer { + display: block; + width: 100%; + padding: 10px 8px; + margin-top: 4px; + border-top: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} + +.footer:hover, +.footer:active { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} diff --git a/web/src/components/search/MobileSearchSheet.test.tsx b/web/src/components/search/MobileSearchSheet.test.tsx new file mode 100644 index 00000000..b08231e5 --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { MobileSearchSheet } from "./MobileSearchSheet"; + +vi.mock("@/hooks/useSearch", () => ({ + useSearch: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +import { useSearch } from "@/hooks/useSearch"; + +const mockResults = { + series: [ + { + id: "s1", + title: "Alpha Series", + bookCount: 3, + createdAt: "2024-01-01T00:00:00Z", + libraryId: "lib-1", + libraryName: "Comics", + updatedAt: "2024-01-01T00:00:00Z", + }, + ], + books: [ + { + id: "b1", + title: "First Book", + libraryId: "lib-1", + libraryName: "Comics", + seriesName: "Gamma Series", + seriesId: "s1", + filePath: "/path/first.cbz", + fileSize: 1000, + fileHash: "hash1", + fileFormat: "cbz", + pageCount: 100, + analyzed: true, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deleted: false, + }, + ], +}; + +describe("MobileSearchSheet", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSearch).mockReturnValue({ + results: { series: [], books: [] }, + isLoading: false, + error: null, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not render input when closed", () => { + renderWithProviders(); + expect( + screen.queryByPlaceholderText("Search series and books..."), + ).not.toBeInTheDocument(); + }); + + it("renders input when opened", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Search series and books..."), + ).toBeInTheDocument(); + }); + + it("does not render result groups when query is below the minimum length", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "t"); + + expect(screen.queryByText("Alpha Series")).not.toBeInTheDocument(); + }); + + it("shows series and book results when query length is at least 2", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("Alpha Series")).toBeInTheDocument(); + expect(screen.getByText("First Book")).toBeInTheDocument(); + }); + }); + + it("navigates and closes when a series result is clicked", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "alpha"); + + await waitFor(() => { + expect(screen.getByText("Alpha Series")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Alpha Series")); + + expect(mockNavigate).toHaveBeenCalledWith("/series/s1"); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates and closes when a book result is clicked", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "book"); + + await waitFor(() => { + expect(screen.getByText("First Book")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("First Book")); + + expect(mockNavigate).toHaveBeenCalledWith("/books/b1"); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates to /search and closes on Enter when query is long enough", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "hello"); + await user.keyboard("{Enter}"); + + expect(mockNavigate).toHaveBeenCalledWith("/search?q=hello"); + expect(onClose).toHaveBeenCalled(); + }); + + it("does not navigate on Enter when query is too short", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "a"); + await user.keyboard("{Enter}"); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("shows the loading state while searching", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: { series: [], books: [] }, + isLoading: true, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("Searching...")).toBeInTheDocument(); + }); + }); + + it("shows a no-results message when the query has no matches", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("No results found")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/search/MobileSearchSheet.tsx b/web/src/components/search/MobileSearchSheet.tsx new file mode 100644 index 00000000..ffbcbf2d --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.tsx @@ -0,0 +1,150 @@ +import { + Drawer, + Group, + Loader, + ScrollArea, + Stack, + Text, + TextInput, + UnstyledButton, +} from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useSearch } from "@/hooks/useSearch"; +import classes from "./MobileSearchSheet.module.css"; +import { BookResultContent, SeriesResultContent } from "./SearchResultItem"; + +interface MobileSearchSheetProps { + opened: boolean; + onClose: () => void; +} + +export function MobileSearchSheet({ opened, onClose }: MobileSearchSheetProps) { + const [query, setQuery] = useState(""); + const navigate = useNavigate(); + const { results, isLoading } = useSearch(query); + + const series = results?.series ?? []; + const books = results?.books ?? []; + const hasResults = series.length > 0 || books.length > 0; + const showResults = query.trim().length >= 2; + const showMoreLink = series.length > 5 || books.length > 5; + + useEffect(() => { + if (!opened) { + setQuery(""); + } + }, [opened]); + + const handleNavigate = useCallback( + (path: string) => { + onClose(); + navigate(path); + }, + [navigate, onClose], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && query.trim().length >= 2) { + event.preventDefault(); + handleNavigate(`/search?q=${encodeURIComponent(query.trim())}`); + } + }, + [query, handleNavigate], + ); + + return ( + + + : + } + value={query} + onChange={(event) => setQuery(event.currentTarget.value)} + onKeyDown={handleKeyDown} + size="md" + aria-label="Search query" + /> + + {showResults && ( + + {isLoading ? ( + + + + Searching... + + + ) : !hasResults ? ( + + No results found + + ) : ( + + {series.length > 0 && ( + + + Series + + {series.slice(0, 5).map((s) => ( + handleNavigate(`/series/${s.id}`)} + > + + + ))} + + )} + {books.length > 0 && ( + + + Books + + {books.slice(0, 5).map((b) => ( + handleNavigate(`/books/${b.id}`)} + > + + + ))} + + )} + {showMoreLink && ( + + handleNavigate( + `/search?q=${encodeURIComponent(query.trim())}`, + ) + } + > + + See all results + + + )} + + )} + + )} + + + ); +} diff --git a/web/src/components/search/SearchInput.tsx b/web/src/components/search/SearchInput.tsx index a49f06d2..ca07c1e2 100644 --- a/web/src/components/search/SearchInput.tsx +++ b/web/src/components/search/SearchInput.tsx @@ -1,10 +1,8 @@ import { Combobox, Group, - Image, Loader, ScrollArea, - Stack, Text, TextInput, useCombobox, @@ -22,6 +20,7 @@ import { useNavigate } from "react-router-dom"; import { useSearch } from "@/hooks/useSearch"; import type { Book, Series } from "@/types"; import classes from "./SearchInput.module.css"; +import { BookResultContent, SeriesResultContent } from "./SearchResultItem"; interface SearchInputProps { placeholder?: string; @@ -128,25 +127,7 @@ export const SearchInput = forwardRef( key={series.id} className={classes.option} > - - - - - {series.title} - - - {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} - - - + ); @@ -156,29 +137,7 @@ export const SearchInput = forwardRef( key={book.id} className={classes.option} > - - - - - {book.number !== undefined && book.number !== null - ? `${book.number} - ${book.title}` - : book.title} - - {book.seriesName && ( - - {book.seriesName} - - )} - - + ); @@ -204,7 +163,7 @@ export const SearchInput = forwardRef( } }} onBlur={() => combobox.closeDropdown()} - visibleFrom="sm" + visibleFrom="xs" w={width} /> diff --git a/web/src/components/search/SearchResultItem.tsx b/web/src/components/search/SearchResultItem.tsx new file mode 100644 index 00000000..4b8e2726 --- /dev/null +++ b/web/src/components/search/SearchResultItem.tsx @@ -0,0 +1,57 @@ +import { Group, Image, Stack, Text } from "@mantine/core"; +import type { Book, Series } from "@/types"; + +const FALLBACK_THUMB = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='56'%3E%3Crect fill='%23333' width='40' height='56'/%3E%3C/svg%3E"; + +export function SeriesResultContent({ series }: { series: Series }) { + return ( + + + + + {series.title} + + + {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} + + + + ); +} + +export function BookResultContent({ book }: { book: Book }) { + return ( + + + + + {book.number !== undefined && book.number !== null + ? `${book.number} - ${book.title}` + : book.title} + + {book.seriesName && ( + + {book.seriesName} + + )} + + + ); +} diff --git a/web/src/components/search/index.ts b/web/src/components/search/index.ts index db1d8f35..5dfc4b79 100644 --- a/web/src/components/search/index.ts +++ b/web/src/components/search/index.ts @@ -1,2 +1,3 @@ +export { MobileSearchSheet } from "./MobileSearchSheet"; export type { SearchInputHandle } from "./SearchInput"; export { SearchInput } from "./SearchInput"; diff --git a/web/src/components/series/GenreTagChips.test.tsx b/web/src/components/series/GenreTagChips.test.tsx index 737ac937..3c6797b0 100644 --- a/web/src/components/series/GenreTagChips.test.tsx +++ b/web/src/components/series/GenreTagChips.test.tsx @@ -61,6 +61,27 @@ describe("GenreTagChips", () => { expect(screen.getByText("Completed")).toBeInTheDocument(); }); + it("renders tags with outline variant so they stay visible in dark mode", () => { + renderWithProviders(); + + // Mantine Badge sets `data-variant` on the root element. Tags must use + // `outline` (not the default `light`) so they don't disappear against the + // dark-mode surface — see Phase 9 / R9-2. + const favoriteBadge = screen + .getByText("Favorite") + .closest("[data-variant]"); + expect(favoriteBadge).toHaveAttribute("data-variant", "outline"); + }); + + it("renders genres with the default light variant", () => { + renderWithProviders( + , + ); + + const actionBadge = screen.getByText("Action").closest("[data-variant]"); + expect(actionBadge).toHaveAttribute("data-variant", "light"); + }); + it("should render both genres and tags together", () => { renderWithProviders(); diff --git a/web/src/components/series/GenreTagChips.tsx b/web/src/components/series/GenreTagChips.tsx index 39fb3af7..c10777ee 100644 --- a/web/src/components/series/GenreTagChips.tsx +++ b/web/src/components/series/GenreTagChips.tsx @@ -1,4 +1,4 @@ -import { Badge, Group, Text } from "@mantine/core"; +import { Badge, type BadgeVariant, Group, Text } from "@mantine/core"; import { useState } from "react"; import { Link } from "react-router-dom"; import type { Genre } from "@/api/genres"; @@ -12,6 +12,7 @@ interface BadgeItem { interface BadgeGroup { items: BadgeItem[]; color: string; + variant?: BadgeVariant; getUrl?: (item: BadgeItem) => string; } @@ -60,6 +61,9 @@ export function GenreTagChips({ { items: tags, color: "gray", + // Outline variant keeps tags visible in dark mode (the "light + gray" + // pairing renders almost invisible against the dim Mantine surface). + variant: "outline" as BadgeVariant, getUrl: clickable ? getTagUrl : undefined, }, ] @@ -100,7 +104,7 @@ export function GenreTagChips({ key={`${group.color}-${item.id}`} component={Link} to={group.getUrl(item)} - variant="light" + variant={group.variant ?? "light"} color={group.color} size="sm" style={{ cursor: "pointer", textDecoration: "none" }} @@ -110,7 +114,7 @@ export function GenreTagChips({ ) : ( diff --git a/web/src/components/ui/ResponsiveTable.test.tsx b/web/src/components/ui/ResponsiveTable.test.tsx new file mode 100644 index 00000000..1ad12c3d --- /dev/null +++ b/web/src/components/ui/ResponsiveTable.test.tsx @@ -0,0 +1,371 @@ +import { ActionIcon, Text } from "@mantine/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, within } from "@/test/utils"; +import { ResponsiveTable, type ResponsiveTableColumn } from "./ResponsiveTable"; + +interface Row { + id: string; + name: string; + email: string; + role: string; +} + +const ROWS: Row[] = [ + { id: "1", name: "Alice", email: "alice@example.com", role: "admin" }, + { id: "2", name: "Bob", email: "bob@example.com", role: "reader" }, +]; + +const COLUMNS: ResponsiveTableColumn[] = [ + { + key: "name", + header: "Name", + accessor: (row) => {row.name}, + mobilePrimary: true, + }, + { key: "email", header: "Email", accessor: (row) => row.email }, + { key: "role", header: "Role", accessor: (row) => row.role }, +]; + +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe("ResponsiveTable", () => { + beforeEach(() => { + forceDesktopViewport(); + }); + + describe("desktop layout", () => { + it("renders a Mantine Table with headers", () => { + renderWithProviders( + row.id} + data-testid="rt" + />, + ); + + const table = screen.getByRole("table"); + expect(table).toBeInTheDocument(); + const headers = within(table).getAllByRole("columnheader"); + expect(headers).toHaveLength(3); + expect(headers[0]).toHaveTextContent("Name"); + expect(headers[1]).toHaveTextContent("Email"); + expect(headers[2]).toHaveTextContent("Role"); + }); + + it("renders cell content via the column accessor", () => { + renderWithProviders( + row.id} + />, + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); + expect(screen.getByText("admin")).toBeInTheDocument(); + }); + + it("renders rowActions as the last column with a default header", () => { + const onDelete = vi.fn(); + renderWithProviders( + row.id} + rowActions={(row) => ( + + )} + />, + ); + + expect(screen.getByText("Actions")).toBeInTheDocument(); + expect(screen.getByText("Delete Alice")).toBeInTheDocument(); + expect(screen.getByText("Delete Bob")).toBeInTheDocument(); + }); + + it("respects a custom rowActionsHeader", () => { + renderWithProviders( + row.id} + rowActions={() => x} + rowActionsHeader="" + />, + ); + + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + }); + + it("skips columns with hideOnDesktop", () => { + renderWithProviders( + "mobile-only-value", + hideOnDesktop: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Mobile only")).not.toBeInTheDocument(); + expect(screen.queryByText("mobile-only-value")).not.toBeInTheDocument(); + }); + + it("renders emptyState in place of the table when data is empty", () => { + renderWithProviders( + row.id} + emptyState={
No users
} + />, + ); + + expect(screen.getByText("No users")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("renders an empty table (no emptyState) when data is empty and no fallback is given", () => { + renderWithProviders( + row.id} + />, + ); + + const table = screen.getByRole("table"); + expect(table).toBeInTheDocument(); + // Header row only; no body rows. + expect(within(table).getAllByRole("row")).toHaveLength(1); + }); + }); + + describe("mobile layout", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("renders a stack of Cards instead of a Table", () => { + renderWithProviders( + row.id} + data-testid="rt" + />, + ); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByTestId("rt")).toBeInTheDocument(); + }); + + it("renders the primary column without a label on the card", () => { + renderWithProviders( + row.id} + />, + ); + + // Primary column ("name") renders the value; the literal header text + // should not also appear because mobilePrimary suppresses the label. + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.queryByText("Name")).not.toBeInTheDocument(); + }); + + it("renders non-primary columns as label/value pairs", () => { + renderWithProviders( + row.id} + />, + ); + + // Both columns + both rows = labels appear once per row. + expect(screen.getAllByText("Email")).toHaveLength(2); + expect(screen.getAllByText("Role")).toHaveLength(2); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("reader")).toBeInTheDocument(); + }); + + it("uses mobileLabel when provided instead of header", () => { + renderWithProviders( + "value", + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Long Desktop Header")).not.toBeInTheDocument(); + expect(screen.getAllByText("Short")).toHaveLength(2); + }); + + it("skips columns with hideOnMobile", () => { + renderWithProviders( + "should-not-render", + hideOnMobile: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Internal")).not.toBeInTheDocument(); + expect(screen.queryByText("should-not-render")).not.toBeInTheDocument(); + }); + + it("renders rowActions inside each card", () => { + const onDelete = vi.fn(); + renderWithProviders( + row.id} + rowActions={(row) => ( + onDelete(row.id)} + > + x + + )} + />, + ); + + expect(screen.getByLabelText("delete Alice")).toBeInTheDocument(); + expect(screen.getByLabelText("delete Bob")).toBeInTheDocument(); + }); + + it("uses renderMobileCard to override the card body", () => { + renderWithProviders( + row.id} + renderMobileCard={(row) => ( +
{row.name} custom
+ )} + />, + ); + + expect(screen.getByTestId("custom-1")).toHaveTextContent("Alice custom"); + expect(screen.getByTestId("custom-2")).toHaveTextContent("Bob custom"); + // The default label/value rows must not appear. + expect(screen.queryByText("Email")).not.toBeInTheDocument(); + }); + + it("keeps rowActions footer when renderMobileCard is used", () => { + renderWithProviders( + row.id} + renderMobileCard={(row) =>
{row.name}
} + rowActions={(row) => action-{row.id}} + />, + ); + + expect(screen.getByText("action-1")).toBeInTheDocument(); + expect(screen.getByText("action-2")).toBeInTheDocument(); + }); + + it("renders emptyState in place of cards when data is empty", () => { + renderWithProviders( + row.id} + emptyState={
Nothing here
} + />, + ); + + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); + + it("renders mobileFullWidth columns as label + full-width value block", () => { + renderWithProviders( + ( + + A very long string that should occupy the full card width. + + ), + mobileFullWidth: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.getAllByText("Description")).toHaveLength(2); + expect(screen.getAllByTestId("long-value")).toHaveLength(2); + }); + }); +}); diff --git a/web/src/components/ui/ResponsiveTable.tsx b/web/src/components/ui/ResponsiveTable.tsx new file mode 100644 index 00000000..a33c4b29 --- /dev/null +++ b/web/src/components/ui/ResponsiveTable.tsx @@ -0,0 +1,237 @@ +import { + Box, + type BoxProps, + Card, + type CardProps, + Group, + Stack, + Table, + type TableProps, + type TableTdProps, + type TableThProps, + Text, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import type { CSSProperties, ReactNode } from "react"; + +/** + * Mobile breakpoint used by the responsive table. Matches the `xs` value in + * `web/src/theme.ts` (30.125em). The `0.0625em` (~1px) deduction guards against + * sub-pixel rounding so the query fires exactly when Mantine considers the + * viewport below `xs`. + */ +export const MOBILE_MEDIA_QUERY = "(max-width: 30.0625em)"; + +export interface ResponsiveTableColumn { + /** Stable react key for this column. */ + key: string; + /** Header cell content. Doubles as the mobile card label when `mobileLabel` is not set. */ + header: ReactNode; + /** Returns the cell content for a given row. */ + accessor: (row: T, rowIndex: number) => ReactNode; + /** Override the label shown next to the value on mobile (defaults to `header`). */ + mobileLabel?: ReactNode; + /** Skip the column on mobile. Use for columns better expressed elsewhere on a card. */ + hideOnMobile?: boolean; + /** Skip the column on desktop. Useful for mobile-only summary lines. */ + hideOnDesktop?: boolean; + /** + * Render the value as a card header on mobile — no label, larger emphasis, + * placed before the label/value rows. Useful for the primary identifier + * (e.g. user name, plugin name). + */ + mobilePrimary?: boolean; + /** Hide the label on the mobile card; render the value full-width. */ + mobileFullWidth?: boolean; + /** Props applied to the desktop ``. */ + thProps?: Omit; + /** Props applied to the desktop ``. */ + tdProps?: Omit; +} + +export interface ResponsiveTableProps { + /** Row data. */ + data: T[]; + /** Column definitions. */ + columns: ResponsiveTableColumn[]; + /** Stable react key per row. */ + getRowKey: (row: T, index: number) => string; + /** + * Optional per-row actions. On desktop the actions render as the last cell + * of each row. On mobile they render as a footer at the bottom of each card. + */ + rowActions?: (row: T, rowIndex: number) => ReactNode; + /** Header text for the actions column on desktop. */ + rowActionsHeader?: ReactNode; + /** Props applied to the desktop `
`. */ + tableProps?: Omit; + /** Props applied to each mobile ``. */ + cardProps?: Omit; + /** Wrapper props for the desktop table. */ + desktopWrapperProps?: BoxProps; + /** Wrapper props for the mobile stack. */ + mobileWrapperProps?: BoxProps; + /** Rendered (in both layouts) when `data` is empty. */ + emptyState?: ReactNode; + /** + * Custom mobile card body. If provided, replaces the default label/value + * list. `rowActions`, if present, is still appended below the body. + */ + renderMobileCard?: (row: T, rowIndex: number) => ReactNode; + /** + * Optional `data-testid` applied to both the desktop table and the mobile + * stack. Useful for visual regression tests that need to address both + * layouts. + */ + "data-testid"?: string; +} + +const PRIMARY_TEXT_STYLE: CSSProperties = { + minWidth: 0, + wordBreak: "break-word", +}; + +const VALUE_BOX_STYLE: CSSProperties = { + minWidth: 0, + textAlign: "right", + flex: 1, +}; + +const FULL_WIDTH_VALUE_STYLE: CSSProperties = { + minWidth: 0, +}; + +/** + * Renders a data table that gracefully degrades to a stack of cards below the + * `xs` breakpoint. Above `xs` (≥ 30.125em) the standard Mantine `
` is + * used. Below `xs` each row becomes a `` with stacked label/value rows + * and row actions in a footer. + * + * For pages with a bespoke mobile layout (e.g. expandable details rows, + * multi-line primary content), pass `renderMobileCard` to override the + * default body. + */ +export function ResponsiveTable({ + data, + columns, + getRowKey, + rowActions, + rowActionsHeader = "Actions", + tableProps, + cardProps, + desktopWrapperProps, + mobileWrapperProps, + emptyState, + renderMobileCard, + "data-testid": dataTestid, +}: ResponsiveTableProps) { + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + + if (data.length === 0 && emptyState !== undefined) { + return <>{emptyState}; + } + + if (isMobile) { + return ( + + + {data.map((row, idx) => ( + + {renderMobileCard ? ( + renderMobileCard(row, idx) + ) : ( + + {columns + .filter((col) => !col.hideOnMobile) + .map((col) => { + const value = col.accessor(row, idx); + if (col.mobilePrimary) { + return ( + + {value} + + ); + } + if (col.mobileFullWidth) { + return ( + + + {col.mobileLabel ?? col.header} + + {value} + + ); + } + return ( + + + {col.mobileLabel ?? col.header} + + {value} + + ); + })} + + )} + {rowActions ? ( + + {rowActions(row, idx)} + + ) : null} + + ))} + + + ); + } + + return ( + +
+ + + {columns + .filter((col) => !col.hideOnDesktop) + .map((col) => ( + + {col.header} + + ))} + {rowActions ? {rowActionsHeader} : null} + + + + {data.map((row, idx) => ( + + {columns + .filter((col) => !col.hideOnDesktop) + .map((col) => ( + + {col.accessor(row, idx)} + + ))} + {rowActions ? ( + + + {rowActions(row, idx)} + + + ) : null} + + ))} + +
+
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 00000000..27475e3f --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,6 @@ +export { + MOBILE_MEDIA_QUERY, + ResponsiveTable, + type ResponsiveTableColumn, + type ResponsiveTableProps, +} from "./ResponsiveTable"; diff --git a/web/src/index.css b/web/src/index.css index 98e30d48..b0ce6bbd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,27 +1,3 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - body { margin: 0; min-width: 320px; @@ -32,44 +8,92 @@ body { min-height: 100vh; } -h1 { - font-size: 3.2em; - line-height: 1.1; +/* Hide focus outline for mouse clicks, show only for keyboard navigation */ +*:focus:not(:focus-visible) { + outline: none; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; +/* ============================================ + PWA standalone-mode safe-area handling + ============================================ + When installed to the home screen (iOS uses + apple-mobile-web-app-status-bar-style=black-translucent), the app draws + under the notch / status bar / home indicator. Pad the AppShell shell so + nav controls aren't tucked under those areas. The reader's own toolbar + and bottom bar already apply their own safe-area padding (see Phase 3), + so they remain immersive. */ +@media (display-mode: standalone) { + .mantine-AppShell-header { + padding-top: env(safe-area-inset-top, 0px); + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); + height: calc(64px + env(safe-area-inset-top, 0px)); + } + .mantine-AppShell-navbar { + padding-top: env(safe-area-inset-top, 0px); + padding-left: env(safe-area-inset-left, 0px); + } + .mantine-AppShell-main { + padding-left: calc( + var(--app-shell-padding, 1rem) + + env(safe-area-inset-left, 0px) + ); + padding-right: calc( + var(--app-shell-padding, 1rem) + + env(safe-area-inset-right, 0px) + ); + padding-bottom: calc( + var(--app-shell-padding, 1rem) + + env(safe-area-inset-bottom, 0px) + ); + } } -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + +/* ============================================ + Mobile responsive helpers + ============================================ */ + +/* Prevent layout-breaking horizontal overflow from long unbreakable strings + (e.g., file names without spaces) in titles, breadcrumbs, and inline links. + Scoped to known long-string surfaces so we don't change URL/code rendering. */ +.mantine-Title-root, +.mantine-Breadcrumbs-root, +.mantine-Anchor-root { + overflow-wrap: anywhere; + word-break: break-word; } -/* Hide focus outline for mouse clicks, show only for keyboard navigation */ -*:focus:not(:focus-visible) { - outline: none; +/* Mantine sets `white-space: nowrap` on Breadcrumbs items, which prevents + long-but-unbreakable titles from wrapping and causes horizontal overflow on + phones. Allow wrapping inside items so the wrap-row layout works as intended. */ +.mantine-Breadcrumbs-breadcrumb { + white-space: normal; + overflow-wrap: anywhere; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; +/* Phone-only (≤480px): bump touch targets opted in via .touch-target class. */ +@media (max-width: 30.0625em) { + .touch-target, + .touch-target .mantine-ActionIcon-root { + min-width: 44px; + min-height: 44px; + } + + /* Scale down Mantine Title sizes so page headings don't wrap to many lines + at 390px. Mantine's Title renders an h1–h6 tag and sets font size via the + `--title-fz` CSS variable; overriding the variable keeps all of Mantine's + other typography concerns (weight, line-height defaults) intact. */ + .mantine-Title-root[data-order="1"] { + --title-fz: 1.5rem; + --title-lh: 1.3; } - a:hover { - color: #747bff; + .mantine-Title-root[data-order="2"] { + --title-fz: 1.25rem; + --title-lh: 1.3; } - button { - background-color: #f9f9f9; + .mantine-Title-root[data-order="3"] { + --title-fz: 1.125rem; + --title-lh: 1.3; } } diff --git a/web/src/lib/offline/db.test.ts b/web/src/lib/offline/db.test.ts new file mode 100644 index 00000000..59cd6ea4 --- /dev/null +++ b/web/src/lib/offline/db.test.ts @@ -0,0 +1,195 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + _resetForTests, + clearDownloads, + clearOutbox, + type DownloadRecord, + deleteDownload, + deleteOutboxEntry, + drainOutbox, + enqueueOutbox, + getAllDownloads, + getDownload, + getOutbox, + putDownload, + setDbContext, +} from "./db"; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); +}); + +function makeDownload( + id: string, + overrides: Partial = {}, +): DownloadRecord { + return { + id, + format: "epub", + status: "complete", + bytes: 1024, + pageCount: 1, + downloadedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe("offline db — downloads store", () => { + it("round-trips put/get for a single record", async () => { + const rec = makeDownload("book-1"); + await putDownload(rec); + const back = await getDownload("book-1"); + expect(back).toEqual(rec); + }); + + it("getAllDownloads returns every stored record", async () => { + await putDownload(makeDownload("a")); + await putDownload(makeDownload("b", { format: "pdf" })); + await putDownload(makeDownload("c", { format: "comic", pageCount: 24 })); + const all = await getAllDownloads(); + expect(all.map((r) => r.id).sort()).toEqual(["a", "b", "c"]); + }); + + it("put overwrites the existing record for the same id", async () => { + await putDownload( + makeDownload("book-1", { status: "downloading", bytes: 100 }), + ); + await putDownload( + makeDownload("book-1", { status: "complete", bytes: 500 }), + ); + const back = await getDownload("book-1"); + expect(back?.status).toBe("complete"); + expect(back?.bytes).toBe(500); + }); + + it("deleteDownload removes a record without touching siblings", async () => { + await putDownload(makeDownload("a")); + await putDownload(makeDownload("b")); + await deleteDownload("a"); + expect(await getDownload("a")).toBeUndefined(); + expect(await getDownload("b")).toBeDefined(); + }); + + it("clearDownloads empties the store", async () => { + await putDownload(makeDownload("a")); + await putDownload(makeDownload("b")); + await clearDownloads(); + expect(await getAllDownloads()).toEqual([]); + }); + + it("returns undefined for an unknown id", async () => { + expect(await getDownload("nope")).toBeUndefined(); + }); +}); + +describe("offline db — outbox store", () => { + it("enqueue returns an auto-incremented key and getOutbox returns the record", async () => { + const key = await enqueueOutbox({ + url: "/api/v1/books/abc/progress", + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ page: 5 }), + }); + expect(typeof key).toBe("number"); + const all = await getOutbox(); + expect(all).toHaveLength(1); + expect(all[0]?.id).toBe(key); + expect(all[0]?.request.url).toBe("/api/v1/books/abc/progress"); + expect(all[0]?.retryCount).toBe(0); + }); + + it("preserves insertion order in the auto-incremented keys", async () => { + const k1 = await enqueueOutbox({ url: "/a", method: "PUT", headers: {} }); + const k2 = await enqueueOutbox({ url: "/b", method: "PUT", headers: {} }); + const k3 = await enqueueOutbox({ url: "/c", method: "PUT", headers: {} }); + expect(k1).toBeLessThan(k2); + expect(k2).toBeLessThan(k3); + }); + + it("deleteOutboxEntry removes a single entry", async () => { + const k1 = await enqueueOutbox({ url: "/a", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/b", method: "PUT", headers: {} }); + await deleteOutboxEntry(k1); + const remaining = await getOutbox(); + expect(remaining.map((r) => r.request.url)).toEqual(["/b"]); + }); + + it("clearOutbox empties the store", async () => { + await enqueueOutbox({ url: "/a", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/b", method: "PUT", headers: {} }); + await clearOutbox(); + expect(await getOutbox()).toEqual([]); + }); +}); + +describe("offline db — drainOutbox", () => { + it("sends each record in order and removes it on success", async () => { + await enqueueOutbox({ url: "/1", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/2", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/3", method: "PUT", headers: {} }); + + const sentUrls: string[] = []; + const sent = await drainOutbox(async (record) => { + sentUrls.push(record.request.url); + }); + + expect(sent).toBe(3); + expect(sentUrls).toEqual(["/1", "/2", "/3"]); + expect(await getOutbox()).toEqual([]); + }); + + it("stops draining on first failure and bumps retryCount on the failed record", async () => { + await enqueueOutbox({ url: "/1", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/2", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/3", method: "PUT", headers: {} }); + + const sent = await drainOutbox(async (record) => { + if (record.request.url === "/2") { + throw new Error("boom"); + } + }); + + expect(sent).toBe(1); + const remaining = await getOutbox(); + expect(remaining.map((r) => r.request.url)).toEqual(["/2", "/3"]); + const failed = remaining.find((r) => r.request.url === "/2"); + expect(failed?.retryCount).toBe(1); + }); + + it("returns 0 and does not error on an empty outbox", async () => { + const sent = await drainOutbox(async () => { + throw new Error("should not be called"); + }); + expect(sent).toBe(0); + }); + + it("a second drain attempt picks up where the previous one stopped", async () => { + await enqueueOutbox({ url: "/1", method: "PUT", headers: {} }); + await enqueueOutbox({ url: "/2", method: "PUT", headers: {} }); + + let failOnce = true; + await drainOutbox(async (record) => { + if (record.request.url === "/1" && failOnce) { + failOnce = false; + throw new Error("transient"); + } + }); + + // /1 is still queued with retryCount=1, /2 not yet attempted. + let remaining = await getOutbox(); + expect(remaining.map((r) => r.request.url)).toEqual(["/1", "/2"]); + + const sent = await drainOutbox(async () => { + // succeed this time + }); + expect(sent).toBe(2); + remaining = await getOutbox(); + expect(remaining).toEqual([]); + }); +}); diff --git a/web/src/lib/offline/db.ts b/web/src/lib/offline/db.ts new file mode 100644 index 00000000..b14937f6 --- /dev/null +++ b/web/src/lib/offline/db.ts @@ -0,0 +1,316 @@ +/** + * IndexedDB access layer for the offline-reading feature (Phase 12). + * + * Two stores: + * - `downloads`: per-book metadata, source of truth for whether a book is + * available offline. The service worker reads this at boot and intercepts + * page/file requests for matching ids; the page side writes when a download + * completes or is removed. + * - `outbox`: queued reading-progress mutations that failed offline. Drained + * on `online` and `visibilitychange` events. + * + * Works in both Window and ServiceWorkerGlobalScope contexts. No external + * deps; hand-rolled to keep the SW bundle small. + */ + +export const DB_NAME = "codex-offline"; +export const DB_VERSION = 1; + +export const DOWNLOADS_STORE = "downloads"; +export const OUTBOX_STORE = "outbox"; + +export const DOWNLOADS_BROADCAST_CHANNEL = "codex:downloads"; + +export type DownloadFormat = "cbr" | "cbz" | "epub" | "pdf"; +export type DownloadStatus = "queued" | "downloading" | "complete" | "error"; + +export interface DownloadRecord { + /** Book id; primary key. */ + id: string; + format: DownloadFormat; + status: DownloadStatus; + /** Bytes already cached. */ + bytes: number; + /** Total page count for comics; 1 for single-file formats. */ + pageCount: number; + /** ms epoch when the download completed; undefined while queued/downloading. */ + downloadedAt?: number; + /** ms epoch of the most recent reader session. */ + lastReadAt?: number; + /** Error message if status === "error". */ + error?: string; +} + +export interface OutboxRecord { + /** Auto-incremented key. */ + id?: number; + /** Serialised fetch input. Body is stored as string to keep cloning cheap. */ + request: { + url: string; + method: string; + headers: Record; + body?: string; + }; + createdAt: number; + retryCount: number; +} + +/** + * Broadcast payload published on `codex:downloads` when a record changes. + * Subscribers (SW route handler, Downloads page, DownloadButton) refresh + * their in-memory view from this. + */ +export type DownloadsBroadcast = + | { kind: "put"; record: DownloadRecord } + | { kind: "delete"; id: string } + | { kind: "clear" }; + +type IDBContext = { + indexedDB: IDBFactory; +}; + +/** + * Resolve the IndexedDB factory in the current scope. Window has + * `self.indexedDB`; ServiceWorkerGlobalScope has the same. Tests can pass an + * override via `setDbContext` (used by fake-indexeddb). + */ +let dbContext: IDBContext | null = null; + +export function setDbContext(ctx: IDBContext | null): void { + dbContext = ctx; + cachedDb = null; +} + +function getIndexedDB(): IDBFactory { + if (dbContext) return dbContext.indexedDB; + const scopeIDB = + typeof self !== "undefined" + ? (self as unknown as { indexedDB?: IDBFactory }).indexedDB + : undefined; + if (!scopeIDB) { + throw new Error("IndexedDB is not available in this environment"); + } + return scopeIDB; +} + +let cachedDb: IDBDatabase | null = null; +let openPromise: Promise | null = null; + +export async function openDatabase(): Promise { + if (cachedDb) return cachedDb; + if (openPromise) return openPromise; + + openPromise = new Promise((resolve, reject) => { + const request = getIndexedDB().open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(DOWNLOADS_STORE)) { + db.createObjectStore(DOWNLOADS_STORE, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(OUTBOX_STORE)) { + db.createObjectStore(OUTBOX_STORE, { + keyPath: "id", + autoIncrement: true, + }); + } + }; + + request.onsuccess = () => { + cachedDb = request.result; + cachedDb.onclose = () => { + cachedDb = null; + }; + resolve(cachedDb); + }; + + request.onerror = () => reject(request.error); + request.onblocked = () => + reject(new Error("IndexedDB open blocked by another connection")); + }).finally(() => { + openPromise = null; + }); + + return openPromise; +} + +/** + * Reset cached state. Used by tests; not part of the runtime API. + */ +export function _resetForTests(): void { + cachedDb = null; + openPromise = null; +} + +async function runTransaction( + storeName: string, + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest | T, +): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, mode); + const store = tx.objectStore(storeName); + let value: T | undefined; + let didResolve = false; + + try { + const result = fn(store); + if ( + result && + typeof (result as IDBRequest).onsuccess !== "undefined" + ) { + (result as IDBRequest).onsuccess = () => { + value = (result as IDBRequest).result; + didResolve = true; + }; + (result as IDBRequest).onerror = () => + reject((result as IDBRequest).error); + } else { + value = result as T; + didResolve = true; + } + } catch (err) { + reject(err); + return; + } + + tx.oncomplete = () => resolve(didResolve ? (value as T) : (undefined as T)); + tx.onerror = () => reject(tx.error); + tx.onabort = () => + reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }); +} + +// -- downloads store ----------------------------------------------------- + +export async function getAllDownloads(): Promise { + return runTransaction( + DOWNLOADS_STORE, + "readonly", + (store) => store.getAll() as IDBRequest, + ); +} + +export async function getDownload( + id: string, +): Promise { + return runTransaction( + DOWNLOADS_STORE, + "readonly", + (store) => store.get(id) as IDBRequest, + ); +} + +export async function putDownload(record: DownloadRecord): Promise { + await runTransaction(DOWNLOADS_STORE, "readwrite", (store) => + store.put(record), + ); +} + +export async function deleteDownload(id: string): Promise { + await runTransaction(DOWNLOADS_STORE, "readwrite", (store) => { + store.delete(id); + return undefined; + }); +} + +export async function clearDownloads(): Promise { + await runTransaction(DOWNLOADS_STORE, "readwrite", (store) => { + store.clear(); + return undefined; + }); +} + +// -- outbox store -------------------------------------------------------- + +export async function enqueueOutbox( + request: OutboxRecord["request"], +): Promise { + const record: OutboxRecord = { + request, + createdAt: Date.now(), + retryCount: 0, + }; + const key = await runTransaction( + OUTBOX_STORE, + "readwrite", + (store) => store.add(record), + ); + return Number(key); +} + +export async function getOutbox(): Promise { + return runTransaction( + OUTBOX_STORE, + "readonly", + (store) => store.getAll() as IDBRequest, + ); +} + +export async function deleteOutboxEntry(id: number): Promise { + await runTransaction(OUTBOX_STORE, "readwrite", (store) => { + store.delete(id); + return undefined; + }); +} + +export async function clearOutbox(): Promise { + await runTransaction(OUTBOX_STORE, "readwrite", (store) => { + store.clear(); + return undefined; + }); +} + +/** + * Drain the outbox in insertion order. `send` is invoked sequentially for + * each record; on success the record is removed. On failure the drain stops + * (preserves order; the failing record stays at the head for the next + * attempt) and the failed record's `retryCount` is bumped. Returns the + * number of records successfully sent. + * + * Sequential rather than parallel because reading-progress updates for the + * same book must apply in order; the server's last-write-wins resolution + * would otherwise reorder them. + */ +export async function drainOutbox( + send: (record: OutboxRecord) => Promise, +): Promise { + const all = await getOutbox(); + all.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)); + let sent = 0; + for (const record of all) { + try { + await send(record); + if (record.id !== undefined) { + await deleteOutboxEntry(record.id); + } + sent += 1; + } catch { + if (record.id !== undefined) { + await runTransaction(OUTBOX_STORE, "readwrite", (store) => { + store.put({ ...record, retryCount: record.retryCount + 1 }); + return undefined; + }); + } + break; + } + } + return sent; +} + +// -- broadcast helpers --------------------------------------------------- + +/** + * Publish a downloads-store change. Returns silently in environments without + * BroadcastChannel (older Safari, test JSDOM without the polyfill). + */ +export function broadcastDownloadsChange(payload: DownloadsBroadcast): void { + if (typeof BroadcastChannel === "undefined") return; + const channel = new BroadcastChannel(DOWNLOADS_BROADCAST_CHANNEL); + try { + channel.postMessage(payload); + } finally { + channel.close(); + } +} diff --git a/web/src/lib/offline/downloadManager.test.ts b/web/src/lib/offline/downloadManager.test.ts new file mode 100644 index 00000000..1bab9903 --- /dev/null +++ b/web/src/lib/offline/downloadManager.test.ts @@ -0,0 +1,690 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetForTests, + getAllDownloads, + getDownload, + setDbContext, +} from "./db"; +import { + _resetPersistenceForTests, + downloadComicBook, + downloadSingleFileBook, + getStoragePersistence, + requestStoragePersistence, +} from "./downloadManager"; +import { cacheNameForBook } from "./routeMatcher"; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + _resetPersistenceForTests(); +}); + +// -- Fake CacheStorage ---------------------------------------------------- +// jsdom does not ship the Cache API. The downloadManager only needs `open` +// and `delete`, and Cache only needs `put` / `match` for our purposes. + +interface CacheEntry { + body: Uint8Array; + status: number; + headers: Record; +} + +function makeFakeCaches() { + const stores = new Map>(); + + const cachesImpl = { + async open(name: string): Promise { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + const cache: Partial = { + put: async (request, response) => { + const url = + typeof request === "string" ? request : (request as Request).url; + const buffer = await response.arrayBuffer(); + const headerObj: Record = {}; + response.headers.forEach((value, key) => { + headerObj[key] = value; + }); + store!.set(url, { + body: new Uint8Array(buffer), + status: response.status, + headers: headerObj, + }); + }, + match: async (request) => { + const url = + typeof request === "string" ? request : (request as Request).url; + const entry = store!.get(url); + if (!entry) return undefined; + return new Response(entry.body, { + status: entry.status, + headers: entry.headers, + }); + }, + }; + return cache as Cache; + }, + async delete(name: string): Promise { + return stores.delete(name); + }, + } as Partial; + + return { + caches: cachesImpl as CacheStorage, + getStore: (name: string) => stores.get(name), + }; +} + +// -- Fetch helpers -------------------------------------------------------- + +function makeStreamingResponse( + chunks: Uint8Array[], + init: { + contentLength?: number | null; + contentType?: string; + status?: number; + } = {}, +): Response { + const headers = new Headers(); + if (init.contentType) headers.set("content-type", init.contentType); + if (init.contentLength !== null && init.contentLength !== undefined) { + headers.set("content-length", String(init.contentLength)); + } + + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: init.status ?? 200, + headers, + }); +} + +describe("downloadSingleFileBook: success path", () => { + it("streams the body, caches it under codex-book-, and writes a complete IDB row", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const fakeFetch = vi.fn(async () => + makeStreamingResponse([payload.slice(0, 4), payload.slice(4)], { + contentLength: payload.length, + contentType: "application/epub+zip", + }), + ); + + const result = await downloadSingleFileBook({ + bookId: "book-1", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(result).toEqual({ bookId: "book-1", bytes: 8 }); + expect(fakeFetch).toHaveBeenCalledWith( + "/api/v1/books/book-1/file", + expect.objectContaining({}), + ); + + const record = await getDownload("book-1"); + expect(record?.status).toBe("complete"); + expect(record?.bytes).toBe(8); + expect(record?.format).toBe("epub"); + expect(record?.pageCount).toBe(1); + expect(record?.downloadedAt).toBeGreaterThan(0); + + const store = getStore(cacheNameForBook("book-1")); + expect(store?.has("/api/v1/books/book-1/file")).toBe(true); + const entry = store?.get("/api/v1/books/book-1/file"); + expect(Array.from(entry?.body ?? [])).toEqual(Array.from(payload)); + expect(entry?.headers["content-type"]).toBe("application/epub+zip"); + }); + + it("invokes onProgress with monotonically increasing loaded values and the correct total", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const chunks = [ + new Uint8Array([1, 2, 3]), + new Uint8Array([4, 5]), + new Uint8Array([6, 7, 8, 9, 10]), + ]; + const total = chunks.reduce((acc, c) => acc + c.length, 0); + const fakeFetch = vi.fn(async () => + makeStreamingResponse(chunks, { contentLength: total }), + ); + const progress: { loaded: number; total: number | null }[] = []; + + await downloadSingleFileBook({ + bookId: "book-2", + format: "pdf", + onProgress: (p) => progress.push({ ...p }), + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(progress.map((p) => p.loaded)).toEqual([3, 5, 10]); + expect(progress.every((p) => p.total === total)).toBe(true); + }); + + it("reports total: null when Content-Length is missing", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const fakeFetch = vi.fn(async () => + makeStreamingResponse([new Uint8Array([1, 2, 3])], { + contentLength: null, + }), + ); + const progress: { loaded: number; total: number | null }[] = []; + + await downloadSingleFileBook({ + bookId: "book-3", + format: "epub", + onProgress: (p) => progress.push({ ...p }), + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(progress[0]?.total).toBeNull(); + }); + + it("flips the IDB record from downloading -> complete in two writes", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const states: string[] = []; + const fakeFetch = vi.fn(async () => { + // Capture the IDB row state at the time fetch is invoked: by then + // putDownload should have already written the `downloading` row. + const mid = await getDownload("book-4"); + if (mid) states.push(mid.status); + return makeStreamingResponse([new Uint8Array([1])], { contentLength: 1 }); + }); + + await downloadSingleFileBook({ + bookId: "book-4", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + const final = await getDownload("book-4"); + states.push(final?.status ?? "missing"); + expect(states).toEqual(["downloading", "complete"]); + }); + + it("supports independent concurrent downloads in separate caches", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const body = url.includes("book-a") + ? new Uint8Array([0xa]) + : new Uint8Array([0xb, 0xb]); + return makeStreamingResponse([body], { contentLength: body.length }); + }); + + await Promise.all([ + downloadSingleFileBook({ + bookId: "book-a", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + downloadSingleFileBook({ + bookId: "book-b", + format: "pdf", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ]); + + const all = await getAllDownloads(); + expect(all.map((r) => r.id).sort()).toEqual(["book-a", "book-b"]); + expect(getStore(cacheNameForBook("book-a"))?.size).toBe(1); + expect(getStore(cacheNameForBook("book-b"))?.size).toBe(1); + }); +}); + +describe("downloadSingleFileBook: error paths", () => { + it("records an error and rethrows when fetch throws", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const fakeFetch = vi.fn(async () => { + throw new Error("network down"); + }); + + await expect( + downloadSingleFileBook({ + bookId: "book-err", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow("network down"); + + const record = await getDownload("book-err"); + expect(record?.status).toBe("error"); + expect(record?.error).toBe("network down"); + }); + + it("records an error and rethrows on a non-OK response", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const fakeFetch = vi.fn( + async () => new Response("forbidden", { status: 403 }), + ); + + await expect( + downloadSingleFileBook({ + bookId: "book-403", + format: "pdf", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow(/HTTP 403/); + + const record = await getDownload("book-403"); + expect(record?.status).toBe("error"); + // Nothing was cached. + expect(getStore(cacheNameForBook("book-403"))).toBeUndefined(); + }); + + it("records an error if the stream errors mid-download", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const fakeFetch = vi.fn(async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.error(new Error("stream broke")); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-length": "8" }, + }); + }); + + await expect( + downloadSingleFileBook({ + bookId: "book-stream", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow("stream broke"); + + const record = await getDownload("book-stream"); + expect(record?.status).toBe("error"); + expect(getStore(cacheNameForBook("book-stream"))).toBeUndefined(); + }); +}); + +describe("downloadSingleFileBook: cancellation", () => { + it("aborting before the stream finishes deletes the IDB row and the per-book cache", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const controller = new AbortController(); + + const fakeFetch = vi.fn(async (_input, init?: RequestInit) => { + const signal = init?.signal; + const stream = new ReadableStream({ + async start(streamController) { + streamController.enqueue(new Uint8Array([1, 2])); + // Wait then abort, triggering a stream error on the reader. + await new Promise((resolve) => setTimeout(resolve, 5)); + controller.abort(); + if (signal?.aborted) { + streamController.error(new DOMException("Aborted", "AbortError")); + } else { + streamController.enqueue(new Uint8Array([3, 4])); + streamController.close(); + } + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-length": "4" }, + }); + }); + + await expect( + downloadSingleFileBook({ + bookId: "book-abort", + format: "epub", + signal: controller.signal, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(await getDownload("book-abort")).toBeUndefined(); + expect(getStore(cacheNameForBook("book-abort"))).toBeUndefined(); + }); +}); + +// -- Comic per-page download (T4) ---------------------------------------- + +function makePageResponse( + bytes: Uint8Array, + contentType = "image/jpeg", +): Response { + return new Response(bytes, { + status: 200, + headers: { "content-type": contentType }, + }); +} + +function parsePageNumber(url: string): number { + const match = url.match(/\/pages\/(\d+)$/); + if (!match) throw new Error(`Not a page URL: ${url}`); + return Number(match[1]); +} + +describe("downloadComicBook: success path", () => { + it("fetches every page, stores each under the per-book cache, and writes a complete IDB row", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const pageCount = 12; + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const n = parsePageNumber(url); + // Page N body = a single byte equal to N (test-friendly). + return makePageResponse(new Uint8Array([n])); + }); + + const result = await downloadComicBook({ + bookId: "book-1", + format: "cbz", + pageCount, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(result).toEqual({ bookId: "book-1", bytes: pageCount }); + expect(fakeFetch).toHaveBeenCalledTimes(pageCount); + + const store = getStore(cacheNameForBook("book-1")); + expect(store?.size).toBe(pageCount); + for (let n = 1; n <= pageCount; n++) { + const entry = store?.get(`/api/v1/books/book-1/pages/${n}`); + expect(entry).toBeDefined(); + expect(Array.from(entry?.body ?? [])).toEqual([n]); + } + + const record = await getDownload("book-1"); + expect(record?.status).toBe("complete"); + expect(record?.format).toBe("cbz"); + expect(record?.pageCount).toBe(pageCount); + expect(record?.bytes).toBe(pageCount); + expect(record?.downloadedAt).toBeGreaterThan(0); + }); + + it("respects the concurrency cap (no more than `concurrency` requests in flight)", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + let inFlight = 0; + let peak = 0; + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + inFlight++; + peak = Math.max(peak, inFlight); + // Give the event loop a microtask break so concurrency can actually + // ramp up (sync resolves would all run in one tick at peak=1). + await new Promise((r) => setTimeout(r, 1)); + inFlight--; + const n = parsePageNumber( + typeof input === "string" ? input : (input as URL).toString(), + ); + return makePageResponse(new Uint8Array([n])); + }); + + await downloadComicBook({ + bookId: "book-conc", + format: "cbz", + pageCount: 20, + concurrency: 4, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(peak).toBeLessThanOrEqual(4); + expect(peak).toBeGreaterThan(1); + }); + + it("reports progress as pages-done / pageCount, monotonically increasing", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const n = parsePageNumber( + typeof input === "string" ? input : (input as URL).toString(), + ); + return makePageResponse(new Uint8Array([n])); + }); + const progress: { loaded: number; total: number | null }[] = []; + + await downloadComicBook({ + bookId: "book-prog", + format: "cbz", + pageCount: 5, + concurrency: 1, + onProgress: (p) => progress.push({ ...p }), + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + + expect(progress.map((p) => p.loaded)).toEqual([1, 2, 3, 4, 5]); + expect(progress.every((p) => p.total === 5)).toBe(true); + }); + + it("rejects pageCount < 1 without touching IDB or cache", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + await expect( + downloadComicBook({ + bookId: "bad", + format: "cbz", + pageCount: 0, + fetch: vi.fn() as unknown as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow(/Invalid pageCount/); + expect(await getDownload("bad")).toBeUndefined(); + expect(getStore(cacheNameForBook("bad"))).toBeUndefined(); + }); +}); + +describe("downloadComicBook: page failure", () => { + it("aborts on a 404 page, sets IDB to error with the page number, and evicts the partial cache", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const n = parsePageNumber(url); + if (n === 3) return new Response("missing", { status: 404 }); + // Pause briefly so siblings actually get a chance to start before + // the failure aborts them, otherwise the test trivially passes with + // page 3 being the only attempt. + await new Promise((r) => setTimeout(r, 1)); + return makePageResponse(new Uint8Array([n])); + }); + + await expect( + downloadComicBook({ + bookId: "book-fail", + format: "cbz", + pageCount: 5, + concurrency: 2, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow(/HTTP 404.*page 3/); + + const record = await getDownload("book-fail"); + expect(record?.status).toBe("error"); + expect(record?.error).toMatch(/page 3/); + // The per-book cache must be cleared so the reader never sees an + // incomplete download (partial caches are useless for comic reading). + expect(getStore(cacheNameForBook("book-fail"))).toBeUndefined(); + }); + + it("does not start additional pages after the first failure (in-flight workers exit)", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const n = parsePageNumber(url); + if (n === 2) return new Response("nope", { status: 500 }); + return makePageResponse(new Uint8Array([n])); + }); + + await expect( + downloadComicBook({ + bookId: "book-stop", + format: "cbz", + pageCount: 100, + concurrency: 2, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toThrow(/HTTP 500/); + + // With concurrency=2 and an immediate failure on page 2, the worker + // pool should not have fanned out to anywhere near all 100 pages. + expect(fakeFetch.mock.calls.length).toBeLessThan(20); + }); +}); + +describe("downloadComicBook: cancellation", () => { + it("aborting during the download deletes the IDB row and the per-book cache", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const controller = new AbortController(); + + const fakeFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : (input as URL).toString(); + const n = parsePageNumber(url); + // Abort once page 3 starts; pages already-finished stay in cache + // (the cleanup runs after Promise.all resolves). + if (n === 3) controller.abort(); + if (init?.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + return makePageResponse(new Uint8Array([n])); + }, + ); + + await expect( + downloadComicBook({ + bookId: "book-abort", + format: "cbz", + pageCount: 10, + concurrency: 1, + signal: controller.signal, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(await getDownload("book-abort")).toBeUndefined(); + expect(getStore(cacheNameForBook("book-abort"))).toBeUndefined(); + }); +}); + +// -- Storage persistence (T9) -------------------------------------------- + +describe("requestStoragePersistence", () => { + it("calls persist() once and caches the result for subsequent calls", async () => { + const persistSpy = vi.fn(async () => true); + const fakeStorage = { persist: persistSpy } as unknown as StorageManager; + + const first = await requestStoragePersistence(fakeStorage); + const second = await requestStoragePersistence(fakeStorage); + + expect(first).toBe(true); + expect(second).toBe(true); + expect(persistSpy).toHaveBeenCalledTimes(1); + expect(getStoragePersistence()).toBe(true); + }); + + it("returns null when the StorageManager API is unavailable", async () => { + const result = await requestStoragePersistence( + undefined as unknown as StorageManager, + ); + expect(result).toBeNull(); + expect(getStoragePersistence()).toBeNull(); + }); + + it("returns false when persist() throws", async () => { + const fakeStorage = { + persist: vi.fn(async () => { + throw new Error("denied"); + }), + } as unknown as StorageManager; + + const result = await requestStoragePersistence(fakeStorage); + expect(result).toBe(false); + expect(getStoragePersistence()).toBe(false); + }); + + it("returns false when persist() resolves to false (denied)", async () => { + const fakeStorage = { + persist: vi.fn(async () => false), + } as unknown as StorageManager; + + const result = await requestStoragePersistence(fakeStorage); + expect(result).toBe(false); + expect(getStoragePersistence()).toBe(false); + }); + + it("deduplicates concurrent in-flight calls", async () => { + let resolvePersist: ((granted: boolean) => void) | null = null; + const persistSpy = vi.fn( + () => + new Promise((res) => { + resolvePersist = res; + }), + ); + const fakeStorage = { persist: persistSpy } as unknown as StorageManager; + + const a = requestStoragePersistence(fakeStorage); + const b = requestStoragePersistence(fakeStorage); + expect(persistSpy).toHaveBeenCalledTimes(1); + + resolvePersist?.(true); + expect(await a).toBe(true); + expect(await b).toBe(true); + }); +}); + +describe("downloadSingleFileBook + persistence", () => { + it("requests storage persistence on first successful download", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const persistSpy = vi.fn(async () => true); + Object.defineProperty(globalThis.navigator, "storage", { + configurable: true, + value: { persist: persistSpy } as unknown as StorageManager, + }); + + const fakeFetch = vi.fn(async () => + makeStreamingResponse([new Uint8Array([1])], { contentLength: 1 }), + ); + + try { + await downloadSingleFileBook({ + bookId: "book-persist", + format: "epub", + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + // Allow the fire-and-forget persistence request to settle before + // asserting the spy was called. + await new Promise((r) => setTimeout(r, 0)); + expect(persistSpy).toHaveBeenCalledTimes(1); + expect(getStoragePersistence()).toBe(true); + } finally { + Object.defineProperty(globalThis.navigator, "storage", { + configurable: true, + value: undefined, + }); + } + }); +}); diff --git a/web/src/lib/offline/downloadManager.ts b/web/src/lib/offline/downloadManager.ts new file mode 100644 index 00000000..4bb806b0 --- /dev/null +++ b/web/src/lib/offline/downloadManager.ts @@ -0,0 +1,444 @@ +/** + * Page-side download manager for the offline-reading feature (Phase 12). + * + * Two entry points cover every book format Codex supports: + * + * - `downloadSingleFileBook` (T3) for EPUB and PDF, which the backend serves + * as one response from `/api/v1/books/{id}/file`. The body is streamed via + * `ReadableStream.getReader()` so progress reports against `Content-Length` + * when present; the assembled response is stored in a per-book Cache. + * + * - `downloadComicBook` (T4) for CBZ/CBR, which the backend serves one page + * at a time from `/api/v1/books/{id}/pages/{n}`. Pages are fetched with + * bounded concurrency; progress is reported as pages-done/pages-total. + * + * Both flows write the IDB row as `downloading` immediately, then flip it to + * `complete` once everything lands. Abort cleans up the IDB row and the + * per-book cache so a retry starts from a clean slate. Any other failure + * (network throw, non-2xx response, mid-stream error, per-page 404) sets + * the IDB row to `error` with the message preserved for the T7 Downloads + * page to surface, and removes the partial cache so the reader never sees + * a half-downloaded book. + * + * Series batch (T5) is a queue around these functions; it is not in this + * module. + */ + +import { + broadcastDownloadsChange, + type DownloadRecord, + deleteDownload, + putDownload, +} from "./db"; +import { cacheNameForBook } from "./routeMatcher"; + +export type SingleFileFormat = "epub" | "pdf"; +export type ComicFormat = "cbz" | "cbr"; +export type DownloadableFormat = SingleFileFormat | ComicFormat; + +export interface ProgressUpdate { + /** + * Units depend on the download flow: + * - Single-file: bytes received so far. + * - Comic: pages fetched so far. + */ + loaded: number; + /** + * Single-file: total bytes from `Content-Length`; `null` if the header + * is missing. Comic: total page count. + */ + total: number | null; +} + +export interface SingleFileDownloadOptions { + bookId: string; + format: SingleFileFormat; + /** Cancels the download. Cleans up IDB and cache on cancellation. */ + signal?: AbortSignal; + /** Invoked after every chunk arrives. */ + onProgress?: (progress: ProgressUpdate) => void; + /** Injection points for testing. Default to global `fetch` / `caches`. */ + fetch?: typeof globalThis.fetch; + caches?: CacheStorage; +} + +export interface DownloadResult { + bookId: string; + bytes: number; +} + +/** @deprecated Use `DownloadResult`. Kept for back-compat. */ +export type SingleFileDownloadResult = DownloadResult; + +function bookFileUrl(bookId: string): string { + return `/api/v1/books/${bookId}/file`; +} + +function bookPageUrl(bookId: string, pageNumber: number): string { + return `/api/v1/books/${bookId}/pages/${pageNumber}`; +} + +// -- Storage persistence (T9) -------------------------------------------- + +/** + * Result of `navigator.storage.persist()` for the current session. + * + * - `null`: not yet attempted (no successful download in this session, or + * the StorageManager API is not available). + * - `true`: persist was granted; the browser will not evict our data under + * ordinary storage pressure. + * - `false`: persist was denied (typical for non-installed PWAs on Safari + * and for tabs that have not built up enough engagement on Chromium). + */ +export type StoragePersistence = boolean | null; + +let cachedPersistResult: StoragePersistence = null; +let persistInFlight: Promise | null = null; + +/** + * Returns the cached `navigator.storage.persist()` result without making a + * new request. Used by the Downloads page (T7) to render the durability + * indicator without forcing a re-prompt. + */ +export function getStoragePersistence(): StoragePersistence { + return cachedPersistResult; +} + +/** + * Requests persistent storage if it has not been requested this session. + * Idempotent: subsequent calls return the cached result without re-asking + * the browser. Falls through silently in environments without the + * StorageManager API (older Safari, jsdom without injection). + * + * Exposed primarily so the Downloads page (T7) can opportunistically + * trigger the prompt when a user lands there, even if they haven't + * downloaded anything yet. The download flows below also call this after + * each successful completion. + */ +export async function requestStoragePersistence( + storage?: StorageManager, +): Promise { + if (cachedPersistResult !== null) return cachedPersistResult; + if (persistInFlight) return persistInFlight; + + const storageManager = storage ?? globalThis.navigator?.storage; + if (!storageManager || typeof storageManager.persist !== "function") { + return null; + } + + persistInFlight = (async () => { + try { + const granted = await storageManager.persist(); + cachedPersistResult = granted; + return granted; + } catch { + // Some browsers reject persist() under restricted contexts; treat + // that as "not granted" rather than letting it propagate. + cachedPersistResult = false; + return false; + } finally { + persistInFlight = null; + } + })(); + + return persistInFlight; +} + +/** + * Reset the cached persist result. Test-only. + */ +export function _resetPersistenceForTests(): void { + cachedPersistResult = null; + persistInFlight = null; +} + +export async function downloadSingleFileBook( + options: SingleFileDownloadOptions, +): Promise { + const { bookId, format, signal, onProgress } = options; + const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + const cachesImpl = options.caches ?? globalThis.caches; + if (!cachesImpl) { + throw new Error("Cache Storage is not available in this environment"); + } + + const url = bookFileUrl(bookId); + + const startRecord: DownloadRecord = { + id: bookId, + format, + status: "downloading", + bytes: 0, + pageCount: 1, + }; + await putDownload(startRecord); + broadcastDownloadsChange({ kind: "put", record: startRecord }); + + let response: Response; + try { + response = await fetchImpl(url, { signal }); + } catch (err) { + if (signal?.aborted) { + await cleanupAfterAbort(bookId, cachesImpl); + throw abortError(err); + } + await recordError(startRecord, err); + throw normalizeError(err); + } + + if (!response.ok) { + const err = new Error(`HTTP ${response.status} fetching ${url}`); + await recordError(startRecord, err); + throw err; + } + + const body = response.body; + if (!body) { + const err = new Error(`No response body for ${url}`); + await recordError(startRecord, err); + throw err; + } + + const totalHeader = response.headers.get("content-length"); + const total = totalHeader ? Number(totalHeader) : null; + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + loaded += value.length; + onProgress?.({ loaded, total: total ?? null }); + } + } + } catch (err) { + if (signal?.aborted) { + await cleanupAfterAbort(bookId, cachesImpl); + throw abortError(err); + } + await recordError(startRecord, err); + throw normalizeError(err); + } + + // Build a fresh Response so the cache key is stable and the headers + // (especially Content-Type) match what the server sent. Concatenate the + // chunks into a single Uint8Array rather than wrapping them in a Blob: + // jsdom's Response constructor stringifies Blob inputs ("[object Blob]") + // whereas both jsdom and the browser handle Uint8Array bodies correctly. + const merged = concatChunks(chunks, loaded); + const cached = new Response(merged, { + status: 200, + statusText: response.statusText || "OK", + headers: response.headers, + }); + + const cache = await cachesImpl.open(cacheNameForBook(bookId)); + await cache.put(url, cached); + + const completeRecord: DownloadRecord = { + id: bookId, + format, + status: "complete", + bytes: loaded, + pageCount: 1, + downloadedAt: Date.now(), + }; + await putDownload(completeRecord); + broadcastDownloadsChange({ kind: "put", record: completeRecord }); + // T9: request persistent storage once per session, opportunistically. + void requestStoragePersistence(); + + return { bookId, bytes: loaded }; +} + +export interface ComicDownloadOptions { + bookId: string; + format: ComicFormat; + /** Total page count from book metadata; must be >= 1. */ + pageCount: number; + signal?: AbortSignal; + /** + * Reports `{ loaded: pagesDone, total: pageCount }` after each page lands. + */ + onProgress?: (progress: ProgressUpdate) => void; + /** Max concurrent page fetches. Defaults to 5. */ + concurrency?: number; + fetch?: typeof globalThis.fetch; + caches?: CacheStorage; +} + +/** + * Default concurrency for per-page comic downloads. Tuned to balance + * throughput against the backend's per-client connection budget and to + * stay below most browsers' default 6-connection-per-origin limit. + */ +const DEFAULT_COMIC_CONCURRENCY = 5; + +export async function downloadComicBook( + options: ComicDownloadOptions, +): Promise { + const { + bookId, + format, + pageCount, + signal, + onProgress, + concurrency = DEFAULT_COMIC_CONCURRENCY, + } = options; + const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + const cachesImpl = options.caches ?? globalThis.caches; + if (!cachesImpl) { + throw new Error("Cache Storage is not available in this environment"); + } + if (!Number.isInteger(pageCount) || pageCount < 1) { + throw new Error(`Invalid pageCount: ${pageCount}`); + } + + const startRecord: DownloadRecord = { + id: bookId, + format, + status: "downloading", + bytes: 0, + pageCount, + }; + await putDownload(startRecord); + broadcastDownloadsChange({ kind: "put", record: startRecord }); + + // Compose external + internal abort signals so a per-page failure can + // cancel the in-flight siblings without affecting the caller's signal. + const internalController = new AbortController(); + const externalAbortHandler = () => internalController.abort(); + if (signal) { + if (signal.aborted) internalController.abort(); + else signal.addEventListener("abort", externalAbortHandler); + } + + const cache = await cachesImpl.open(cacheNameForBook(bookId)); + + let totalBytes = 0; + let pagesDone = 0; + let firstFailure: Error | null = null; + let nextIndex = 0; + + async function worker() { + while (true) { + const i = nextIndex++; + if (i >= pageCount) return; + if (internalController.signal.aborted) return; + const pageNumber = i + 1; + const url = bookPageUrl(bookId, pageNumber); + try { + const response = await fetchImpl(url, { + signal: internalController.signal, + }); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} fetching page ${pageNumber} of book ${bookId}`, + ); + } + const buffer = await response.arrayBuffer(); + const body = new Uint8Array(buffer); + const headers = new Headers(response.headers); + const cached = new Response(body, { + status: 200, + statusText: response.statusText || "OK", + headers, + }); + await cache.put(url, cached); + totalBytes += body.byteLength; + pagesDone += 1; + onProgress?.({ loaded: pagesDone, total: pageCount }); + } catch (err) { + if (firstFailure === null && !signal?.aborted) { + firstFailure = err instanceof Error ? err : new Error(String(err)); + } + internalController.abort(); + return; + } + } + } + + try { + const workerCount = Math.min(Math.max(1, concurrency), pageCount); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + } finally { + if (signal) signal.removeEventListener("abort", externalAbortHandler); + } + + if (signal?.aborted) { + await cleanupAfterAbort(bookId, cachesImpl); + throw abortError(undefined); + } + if (firstFailure) { + await recordError(startRecord, firstFailure); + // Partial caches are useless for reading (the reader needs every page), + // so evict the whole per-book cache. The IDB row stays at status=error + // so the Downloads page (T7) can show what went wrong. + await cachesImpl.delete(cacheNameForBook(bookId)); + throw firstFailure; + } + + const completeRecord: DownloadRecord = { + id: bookId, + format, + status: "complete", + bytes: totalBytes, + pageCount, + downloadedAt: Date.now(), + }; + await putDownload(completeRecord); + broadcastDownloadsChange({ kind: "put", record: completeRecord }); + // T9: request persistent storage once per session, opportunistically. + void requestStoragePersistence(); + + return { bookId, bytes: totalBytes }; +} + +async function recordError(base: DownloadRecord, err: unknown): Promise { + const errorRecord: DownloadRecord = { + ...base, + status: "error", + error: err instanceof Error ? err.message : String(err), + }; + await putDownload(errorRecord); + broadcastDownloadsChange({ kind: "put", record: errorRecord }); +} + +async function cleanupAfterAbort( + bookId: string, + cachesImpl: CacheStorage, +): Promise { + await deleteDownload(bookId); + broadcastDownloadsChange({ kind: "delete", id: bookId }); + await cachesImpl.delete(cacheNameForBook(bookId)); +} + +function abortError(original: unknown): DOMException { + if (original instanceof DOMException && original.name === "AbortError") { + return original; + } + return new DOMException("Download aborted", "AbortError"); +} + +function normalizeError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +function concatChunks( + chunks: Uint8Array[], + total: number, +): Uint8Array { + // Force `Uint8Array` (not `Uint8Array`) so + // the value satisfies `BodyInit`'s `BufferSource` constraint in TS 5.7+. + const out = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} diff --git a/web/src/lib/offline/installNudge.test.ts b/web/src/lib/offline/installNudge.test.ts new file mode 100644 index 00000000..3f4b41e4 --- /dev/null +++ b/web/src/lib/offline/installNudge.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + INSTALL_NUDGE_DISMISSED_KEY, + INSTALL_NUDGE_TTL_MS, + isIosUserAgent, + isNudgeDismissed, + isStandaloneDisplay, + recordNudgeDismissal, + shouldShowInstallNudge, +} from "./installNudge"; + +const ORIGINAL_UA = navigator.userAgent; +const ORIGINAL_PLATFORM = navigator.platform; + +function setUserAgent(ua: string, platform: string = ORIGINAL_PLATFORM): void { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: ua, + }); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + setUserAgent(ORIGINAL_UA, ORIGINAL_PLATFORM); + vi.restoreAllMocks(); +}); + +describe("isIosUserAgent", () => { + it("returns true for iPhone UA", () => { + setUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605", + ); + expect(isIosUserAgent()).toBe(true); + }); + + it("returns false for desktop Chrome UA", () => { + setUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"); + expect(isIosUserAgent()).toBe(false); + }); + + it("treats iPadOS (MacIntel with touch points) as iOS", () => { + Object.defineProperty(navigator, "maxTouchPoints", { + configurable: true, + value: 5, + }); + setUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605", + "MacIntel", + ); + expect(isIosUserAgent()).toBe(true); + }); +}); + +describe("isStandaloneDisplay", () => { + it("returns false in jsdom by default", () => { + expect(isStandaloneDisplay()).toBe(false); + }); +}); + +describe("dismissal persistence", () => { + it("isNudgeDismissed is false when nothing has been written", () => { + expect(isNudgeDismissed()).toBe(false); + }); + + it("recordNudgeDismissal writes a timestamp", () => { + recordNudgeDismissal(1_000_000); + expect(window.localStorage.getItem(INSTALL_NUDGE_DISMISSED_KEY)).toBe( + "1000000", + ); + }); + + it("isNudgeDismissed is true within the TTL window", () => { + recordNudgeDismissal(1_000_000); + expect(isNudgeDismissed(1_000_000 + 1000)).toBe(true); + }); + + it("isNudgeDismissed is false after the TTL expires", () => { + recordNudgeDismissal(1_000_000); + expect(isNudgeDismissed(1_000_000 + INSTALL_NUDGE_TTL_MS + 1)).toBe(false); + }); + + it("malformed timestamps are treated as not-dismissed", () => { + window.localStorage.setItem(INSTALL_NUDGE_DISMISSED_KEY, "not-a-number"); + expect(isNudgeDismissed()).toBe(false); + }); +}); + +describe("shouldShowInstallNudge", () => { + it("is false on non-iOS browsers", () => { + setUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"); + expect(shouldShowInstallNudge()).toBe(false); + }); + + it("is true on a fresh iOS Safari tab", () => { + setUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605", + ); + expect(shouldShowInstallNudge()).toBe(true); + }); + + it("is false on iOS Safari after dismissal within TTL", () => { + setUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605", + ); + recordNudgeDismissal(Date.now()); + expect(shouldShowInstallNudge()).toBe(false); + }); +}); diff --git a/web/src/lib/offline/installNudge.ts b/web/src/lib/offline/installNudge.ts new file mode 100644 index 00000000..f6c33036 --- /dev/null +++ b/web/src/lib/offline/installNudge.ts @@ -0,0 +1,83 @@ +/** + * iOS Safari install nudge (Phase 12 T10). + * + * On a non-installed iOS Safari tab the browser is allowed to evict our + * IndexedDB + Cache Storage after ~7 days of inactivity, even after + * `navigator.storage.persist()` returns true (it returns false on Safari + * tabs). On every other surface (Chrome tab, Android, installed iOS PWA) + * downloads are durable enough that warning the user would be noise. + * + * This module owns the "should we nudge?" predicate and the dismissal + * persistence. It is intentionally framework-agnostic so any download + * surface (per-book button, series-batch button) can call it before + * kicking off its first download in a session. + * + * Dismissal: + * - Persisted in localStorage under `INSTALL_NUDGE_DISMISSED_KEY` with a + * 30-day TTL, matching the convention used by `InstallPrompt.tsx`. + * - Both "Continue anyway" and "Show me how to install" (after the user + * reads the modal) record dismissal so we do not re-nag every tap. + */ + +export const INSTALL_NUDGE_DISMISSED_KEY = + "codex-offline-install-nudge-dismissed"; +export const INSTALL_NUDGE_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +export function isIosUserAgent(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + const isIPad = + /iPad/.test(ua) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + return /iPhone|iPod/.test(ua) || isIPad; +} + +export function isStandaloneDisplay(): boolean { + if (typeof window === "undefined") return false; + const standaloneMedia = window.matchMedia?.( + "(display-mode: standalone)", + ).matches; + const iosStandalone = + "standalone" in window.navigator && + (window.navigator as { standalone?: boolean }).standalone === true; + return Boolean(standaloneMedia || iosStandalone); +} + +export function isNudgeDismissed(now: number = Date.now()): boolean { + if (typeof window === "undefined") return true; + try { + const raw = window.localStorage.getItem(INSTALL_NUDGE_DISMISSED_KEY); + if (!raw) return false; + const ts = Number.parseInt(raw, 10); + if (Number.isNaN(ts)) return false; + return now - ts < INSTALL_NUDGE_TTL_MS; + } catch { + // Treat storage errors (private mode, etc.) as "dismissed" so we do + // not loop the modal in environments where we cannot record consent. + return true; + } +} + +export function recordNudgeDismissal(now: number = Date.now()): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(INSTALL_NUDGE_DISMISSED_KEY, String(now)); + } catch { + /* storage unavailable — silently ignore */ + } +} + +/** + * Should the iOS install nudge be shown before the next download? + * + * True when the runtime is an iOS Safari tab that has not yet been added + * to the home screen, and the user has not dismissed the modal in the + * past 30 days. Returns false everywhere else (installed PWA, other + * browsers, server-side rendering). + */ +export function shouldShowInstallNudge(): boolean { + if (!isIosUserAgent()) return false; + if (isStandaloneDisplay()) return false; + if (isNudgeDismissed()) return false; + return true; +} diff --git a/web/src/lib/offline/outbox.test.ts b/web/src/lib/offline/outbox.test.ts new file mode 100644 index 00000000..b34c3d4d --- /dev/null +++ b/web/src/lib/offline/outbox.test.ts @@ -0,0 +1,235 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { _resetForTests, clearOutbox, getOutbox, setDbContext } from "./db"; +import { + _resetOutboxLifecycleForTests, + drainOfflineOutbox, + enqueueOfflineWrite, + installOutboxDrainListeners, + isOfflineError, + isOfflineQueuedError, + OfflineQueuedError, +} from "./outbox"; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(async () => { + _resetOutboxLifecycleForTests(); + await clearOutbox().catch(() => {}); + setDbContext(null); + _resetForTests(); +}); + +describe("isOfflineError", () => { + it("recognises the project's ApiError network shape", () => { + expect(isOfflineError({ error: "Network Error", message: "..." })).toBe( + true, + ); + }); + + it("recognises raw axios ERR_NETWORK / ECONNABORTED codes", () => { + expect(isOfflineError({ code: "ERR_NETWORK" })).toBe(true); + expect(isOfflineError({ code: "ECONNABORTED" })).toBe(true); + }); + + it("recognises errors with no response and a network-flavoured message", () => { + expect( + isOfflineError({ + message: "Network request failed", + response: undefined, + }), + ).toBe(true); + expect( + isOfflineError({ message: "fetch failed", response: undefined }), + ).toBe(true); + }); + + it("returns false for server errors (response present)", () => { + expect( + isOfflineError({ error: "Internal", response: { status: 500 } }), + ).toBe(false); + }); + + it("returns true when navigator.onLine is false even on an opaque error", () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis.navigator, + "onLine", + ); + Object.defineProperty(globalThis.navigator, "onLine", { + configurable: true, + value: false, + }); + try { + expect(isOfflineError(new Error("whatever"))).toBe(true); + } finally { + if (originalDescriptor) { + Object.defineProperty( + globalThis.navigator, + "onLine", + originalDescriptor, + ); + } else { + Object.defineProperty(globalThis.navigator, "onLine", { + configurable: true, + value: true, + }); + } + } + }); + + it("returns false for null / non-objects", () => { + expect(isOfflineError(null)).toBe(false); + expect(isOfflineError("string")).toBe(false); + }); +}); + +describe("OfflineQueuedError + isOfflineQueuedError", () => { + it("isOfflineQueuedError narrows the type", () => { + const err = new OfflineQueuedError({ url: "/x", method: "PUT" }); + expect(isOfflineQueuedError(err)).toBe(true); + expect(isOfflineQueuedError(new Error("nope"))).toBe(false); + expect(err.request.url).toBe("/x"); + }); +}); + +describe("enqueueOfflineWrite", () => { + it("normalises method to uppercase and JSON-encodes the body", async () => { + const key = await enqueueOfflineWrite({ + url: "/api/v1/books/abc/progress", + method: "put", + headers: { Authorization: "Bearer t" }, + body: { currentPage: 42, completed: false }, + }); + expect(typeof key).toBe("number"); + const stored = await getOutbox(); + expect(stored).toHaveLength(1); + expect(stored[0]?.request.method).toBe("PUT"); + expect(stored[0]?.request.body).toBe( + JSON.stringify({ currentPage: 42, completed: false }), + ); + expect(stored[0]?.request.headers.Authorization).toBe("Bearer t"); + }); + + it("leaves body undefined when none is provided", async () => { + await enqueueOfflineWrite({ + url: "/x", + method: "DELETE", + }); + const stored = await getOutbox(); + expect(stored[0]?.request.body).toBeUndefined(); + }); +}); + +describe("drainOfflineOutbox", () => { + it("replays each queued request in insertion order and clears the outbox", async () => { + await enqueueOfflineWrite({ url: "/a", method: "PUT" }); + await enqueueOfflineWrite({ url: "/b", method: "PUT" }); + await enqueueOfflineWrite({ url: "/c", method: "PUT" }); + + const sent: string[] = []; + const result = await drainOfflineOutbox(async (record) => { + sent.push(record.request.url); + }); + expect(result).toBe(3); + expect(sent).toEqual(["/a", "/b", "/c"]); + expect(await getOutbox()).toEqual([]); + }); + + it("stops at the first failure (record stays at head, retryCount bumps)", async () => { + await enqueueOfflineWrite({ url: "/ok", method: "PUT" }); + await enqueueOfflineWrite({ url: "/fail", method: "PUT" }); + await enqueueOfflineWrite({ url: "/never", method: "PUT" }); + + const sent = await drainOfflineOutbox(async (record) => { + if (record.request.url === "/fail") throw new Error("boom"); + }); + expect(sent).toBe(1); + const remaining = await getOutbox(); + expect(remaining.map((r) => r.request.url)).toEqual(["/fail", "/never"]); + expect(remaining[0]?.retryCount).toBe(1); + }); + + it("deduplicates concurrent in-flight drains", async () => { + await enqueueOfflineWrite({ url: "/a", method: "PUT" }); + await enqueueOfflineWrite({ url: "/b", method: "PUT" }); + + const seen: string[] = []; + const slowSend = async (record: { request: { url: string } }) => { + seen.push(record.request.url); + await new Promise((res) => setTimeout(res, 0)); + }; + + const drain1 = drainOfflineOutbox(slowSend as never); + const drain2 = drainOfflineOutbox(slowSend as never); + + expect(drain1).toBe(drain2); + const [a, b] = await Promise.all([drain1, drain2]); + expect(a).toBe(2); + expect(b).toBe(2); + expect(seen).toEqual(["/a", "/b"]); + }); +}); + +describe("installOutboxDrainListeners", () => { + it("drains the outbox when the window fires `online`", async () => { + const drainSpy = vi.fn(async (_record: unknown) => undefined); + // Pre-seed two queued writes so the drain has something to do. + await enqueueOfflineWrite({ url: "/a", method: "PUT" }); + await enqueueOfflineWrite({ url: "/b", method: "PUT" }); + + // Default sender uses fetch; stub it instead of relying on a spy because + // the listener-installed handler does not accept a sender argument. + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input) => { + drainSpy({ request: { url: String(input) } }); + return new Response(null, { status: 200 }); + }); + + installOutboxDrainListeners(); + window.dispatchEvent(new Event("online")); + // Poll until the drain has flushed both records (sequential IDB + // round-trips need a few microtask + macrotask ticks beyond a single + // `setTimeout(0)`). + await vi.waitFor(async () => { + expect(await getOutbox()).toEqual([]); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + fetchSpy.mockRestore(); + }); + + it("is idempotent: calling install twice does not register duplicate listeners", async () => { + const addSpy = vi.spyOn(window, "addEventListener"); + installOutboxDrainListeners(); + installOutboxDrainListeners(); + // 1 for `online` + 1 for `visibilitychange` on the document. + // The window-level addEventListener spy only sees `online`. + const onlineCalls = addSpy.mock.calls.filter((c) => c[0] === "online"); + expect(onlineCalls).toHaveLength(1); + addSpy.mockRestore(); + }); + + it("drains on visibilitychange when the tab becomes visible", async () => { + await enqueueOfflineWrite({ url: "/x", method: "PUT" }); + + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + + installOutboxDrainListeners(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + await vi.waitFor(async () => { + expect(await getOutbox()).toEqual([]); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); +}); diff --git a/web/src/lib/offline/outbox.ts b/web/src/lib/offline/outbox.ts new file mode 100644 index 00000000..ecb897ed --- /dev/null +++ b/web/src/lib/offline/outbox.ts @@ -0,0 +1,216 @@ +/** + * Page-side outbox helpers for offline write operations (Phase 12, T6). + * + * The reading-progress mutation client (and other write paths down the + * road) wraps its real network call in a try/catch: on offline failure it + * serialises the request, hands it to {@link enqueueOfflineWrite}, and + * throws an {@link OfflineQueuedError} so the caller knows the write was + * deferred rather than lost. The queue is drained automatically on the + * window `online` event and when the tab returns to `visible`; manual + * drains are also available for tests and explicit "Retry now" UX. + * + * The drain order is sequential and stops at the first failure. Reading + * progress for the same book must apply in the order the user produced it + * (we don't want page 20 to overwrite a later page 25), so parallel drain + * is intentionally avoided. + */ + +import { + drainOutbox as drainOutboxStore, + enqueueOutbox, + type OutboxRecord, +} from "./db"; + +export interface SerialisableRequest { + url: string; + method: string; + /** Optional. Defaults to an empty bag; capture auth headers at enqueue time. */ + headers?: Record; + /** + * JSON-serialisable body. Will be stringified before storage so it + * survives reads from IDB intact. + */ + body?: unknown; +} + +/** + * Thrown by API wrappers after a write has been queued for later delivery. + * Callers catching this can treat the write as "stored locally" rather than + * "failed" and avoid surfacing an error to the user. + */ +export class OfflineQueuedError extends Error { + readonly request: SerialisableRequest; + constructor(request: SerialisableRequest) { + super("Request queued for offline delivery"); + this.name = "OfflineQueuedError"; + this.request = request; + } +} + +export function isOfflineQueuedError(err: unknown): err is OfflineQueuedError { + return err instanceof OfflineQueuedError; +} + +/** + * Heuristic for "the request never reached a server, so queueing it makes + * sense" versus "the server replied with an error, queueing won't help". + * Recognises both the project's ApiError shape (`{ error: "Network Error" }`, + * produced by [api/client.ts](../../api/client.ts) when axios sees no response) + * and raw axios errors for cases that bypass the interceptor. + */ +export function isOfflineError(err: unknown): boolean { + if (typeof navigator !== "undefined" && navigator.onLine === false) { + return true; + } + if (!err || typeof err !== "object") return false; + const e = err as { + error?: string; + code?: string; + response?: unknown; + message?: string; + }; + if (e.error === "Network Error") return true; + if (e.code === "ERR_NETWORK") return true; + if (e.code === "ECONNABORTED") return true; + if (e.response === undefined && typeof e.message === "string") { + const lower = e.message.toLowerCase(); + if (lower.includes("network") || lower.includes("fetch failed")) { + return true; + } + } + return false; +} + +/** + * Persist a request to the outbox store and return its row id. + * + * Headers and body are normalised so the drain step can replay them with a + * plain `fetch()` call: + * - `headers` is shallow-copied into a `Record`. + * - `body` is JSON-stringified (undefined remains undefined). + */ +export async function enqueueOfflineWrite( + request: SerialisableRequest, +): Promise { + const headers = request.headers ? { ...request.headers } : {}; + const body = + request.body === undefined ? undefined : JSON.stringify(request.body); + return enqueueOutbox({ + url: request.url, + method: request.method.toUpperCase(), + headers, + body, + }); +} + +/** + * Sender used by {@link drainOfflineOutbox}. Tests inject a mock; production + * defaults to {@link defaultDrainSender} which uses plain `fetch()` so the + * outbox module avoids depending on axios. + */ +export type OutboxSender = (record: OutboxRecord) => Promise; + +async function defaultDrainSender(record: OutboxRecord): Promise { + const init: RequestInit = { + method: record.request.method, + headers: record.request.headers, + credentials: "include", + }; + if (record.request.body !== undefined) { + init.body = record.request.body; + } + const response = await fetch(record.request.url, init); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} replaying ${record.request.method} ${record.request.url}`, + ); + } +} + +let drainInFlight: Promise | null = null; + +/** + * Drain the outbox sequentially. Concurrent calls share one in-flight + * promise so a flurry of `online` + `visibilitychange` events do not start + * overlapping drains. + * + * Resolves with the number of records successfully replayed. A drain that + * fails partway through still resolves (the failing record stays at the + * head of the queue with its retry count bumped — see + * [db.ts](./db.ts#drainOutbox)). + */ +export function drainOfflineOutbox( + send: OutboxSender = defaultDrainSender, +): Promise { + // Not declared `async` so the returned promise is the same reference for + // every concurrent call (otherwise the implicit async wrapper produces a + // fresh promise per invocation and the dedupe contract leaks). + if (drainInFlight) return drainInFlight; + drainInFlight = (async () => { + try { + return await drainOutboxStore(send); + } finally { + drainInFlight = null; + } + })(); + return drainInFlight; +} + +let listenersInstalled = false; +let installedOnline: (() => void) | null = null; +let installedVisibility: (() => void) | null = null; + +/** + * Install global `online` and `visibilitychange` listeners that drain the + * outbox automatically. Safe to call more than once: subsequent calls are + * no-ops and return the same teardown function. Returns the teardown + * function for tests that need to uninstall. + */ +export function installOutboxDrainListeners(): () => void { + if (listenersInstalled) return uninstallOutboxDrainListeners; + if (typeof window === "undefined" || typeof document === "undefined") { + return uninstallOutboxDrainListeners; + } + + const onOnline = () => { + void drainOfflineOutbox().catch(() => { + // Swallow: drainOutbox already handles per-record retry bookkeeping, + // and we don't want a transient failure to leak unhandled rejections + // into the browser console. + }); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") { + void drainOfflineOutbox().catch(() => {}); + } + }; + + window.addEventListener("online", onOnline); + document.addEventListener("visibilitychange", onVisibility); + installedOnline = onOnline; + installedVisibility = onVisibility; + listenersInstalled = true; + + return uninstallOutboxDrainListeners; +} + +export function uninstallOutboxDrainListeners(): void { + if (!listenersInstalled) return; + if (installedOnline && typeof window !== "undefined") { + window.removeEventListener("online", installedOnline); + } + if (installedVisibility && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", installedVisibility); + } + installedOnline = null; + installedVisibility = null; + listenersInstalled = false; +} + +/** + * Reset transient state. Test-only. + */ +export function _resetOutboxLifecycleForTests(): void { + uninstallOutboxDrainListeners(); + drainInFlight = null; +} diff --git a/web/src/lib/offline/prefetchWindow.test.ts b/web/src/lib/offline/prefetchWindow.test.ts new file mode 100644 index 00000000..ffd035c8 --- /dev/null +++ b/web/src/lib/offline/prefetchWindow.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + getEffectivePreloadWindow, + MAX_PREFETCH_PAGES, + MIN_PREFETCH_DOWNLOADED, + MIN_PREFETCH_NOT_DOWNLOADED, +} from "./prefetchWindow"; + +describe("getEffectivePreloadWindow", () => { + it("respects the user setting when above the not-downloaded floor", () => { + expect(getEffectivePreloadWindow(7, false)).toBe(7); + }); + + it("raises a low user setting to the not-downloaded floor", () => { + expect(getEffectivePreloadWindow(1, false)).toBe( + MIN_PREFETCH_NOT_DOWNLOADED, + ); + }); + + it("widens to the downloaded floor when the book is in the cache", () => { + expect(getEffectivePreloadWindow(1, true)).toBe(MIN_PREFETCH_DOWNLOADED); + }); + + it("clamps user settings above the max", () => { + expect(getEffectivePreloadWindow(99, false)).toBe(MAX_PREFETCH_PAGES); + expect(getEffectivePreloadWindow(99, true)).toBe(MAX_PREFETCH_PAGES); + }); + + it("clamps negative user settings to the floor (never below 0)", () => { + expect(getEffectivePreloadWindow(-5, false)).toBe( + MIN_PREFETCH_NOT_DOWNLOADED, + ); + }); +}); diff --git a/web/src/lib/offline/prefetchWindow.ts b/web/src/lib/offline/prefetchWindow.ts new file mode 100644 index 00000000..025864d7 --- /dev/null +++ b/web/src/lib/offline/prefetchWindow.ts @@ -0,0 +1,53 @@ +/** + * Reader prefetch-window helper (Phase 12 T11). + * + * The Comic reader's preload-pages setting (`useReaderStore.settings.preloadPages`) + * defaults to 1 and is user-clamped to 0-10. That default is fine for desktop + * with a wired connection but punishes mobile readers on cellular: a tap to + * the next page hits the network rather than a primed image cache. + * + * This helper widens the effective window in two cases: + * + * - The book is downloaded (per the IDB downloads store). Every page is in + * the SW's CacheFirst route already, so we can preload aggressively at + * zero network cost — primes the browser's image decoder. + * - The book is not downloaded but the user is reading on cellular. Force + * a minimum window so the in-session experience is responsive even when + * `preloadPages` is set low. + * + * Pure function so the React effect in `ComicReader.tsx` can call it inline + * without an extra hook, and so the unit test can exercise it without a + * full reader render. + */ + +/** + * Maximum effective preload size. Matches the user-facing slider cap in + * `readerStore.ts` so the helper never asks for a wider window than the + * existing UI exposes. + */ +export const MAX_PREFETCH_PAGES = 10; + +/** + * Minimum window when the book is not downloaded. Per the Phase 12 plan: + * "extend the existing prefetch logic from the current small window to 5-10 + * pages so the in-session experience improves on cellular regardless of + * download status." + */ +export const MIN_PREFETCH_NOT_DOWNLOADED = 5; + +/** + * Minimum window when the book *is* downloaded. SW cache hits are free, so + * prime aggressively up to the cap. + */ +export const MIN_PREFETCH_DOWNLOADED = MAX_PREFETCH_PAGES; + +export function getEffectivePreloadWindow( + userSetting: number, + isDownloaded: boolean, +): number { + const floor = isDownloaded + ? MIN_PREFETCH_DOWNLOADED + : MIN_PREFETCH_NOT_DOWNLOADED; + const effective = Math.max(userSetting, floor); + return Math.min(MAX_PREFETCH_PAGES, Math.max(0, effective)); +} diff --git a/web/src/lib/offline/routeMatcher.test.ts b/web/src/lib/offline/routeMatcher.test.ts new file mode 100644 index 00000000..8d653842 --- /dev/null +++ b/web/src/lib/offline/routeMatcher.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { cacheNameForBook, matchDownloadedBookRequest } from "./routeMatcher"; + +function u(path: string): URL { + return new URL(`https://example.com${path}`); +} + +describe("matchDownloadedBookRequest", () => { + const downloaded = new Set(["abc", "xyz-123"]); + + it("matches a /pages/N request for a downloaded book", () => { + const result = matchDownloadedBookRequest( + u("/api/v1/books/abc/pages/7"), + "GET", + downloaded, + ); + expect(result).toEqual({ + bookId: "abc", + resource: { kind: "page", number: 7 }, + }); + }); + + it("matches a /file request for a downloaded book", () => { + const result = matchDownloadedBookRequest( + u("/api/v1/books/xyz-123/file"), + "GET", + downloaded, + ); + expect(result).toEqual({ + bookId: "xyz-123", + resource: { kind: "file" }, + }); + }); + + it("returns null for books that are not in the downloaded set", () => { + expect( + matchDownloadedBookRequest( + u("/api/v1/books/not-downloaded/file"), + "GET", + downloaded, + ), + ).toBeNull(); + }); + + it("returns null for non-GET methods even when the book is downloaded", () => { + expect( + matchDownloadedBookRequest( + u("/api/v1/books/abc/file"), + "PUT", + downloaded, + ), + ).toBeNull(); + expect( + matchDownloadedBookRequest( + u("/api/v1/books/abc/pages/1"), + "DELETE", + downloaded, + ), + ).toBeNull(); + }); + + it("does not match unrelated API paths", () => { + expect( + matchDownloadedBookRequest(u("/api/v1/books/abc"), "GET", downloaded), + ).toBeNull(); + expect( + matchDownloadedBookRequest( + u("/api/v1/books/abc/thumbnail"), + "GET", + downloaded, + ), + ).toBeNull(); + expect( + matchDownloadedBookRequest( + u("/api/v1/series/abc/file"), + "GET", + downloaded, + ), + ).toBeNull(); + }); + + it("does not match versioned paths outside /v1/", () => { + expect( + matchDownloadedBookRequest( + u("/api/v2/books/abc/file"), + "GET", + downloaded, + ), + ).toBeNull(); + }); + + it("does not match a /pages path with a non-numeric segment", () => { + expect( + matchDownloadedBookRequest( + u("/api/v1/books/abc/pages/foo"), + "GET", + downloaded, + ), + ).toBeNull(); + }); + + it("returns null for an empty downloaded set", () => { + expect( + matchDownloadedBookRequest( + u("/api/v1/books/abc/file"), + "GET", + new Set(), + ), + ).toBeNull(); + }); + + it("ignores query strings and hash fragments", () => { + const result = matchDownloadedBookRequest( + u("/api/v1/books/abc/pages/3?x=1#y"), + "GET", + downloaded, + ); + expect(result?.bookId).toBe("abc"); + expect(result?.resource).toEqual({ kind: "page", number: 3 }); + }); +}); + +describe("cacheNameForBook", () => { + it("produces a deterministic per-book cache name", () => { + expect(cacheNameForBook("abc")).toBe("codex-book-abc"); + expect(cacheNameForBook("xyz-123")).toBe("codex-book-xyz-123"); + }); +}); diff --git a/web/src/lib/offline/routeMatcher.ts b/web/src/lib/offline/routeMatcher.ts new file mode 100644 index 00000000..b9ff0fb5 --- /dev/null +++ b/web/src/lib/offline/routeMatcher.ts @@ -0,0 +1,61 @@ +/** + * Pure URL matcher used by the service worker to decide whether a request + * for a book resource should be served from the per-book offline cache. + * + * Extracted from sw.ts so it can be unit-tested without a SW environment. + */ + +export interface PageResource { + kind: "page"; + number: number; +} + +export interface FileResource { + kind: "file"; +} + +export type BookResource = PageResource | FileResource; + +export interface DownloadedBookMatch { + bookId: string; + resource: BookResource; +} + +// Matches /api/v1/books/{id}/pages/{n} OR /api/v1/books/{id}/file. +// Captures the id and (optionally) the page number. +const BOOK_RESOURCE_PATTERN = + /^\/api\/v1\/books\/([^/]+)\/(?:pages\/(\d+)|file)$/; + +/** + * Return a match descriptor if `url` is a book-resource request for a book + * that is currently downloaded; otherwise null. + * + * Only GET requests are matched. Other methods (PUT for progress, DELETE) + * always go through to the network so server state stays canonical. + */ +export function matchDownloadedBookRequest( + url: URL, + method: string, + downloadedIds: ReadonlySet, +): DownloadedBookMatch | null { + if (method !== "GET") return null; + const match = BOOK_RESOURCE_PATTERN.exec(url.pathname); + if (!match) return null; + const bookId = match[1]; + const pageStr = match[2]; + if (!bookId || !downloadedIds.has(bookId)) return null; + return { + bookId, + resource: pageStr + ? { kind: "page", number: Number(pageStr) } + : { kind: "file" }, + }; +} + +/** + * Cache name for a single book's resources. Per-book naming makes eviction + * a single `caches.delete()` call. + */ +export function cacheNameForBook(bookId: string): string { + return `codex-book-${bookId}`; +} diff --git a/web/src/lib/offline/seriesDownloadQueue.test.ts b/web/src/lib/offline/seriesDownloadQueue.test.ts new file mode 100644 index 00000000..7317f83a --- /dev/null +++ b/web/src/lib/offline/seriesDownloadQueue.test.ts @@ -0,0 +1,508 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetForTests, + getAllDownloads, + getDownload, + setDbContext, +} from "./db"; +import { _resetPersistenceForTests } from "./downloadManager"; +import { cacheNameForBook } from "./routeMatcher"; +import { + type BookQueueState, + downloadSeriesBatch, + estimateBookBytes, + preflightQuota, + QuotaExceededError, + type SeriesBookSummary, + type SeriesQueueState, +} from "./seriesDownloadQueue"; + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + _resetPersistenceForTests(); +}); + +// -- Fakes (mirror downloadManager.test.ts) ------------------------------ + +interface CacheEntry { + body: Uint8Array; + status: number; + headers: Record; +} + +function makeFakeCaches() { + const stores = new Map>(); + const cachesImpl = { + async open(name: string): Promise { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + const cache: Partial = { + put: async (request, response) => { + const url = + typeof request === "string" ? request : (request as Request).url; + const buffer = await response.arrayBuffer(); + const headerObj: Record = {}; + response.headers.forEach((value, key) => { + headerObj[key] = value; + }); + store!.set(url, { + body: new Uint8Array(buffer), + status: response.status, + headers: headerObj, + }); + }, + match: async (request) => { + const url = + typeof request === "string" ? request : (request as Request).url; + const entry = store!.get(url); + if (!entry) return undefined; + return new Response(entry.body, { + status: entry.status, + headers: entry.headers, + }); + }, + }; + return cache as Cache; + }, + async delete(name: string): Promise { + return stores.delete(name); + }, + } as Partial; + return { + caches: cachesImpl as CacheStorage, + getStore: (name: string) => stores.get(name), + }; +} + +function makeStreamingResponse( + chunks: Uint8Array[], + init: { contentLength?: number; status?: number } = {}, +): Response { + const headers = new Headers(); + if (init.contentLength !== undefined) { + headers.set("content-length", String(init.contentLength)); + } + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + return new Response(stream, { + status: init.status ?? 200, + headers, + }); +} + +/** + * Build a fetch that resolves single-file downloads with a small payload, + * and resolves comic page downloads with a one-byte body equal to the page + * number. Tracks the per-book request count so we can assert one cache hit + * per book. + */ +function makeFakeFetch() { + const calls: string[] = []; + const fakeFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + calls.push(url); + if (init?.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + if (/\/pages\/(\d+)$/.test(url)) { + const n = Number(url.match(/\/pages\/(\d+)$/)![1]); + return new Response(new Uint8Array([n]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + } + // Single-file: tiny EPUB/PDF body. + return makeStreamingResponse([new Uint8Array([1, 2, 3, 4])], { + contentLength: 4, + }); + }, + ); + return { fakeFetch, calls }; +} + +// -- estimateBookBytes --------------------------------------------------- + +describe("estimateBookBytes", () => { + it("uses fileSize for EPUB when available", () => { + expect( + estimateBookBytes({ + id: "x", + fileFormat: "epub", + pageCount: 1, + fileSize: 5 * 1024 * 1024, + }), + ).toBe(5 * 1024 * 1024); + }); + + it("falls back to one avgPageBytes for EPUB when fileSize missing", () => { + expect( + estimateBookBytes({ id: "x", fileFormat: "pdf", pageCount: 1 }, 1000), + ).toBe(1000); + }); + + it("uses pageCount * avgPageBytes for comics", () => { + expect( + estimateBookBytes({ id: "x", fileFormat: "cbz", pageCount: 20 }, 500), + ).toBe(10_000); + }); + + it("returns 0 for unknown formats", () => { + expect( + estimateBookBytes({ id: "x", fileFormat: "mobi", pageCount: 100 }), + ).toBe(0); + }); +}); + +// -- preflightQuota ------------------------------------------------------ + +describe("preflightQuota", () => { + it("passes when usage + estimated <= quota * threshold", async () => { + const storage = { + estimate: vi.fn(async () => ({ usage: 0, quota: 1_000_000 })), + } as unknown as StorageManager; + await expect( + preflightQuota( + [{ id: "1", fileFormat: "epub", pageCount: 1, fileSize: 100_000 }], + { storage, quotaThreshold: 0.9 }, + ), + ).resolves.toBeUndefined(); + }); + + it("throws QuotaExceededError when projected usage exceeds threshold", async () => { + const storage = { + estimate: vi.fn(async () => ({ usage: 800_000, quota: 1_000_000 })), + } as unknown as StorageManager; + await expect( + preflightQuota( + [{ id: "1", fileFormat: "epub", pageCount: 1, fileSize: 200_000 }], + { storage, quotaThreshold: 0.9 }, + ), + ).rejects.toBeInstanceOf(QuotaExceededError); + }); + + it("treats missing StorageManager as unknown and lets the queue proceed", async () => { + await expect( + preflightQuota( + [{ id: "1", fileFormat: "epub", pageCount: 1, fileSize: 999 }], + { storage: undefined as unknown as StorageManager }, + ), + ).resolves.toBeUndefined(); + }); + + it("treats a 0-quota estimate as unknown rather than blocking", async () => { + const storage = { + estimate: vi.fn(async () => ({ usage: 0, quota: 0 })), + } as unknown as StorageManager; + await expect( + preflightQuota( + [{ id: "1", fileFormat: "epub", pageCount: 1, fileSize: 1 }], + { storage }, + ), + ).resolves.toBeUndefined(); + }); +}); + +// -- downloadSeriesBatch ------------------------------------------------- + +const books3: SeriesBookSummary[] = [ + { id: "a", fileFormat: "epub", pageCount: 1, fileSize: 4 }, + { id: "b", fileFormat: "epub", pageCount: 1, fileSize: 4 }, + { id: "c", fileFormat: "epub", pageCount: 1, fileSize: 4 }, +]; + +describe("downloadSeriesBatch: success path", () => { + it("downloads every book sequentially and resolves with all three completed", async () => { + const { caches: cachesImpl, getStore } = makeFakeCaches(); + const { fakeFetch } = makeFakeFetch(); + const controller = await downloadSeriesBatch({ + seriesId: "series-1", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + const result = await controller.done; + expect(result.completed.sort()).toEqual(["a", "b", "c"]); + expect(result.failed).toEqual([]); + expect(result.cancelled).toEqual([]); + for (const id of ["a", "b", "c"]) { + expect(await getDownload(id)).toMatchObject({ status: "complete" }); + expect(getStore(cacheNameForBook(id))).toBeDefined(); + } + }); + + it("emits state updates to subscribers as books progress", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const { fakeFetch } = makeFakeFetch(); + const controller = await downloadSeriesBatch({ + seriesId: "series-1", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + const snapshots: Array<{ completed: number; total: number }> = []; + controller.subscribe((s) => { + snapshots.push({ completed: s.completed, total: s.total }); + }); + await controller.done; + // First snapshot is the synchronous push; final must show 3 completed. + expect(snapshots[0]).toMatchObject({ total: 3 }); + expect(snapshots[snapshots.length - 1]).toMatchObject({ + completed: 3, + total: 3, + }); + }); + + it("marks unsupported formats as skipped without trying to download them", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const { fakeFetch, calls } = makeFakeFetch(); + const books: SeriesBookSummary[] = [ + { id: "a", fileFormat: "epub", pageCount: 1, fileSize: 4 }, + { id: "b", fileFormat: "mobi", pageCount: 1, fileSize: 4 }, + ]; + const controller = await downloadSeriesBatch({ + seriesId: "series-mix", + books, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + await controller.done; + const state = controller.getState(); + expect(state.perBook.get("b")?.status).toBe("skipped"); + expect(state.perBook.get("a")?.status).toBe("complete"); + expect(calls.some((u) => u.includes("/books/b/"))).toBe(false); + }); +}); + +describe("downloadSeriesBatch: per-book cancel", () => { + it("cancelling the middle book lets the other two complete", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + // Stage the fetch so we can cancel book `b` while it is in flight. + let releaseB!: () => void; + const bStarted = new Promise((resolve) => { + releaseB = resolve; + }); + const fakeFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : (input as URL).toString(); + if (url.includes("/books/b/")) { + releaseB(); + await new Promise((_resolve, reject) => { + const onAbort = () => { + reject(new DOMException("Aborted", "AbortError")); + }; + if (init?.signal?.aborted) { + onAbort(); + return; + } + init?.signal?.addEventListener("abort", onAbort); + }); + } + return makeStreamingResponse([new Uint8Array([1, 2, 3, 4])], { + contentLength: 4, + }); + }, + ); + + const controller = await downloadSeriesBatch({ + seriesId: "series-cancel", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + await bStarted; + controller.cancelBook("b"); + const result = await controller.done; + expect(result.completed.sort()).toEqual(["a", "c"]); + expect(result.cancelled).toEqual(["b"]); + expect(await getDownload("b")).toBeUndefined(); + expect(await getDownload("a")).toMatchObject({ status: "complete" }); + expect(await getDownload("c")).toMatchObject({ status: "complete" }); + }); + + it("cancelling a queued book flips its state without invoking the manager", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + // Pause the first book so the others stay queued long enough to cancel. + let releaseA!: () => void; + const aHolding = new Promise((resolve) => { + releaseA = resolve; + }); + const fakeFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : (input as URL).toString(); + if (url.includes("/books/a/")) { + await aHolding; + } + if (init?.signal?.aborted) + throw new DOMException("Aborted", "AbortError"); + return makeStreamingResponse([new Uint8Array([1, 2, 3, 4])], { + contentLength: 4, + }); + }, + ); + const controller = await downloadSeriesBatch({ + seriesId: "series-queued", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + controller.cancelBook("c"); + releaseA(); + const result = await controller.done; + expect(result.cancelled).toEqual(["c"]); + expect(result.completed.sort()).toEqual(["a", "b"]); + // Cancelled-before-start book never made it into IDB. + expect(await getDownload("c")).toBeUndefined(); + }); +}); + +describe("downloadSeriesBatch: pre-flight quota check", () => { + it("refuses with no IDB writes when projected usage exceeds 90% of quota", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const { fakeFetch } = makeFakeFetch(); + const storage = { + estimate: vi.fn(async () => ({ usage: 900_000, quota: 1_000_000 })), + } as unknown as StorageManager; + + const books: SeriesBookSummary[] = [ + { id: "huge", fileFormat: "epub", pageCount: 1, fileSize: 500_000 }, + ]; + await expect( + downloadSeriesBatch({ + seriesId: "series-over", + books, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + storage, + }), + ).rejects.toBeInstanceOf(QuotaExceededError); + expect(await getAllDownloads()).toEqual([]); + }); + + it("proceeds when projected usage is below the threshold", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const { fakeFetch } = makeFakeFetch(); + const storage = { + estimate: vi.fn(async () => ({ usage: 0, quota: 1_000_000 })), + } as unknown as StorageManager; + const controller = await downloadSeriesBatch({ + seriesId: "series-ok", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + storage, + }); + const result = await controller.done; + expect(result.completed.sort()).toEqual(["a", "b", "c"]); + }); +}); + +describe("downloadSeriesBatch: subscribe", () => { + it("synchronously pushes the current state to new subscribers", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const { fakeFetch } = makeFakeFetch(); + const controller = await downloadSeriesBatch({ + seriesId: "series-sub", + books: [{ id: "x", fileFormat: "epub", pageCount: 1, fileSize: 4 }], + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + let received: SeriesQueueState | null = null; + const unsubscribe = controller.subscribe((s) => { + received = s; + }); + expect(received).not.toBeNull(); + expect(received!.seriesId).toBe("series-sub"); + unsubscribe(); + await controller.done; + }); +}); + +describe("downloadSeriesBatch: cancelAll", () => { + it("aborts in-flight and queued books, resolves with cancelled list", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + let releaseA!: () => void; + const aHolding = new Promise((resolve) => { + releaseA = resolve; + }); + const fakeFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : (input as URL).toString(); + if (url.includes("/books/a/")) { + await new Promise((_resolve, reject) => { + const onAbort = () => + reject(new DOMException("Aborted", "AbortError")); + if (init?.signal?.aborted) { + onAbort(); + return; + } + init?.signal?.addEventListener("abort", onAbort); + releaseA(); + }); + } + return makeStreamingResponse([new Uint8Array([1, 2, 3, 4])], { + contentLength: 4, + }); + }, + ); + const controller = await downloadSeriesBatch({ + seriesId: "series-all", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + await aHolding; + controller.cancelAll(); + const result = await controller.done; + expect(result.cancelled.sort()).toEqual(["a", "b", "c"]); + expect(result.completed).toEqual([]); + }); +}); + +describe("downloadSeriesBatch: mixed result", () => { + it("captures per-book error and lets the rest finish", async () => { + const { caches: cachesImpl } = makeFakeCaches(); + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + if (url.includes("/books/b/")) { + return new Response("forbidden", { status: 403 }); + } + return makeStreamingResponse([new Uint8Array([1, 2, 3, 4])], { + contentLength: 4, + }); + }); + const controller = await downloadSeriesBatch({ + seriesId: "series-err", + books: books3, + fetch: fakeFetch as typeof globalThis.fetch, + caches: cachesImpl, + }); + const result = await controller.done; + expect(result.completed.sort()).toEqual(["a", "c"]); + expect(result.failed.length).toBe(1); + expect(result.failed[0]?.bookId).toBe("b"); + expect(result.failed[0]?.error).toMatch(/HTTP 403/); + const states: BookQueueState[] = Array.from( + controller.getState().perBook.values(), + ); + expect(states.find((s) => s.bookId === "b")?.status).toBe("error"); + }); +}); diff --git a/web/src/lib/offline/seriesDownloadQueue.ts b/web/src/lib/offline/seriesDownloadQueue.ts new file mode 100644 index 00000000..d5f0d72a --- /dev/null +++ b/web/src/lib/offline/seriesDownloadQueue.ts @@ -0,0 +1,505 @@ +/** + * Series batch download queue (Phase 12 T5). + * + * Wraps the per-book download functions from `./downloadManager` in a small + * in-process queue so a "Download series" action can fan out across every + * book in a series without blowing past quota or kicking off N concurrent + * fetches. The queue is intentionally minimal: it lives only as long as the + * caller holds the returned controller, runs sequentially by default (the + * plan calls for 1-2 books in flight; 1 is the right phone default), and + * does not survive a tab close. The persisted state of partial completes + * lives in the per-book IDB rows and per-book cache that the individual + * download functions already maintain. + * + * Pre-flight quota check: sum the estimated bytes for every queued book, + * compare against `navigator.storage.estimate()`, and refuse with a typed + * `QuotaExceededError` if the queue would push usage past 90% of quota. + * Browsers that do not implement `navigator.storage.estimate()` (Safari + * historically) report `null` quota; treat as "unknown" and let the queue + * proceed rather than blocking on missing data. + * + * Per-book cancel composes the same way the comic per-page worker does in + * `downloadManager.ts`: one AbortController per book, plus a queue-level + * controller for "cancel everything." Cancelling one book leaves the rest + * intact. + */ + +import { + type ComicFormat, + downloadComicBook, + downloadSingleFileBook, + type ProgressUpdate, + type SingleFileFormat, +} from "./downloadManager"; + +/** + * Per-book input to the queue. Pulled from the series' book list at the + * call site so the queue does not need to know about the series API. + */ +export interface SeriesBookSummary { + id: string; + /** Lowercase format from the API (`"cbz" | "cbr" | "epub" | "pdf"`). */ + fileFormat: string; + /** Page count from book metadata. Required for comics. */ + pageCount: number; + /** Single-file size in bytes (EPUB/PDF). Optional for comics. */ + fileSize?: number | null; +} + +export type BookQueueStatus = + | "queued" + | "downloading" + | "complete" + | "error" + | "cancelled" + | "skipped"; + +export interface BookQueueState { + bookId: string; + status: BookQueueStatus; + /** Pages for comics, bytes for single-file. Matches `ProgressUpdate`. */ + loaded: number; + /** Pages or bytes; `null` when unknown (single-file w/o Content-Length). */ + total: number | null; + error?: string; +} + +export interface SeriesQueueState { + seriesId: string; + total: number; + completed: number; + failed: number; + cancelled: number; + /** Per-book state keyed by book id. Insertion order preserved. */ + perBook: Map; +} + +export interface SeriesDownloadResult { + completed: string[]; + failed: { bookId: string; error: string }[]; + cancelled: string[]; +} + +export interface SeriesDownloadController { + /** + * Cancel a single book. If it has not started yet, it is marked + * `cancelled` and skipped; if in flight, the per-book controller is + * aborted (`downloadManager` cleans up its IDB row + cache). + */ + cancelBook: (bookId: string) => void; + /** Cancel every book that has not yet completed. */ + cancelAll: () => void; + /** Subscribe to state-change notifications; returns an unsubscribe fn. */ + subscribe: (listener: (state: SeriesQueueState) => void) => () => void; + /** Snapshot of the current state. */ + getState: () => SeriesQueueState; + /** Resolves when every book has reached a terminal state. */ + done: Promise; +} + +export interface SeriesDownloadOptions { + seriesId: string; + books: SeriesBookSummary[]; + /** + * Max books processed in parallel. Defaults to 1 (sequential), which is + * the right default for phones and avoids fanning out 4 books * 5 pages + * = 20 concurrent fetches on comics. + */ + concurrency?: number; + /** + * Rough bytes-per-page used to estimate a comic's total size for the + * pre-flight quota check when `fileSize` is unknown. Comics rendered + * server-side at ~1080px land around 300 KB / page in practice; we + * default a little high (400 KB) to bias towards refusing borderline + * queues rather than failing mid-download. + */ + avgPageBytes?: number; + /** + * Refuse the queue when `usage + estimatedBytes > quota * quotaThreshold`. + * Defaults to 0.9 per the plan; lowered in tests so the assertion does + * not have to fabricate huge byte counts. + */ + quotaThreshold?: number; + /** Injection points for tests. Default to globals. */ + fetch?: typeof globalThis.fetch; + caches?: CacheStorage; + storage?: StorageManager; +} + +/** + * Thrown by `downloadSeriesBatch` from the pre-flight quota check. Carries + * the numbers needed for a clear user-facing message. + */ +export class QuotaExceededError extends Error { + readonly estimatedBytes: number; + readonly usage: number; + readonly quota: number; + readonly threshold: number; + constructor(args: { + estimatedBytes: number; + usage: number; + quota: number; + threshold: number; + }) { + super( + `Series download would exceed storage quota (${args.usage + args.estimatedBytes} of ${args.quota} bytes would be used; threshold ${Math.round(args.threshold * 100)}%).`, + ); + this.name = "QuotaExceededError"; + this.estimatedBytes = args.estimatedBytes; + this.usage = args.usage; + this.quota = args.quota; + this.threshold = args.threshold; + } +} + +const DEFAULT_AVG_PAGE_BYTES = 400 * 1024; +const DEFAULT_QUOTA_THRESHOLD = 0.9; + +function isSingleFileFormat(format: string): format is SingleFileFormat { + return format === "epub" || format === "pdf"; +} + +function isComicFormat(format: string): format is ComicFormat { + return format === "cbz" || format === "cbr"; +} + +/** + * Estimate the bytes a book will consume in the per-book cache. EPUB/PDF + * uses `fileSize` if known, falling back to a single average-page guess. + * Comics use `pageCount * avgPageBytes` (the backend renders one image per + * page; total size scales linearly). + */ +export function estimateBookBytes( + book: SeriesBookSummary, + avgPageBytes: number = DEFAULT_AVG_PAGE_BYTES, +): number { + if (isSingleFileFormat(book.fileFormat)) { + if (typeof book.fileSize === "number" && book.fileSize > 0) { + return book.fileSize; + } + return avgPageBytes; + } + if (isComicFormat(book.fileFormat)) { + return Math.max(1, book.pageCount) * avgPageBytes; + } + return 0; +} + +async function readQuotaEstimate( + storage: StorageManager | undefined, +): Promise<{ usage: number; quota: number } | null> { + if (!storage || typeof storage.estimate !== "function") return null; + try { + const est = await storage.estimate(); + const quota = typeof est.quota === "number" ? est.quota : 0; + const usage = typeof est.usage === "number" ? est.usage : 0; + if (quota <= 0) return null; + return { usage, quota }; + } catch { + return null; + } +} + +/** + * Run a pre-flight quota check. Returns nothing on success; throws + * `QuotaExceededError` if the queue would push past `quotaThreshold`. + * Treats an unavailable estimate as "unknown" and lets the caller proceed. + */ +export async function preflightQuota( + books: SeriesBookSummary[], + options: { + avgPageBytes?: number; + quotaThreshold?: number; + storage?: StorageManager; + } = {}, +): Promise { + const avg = options.avgPageBytes ?? DEFAULT_AVG_PAGE_BYTES; + const threshold = options.quotaThreshold ?? DEFAULT_QUOTA_THRESHOLD; + const storage = options.storage ?? globalThis.navigator?.storage; + const estimated = books.reduce( + (acc, b) => acc + estimateBookBytes(b, avg), + 0, + ); + if (estimated === 0) return; + const est = await readQuotaEstimate(storage); + if (!est) return; + if (est.usage + estimated > est.quota * threshold) { + throw new QuotaExceededError({ + estimatedBytes: estimated, + usage: est.usage, + quota: est.quota, + threshold, + }); + } +} + +/** + * Kick off a series batch download. Returns synchronously with a + * controller; `controller.done` resolves when every book reaches a + * terminal state. Throws `QuotaExceededError` from the pre-flight check + * without writing any IDB rows. + */ +export async function downloadSeriesBatch( + options: SeriesDownloadOptions, +): Promise { + const { + seriesId, + books, + concurrency = 1, + avgPageBytes = DEFAULT_AVG_PAGE_BYTES, + quotaThreshold = DEFAULT_QUOTA_THRESHOLD, + fetch: fetchImpl, + caches: cachesImpl, + storage, + } = options; + + await preflightQuota(books, { avgPageBytes, quotaThreshold, storage }); + + // Build initial state. Books with unsupported formats are skipped up front + // so the UI can show "1 of N skipped" without each one logging through the + // queue lifecycle. + const perBook = new Map(); + const supportedBooks: SeriesBookSummary[] = []; + for (const b of books) { + if (isSingleFileFormat(b.fileFormat) || isComicFormat(b.fileFormat)) { + perBook.set(b.id, { + bookId: b.id, + status: "queued", + loaded: 0, + total: isComicFormat(b.fileFormat) ? b.pageCount : null, + }); + supportedBooks.push(b); + } else { + perBook.set(b.id, { + bookId: b.id, + status: "skipped", + loaded: 0, + total: null, + }); + } + } + + const state: SeriesQueueState = { + seriesId, + total: books.length, + completed: 0, + failed: 0, + cancelled: 0, + perBook, + }; + + const listeners = new Set<(s: SeriesQueueState) => void>(); + const notify = () => { + // Hand listeners a stable snapshot — perBook is a Map, so React + // consumers should re-render via the listener call rather than by + // identity-checking the object. + for (const l of Array.from(listeners)) { + try { + l(state); + } catch { + // Listener errors should never break the queue. + } + } + }; + + // Per-book controllers so `cancelBook(id)` aborts exactly one fetch. + const controllers = new Map(); + // Queue-level "cancel everything" flag. + let everythingCancelled = false; + // Books that have not yet started; cancelling these flips the state + // without ever asking the manager to do anything. + const queuedIds = new Set(supportedBooks.map((b) => b.id)); + + function setBookState( + bookId: string, + next: Partial & { status?: BookQueueStatus }, + ): void { + const prev = perBook.get(bookId); + if (!prev) return; + const merged: BookQueueState = { ...prev, ...next }; + perBook.set(bookId, merged); + notify(); + } + + function bumpTerminal(prev: BookQueueStatus, next: BookQueueStatus): void { + if (prev === next) return; + if (next === "complete") state.completed += 1; + else if (next === "error") state.failed += 1; + else if (next === "cancelled") state.cancelled += 1; + } + + async function runOne(book: SeriesBookSummary): Promise { + const prev = perBook.get(book.id); + if (!prev) return; + if (prev.status === "cancelled" || prev.status === "skipped") return; + if (everythingCancelled) { + bumpTerminal(prev.status, "cancelled"); + setBookState(book.id, { status: "cancelled" }); + return; + } + + queuedIds.delete(book.id); + + const controller = new AbortController(); + controllers.set(book.id, controller); + setBookState(book.id, { status: "downloading", loaded: 0 }); + + const onProgress = (p: ProgressUpdate) => { + const cur = perBook.get(book.id); + if (!cur || cur.status !== "downloading") return; + setBookState(book.id, { loaded: p.loaded, total: p.total }); + }; + + try { + if (isSingleFileFormat(book.fileFormat)) { + await downloadSingleFileBook({ + bookId: book.id, + format: book.fileFormat, + signal: controller.signal, + onProgress, + fetch: fetchImpl, + caches: cachesImpl, + }); + } else if (isComicFormat(book.fileFormat)) { + await downloadComicBook({ + bookId: book.id, + format: book.fileFormat, + pageCount: book.pageCount, + signal: controller.signal, + onProgress, + fetch: fetchImpl, + caches: cachesImpl, + }); + } else { + // Should not happen — unsupported formats are filtered above — but + // keep the branch so future formats fail loudly. + throw new Error( + `Unsupported format for offline download: ${book.fileFormat}`, + ); + } + bumpTerminal(perBook.get(book.id)?.status ?? "downloading", "complete"); + setBookState(book.id, { status: "complete" }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + bumpTerminal( + perBook.get(book.id)?.status ?? "downloading", + "cancelled", + ); + setBookState(book.id, { status: "cancelled" }); + } else { + const message = err instanceof Error ? err.message : String(err); + bumpTerminal(perBook.get(book.id)?.status ?? "downloading", "error"); + setBookState(book.id, { status: "error", error: message }); + } + } finally { + controllers.delete(book.id); + } + } + + // Worker pool driven off a shared index so concurrency is honoured even + // when individual downloads finish out of order. + let nextIndex = 0; + async function worker() { + while (true) { + const i = nextIndex++; + if (i >= supportedBooks.length) return; + const book = supportedBooks[i]; + if (!book) return; + await runOne(book); + } + } + + const workerCount = Math.max( + 1, + Math.min(concurrency, supportedBooks.length || 1), + ); + + const done: Promise = (async () => { + if (supportedBooks.length === 0) { + // Nothing to do — every book was unsupported. Resolve immediately + // with the skipped list so the UI can render a "0 of N supported" + // message instead of spinning forever. + return summarise(state); + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return summarise(state); + })(); + + return { + cancelBook(bookId: string) { + const cur = perBook.get(bookId); + if (!cur) return; + if ( + cur.status === "complete" || + cur.status === "cancelled" || + cur.status === "error" || + cur.status === "skipped" + ) { + return; + } + const controller = controllers.get(bookId); + if (controller) { + controller.abort(); + return; // The catch arm in runOne flips status to "cancelled". + } + // Not started yet — flip directly so the worker skips it when it + // pops the index. + bumpTerminal(cur.status, "cancelled"); + setBookState(bookId, { status: "cancelled" }); + }, + cancelAll() { + everythingCancelled = true; + for (const [id, controller] of controllers.entries()) { + controller.abort(); + // Defensive: if the abort never propagates (synchronous resolve in + // a test), mark it now so the snapshot is consistent. + const cur = perBook.get(id); + if (cur && cur.status === "downloading") { + bumpTerminal(cur.status, "cancelled"); + setBookState(id, { status: "cancelled" }); + } + } + for (const id of Array.from(queuedIds)) { + const cur = perBook.get(id); + if (cur && cur.status === "queued") { + bumpTerminal(cur.status, "cancelled"); + setBookState(id, { status: "cancelled" }); + } + queuedIds.delete(id); + } + }, + subscribe(listener) { + listeners.add(listener); + // Push the current snapshot synchronously so subscribers do not have + // to render once with an empty state then wait for the next change. + try { + listener(state); + } catch { + /* ignore */ + } + return () => { + listeners.delete(listener); + }; + }, + getState() { + return state; + }, + done, + }; +} + +function summarise(state: SeriesQueueState): SeriesDownloadResult { + const completed: string[] = []; + const failed: { bookId: string; error: string }[] = []; + const cancelled: string[] = []; + for (const book of state.perBook.values()) { + if (book.status === "complete") completed.push(book.bookId); + else if (book.status === "error") + failed.push({ + bookId: book.bookId, + error: book.error ?? "unknown error", + }); + else if (book.status === "cancelled") cancelled.push(book.bookId); + } + return { completed, failed, cancelled }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index bda4748b..682a0f69 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,7 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; +import { InstallPrompt, PwaUpdatePrompt } from "./components/pwa"; import { ThemeSync } from "./components/ThemeSync.tsx"; +import { installOutboxDrainListeners } from "./lib/offline/outbox"; import { cssVariablesResolver, theme } from "./theme"; // Import Mantine styles @@ -49,6 +51,11 @@ async function enableMocking() { return startMockServiceWorker(); } +// Drain the offline write outbox whenever the browser comes back online +// or the tab regains focus. Safe to install before render: the listeners +// no-op if there is nothing queued, and double-install is guarded. +installOutboxDrainListeners(); + // Start the application after mocking is ready enableMocking().then(() => { const rootElement = document.getElementById("root"); @@ -62,6 +69,8 @@ enableMocking().then(() => { > + {import.meta.env.PROD && } + {import.meta.env.PROD && } diff --git a/web/src/mocks/handlers/index.ts b/web/src/mocks/handlers/index.ts index 6dc55b54..3636d40f 100644 --- a/web/src/mocks/handlers/index.ts +++ b/web/src/mocks/handlers/index.ts @@ -19,6 +19,7 @@ import { pdfCacheHandlers } from "./pdfCache"; import { pluginStorageHandlers } from "./pluginStorage"; import { pluginsHandlers } from "./plugins"; import { recommendationsHandlers } from "./recommendations"; +import { releasesHandlers } from "./releases"; import { seriesHandlers } from "./series"; import { seriesExportsHandlers } from "./seriesExports"; import { settingsHandlers } from "./settings"; @@ -128,6 +129,7 @@ export const handlers = [ ...pluginsHandlers, ...pluginStorageHandlers, ...recommendationsHandlers, + ...releasesHandlers, ...userPluginsHandlers, ...seriesExportsHandlers, ...utilityHandlers, @@ -148,6 +150,7 @@ export { pdfCacheHandlers } from "./pdfCache"; export { pluginStorageHandlers } from "./pluginStorage"; export { pluginsHandlers } from "./plugins"; export { recommendationsHandlers } from "./recommendations"; +export { releasesHandlers } from "./releases"; export { seriesHandlers } from "./series"; export { seriesExportsHandlers } from "./seriesExports"; export { settingsHandlers } from "./settings"; diff --git a/web/src/mocks/handlers/releases.ts b/web/src/mocks/handlers/releases.ts new file mode 100644 index 00000000..f20e9ea2 --- /dev/null +++ b/web/src/mocks/handlers/releases.ts @@ -0,0 +1,240 @@ +/** + * Release inbox + sources mock handlers + * + * Covers the three reads that ReleasesInbox.tsx makes on first paint + * (`release-sources`, `releases/facets`, `releases`) plus the small + * set of writes invoked from the inbox UI. The intent is "the page + * renders without crashing in mock mode" — not a full simulation of + * the polling/ledger lifecycle. + */ + +import { delay, HttpResponse, http } from "msw"; +import type { components } from "@/types/api.generated"; +import { createPaginatedResponse } from "../data/factories"; + +type ReleaseLedgerEntryDto = components["schemas"]["ReleaseLedgerEntryDto"]; +type ReleaseSourceDto = components["schemas"]["ReleaseSourceDto"]; +type ReleaseFacetsResponse = components["schemas"]["ReleaseFacetsResponse"]; + +const SERIES_A = "10000000-0000-0000-0000-000000000001"; +const SERIES_B = "10000000-0000-0000-0000-000000000002"; +const LIBRARY_MANGA = "20000000-0000-0000-0000-000000000001"; +const LIBRARY_COMICS = "20000000-0000-0000-0000-000000000002"; +const SOURCE_MANGAUPDATES = "30000000-0000-0000-0000-000000000001"; +const SOURCE_NYAA = "30000000-0000-0000-0000-000000000002"; + +const mockSources: ReleaseSourceDto[] = [ + { + id: SOURCE_MANGAUPDATES, + displayName: "MangaUpdates Releases", + sourceKey: "default", + pluginId: "release-mangaupdates", + kind: "metadata-feed", + enabled: true, + cronSchedule: null, + effectiveCronSchedule: "0 0 * * *", + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-05-15T12:00:00Z", + lastPolledAt: "2026-05-15T12:00:00Z", + lastSummary: "Polled 42 series · 3 new releases", + }, + { + id: SOURCE_NYAA, + displayName: "Nyaa (tsuna69)", + sourceKey: "nyaa:user:tsuna69", + pluginId: "release-nyaa", + kind: "rss-uploader", + enabled: true, + cronSchedule: "*/30 * * * *", + effectiveCronSchedule: "*/30 * * * *", + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-05-15T11:30:00Z", + lastPolledAt: "2026-05-15T11:30:00Z", + lastSummary: "1 new release", + }, +]; + +const mockEntries: ReleaseLedgerEntryDto[] = [ + { + id: "40000000-0000-0000-0000-000000000001", + seriesId: SERIES_A, + seriesTitle: "Solo Leveling", + sourceId: SOURCE_MANGAUPDATES, + externalReleaseId: "mu:solo-leveling:200", + payloadUrl: "https://www.mangaupdates.com/releases.html", + confidence: 0.95, + state: "announced", + observedAt: "2026-05-15T11:55:00Z", + createdAt: "2026-05-15T11:55:00Z", + chapters: [{ start: 200, end: 200 }], + volumes: null, + language: "en", + groupOrUploader: "Disastrous Scans", + }, + { + id: "40000000-0000-0000-0000-000000000002", + seriesId: SERIES_B, + seriesTitle: "Chainsaw Man", + sourceId: SOURCE_NYAA, + externalReleaseId: "nyaa:1234567", + payloadUrl: "https://nyaa.si/view/1234567", + mediaUrl: "https://nyaa.si/download/1234567.torrent", + mediaUrlKind: "torrent", + confidence: 0.88, + state: "announced", + observedAt: "2026-05-15T10:10:00Z", + createdAt: "2026-05-15T10:10:00Z", + chapters: [{ start: 162, end: 162 }], + volumes: null, + language: "en", + groupOrUploader: "GroupZ", + }, +]; + +const mockFacets: ReleaseFacetsResponse = { + languages: [{ language: "en", count: 2 }], + libraries: [ + { libraryId: LIBRARY_MANGA, libraryName: "Manga", count: 2 }, + { libraryId: LIBRARY_COMICS, libraryName: "Comics", count: 0 }, + ], + series: [ + { + seriesId: SERIES_A, + seriesTitle: "Solo Leveling", + libraryId: LIBRARY_MANGA, + libraryName: "Manga", + count: 1, + }, + { + seriesId: SERIES_B, + seriesTitle: "Chainsaw Man", + libraryId: LIBRARY_MANGA, + libraryName: "Manga", + count: 1, + }, + ], +}; + +function filterEntries(params: URLSearchParams): ReleaseLedgerEntryDto[] { + const state = params.get("state") ?? "announced"; + const language = params.get("language"); + const seriesId = params.get("seriesId"); + + return mockEntries.filter((e) => { + if (state !== "all" && e.state !== state) return false; + if (language && e.language !== language) return false; + if (seriesId && e.seriesId !== seriesId) return false; + return true; + }); +} + +export const releasesHandlers = [ + // GET /api/v1/release-sources — list configured sources + http.get("/api/v1/release-sources", async () => { + await delay(100); + return HttpResponse.json({ sources: mockSources }); + }), + + // GET /api/v1/release-sources/applicability — used by series detail to + // gate the Tracking panel. Mock mode advertises tracking as available. + http.get("/api/v1/release-sources/applicability", async () => { + await delay(50); + return HttpResponse.json({ applicable: true }); + }), + + // GET /api/v1/releases — inbox listing (paginated) + http.get("/api/v1/releases", async ({ request }) => { + await delay(150); + const url = new URL(request.url); + const page = Math.max( + 1, + Number.parseInt(url.searchParams.get("page") || "1", 10), + ); + const pageSize = Number.parseInt( + url.searchParams.get("pageSize") || "50", + 10, + ); + + const filtered = filterEntries(url.searchParams); + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return HttpResponse.json( + createPaginatedResponse(items, { + page, + pageSize, + total: filtered.length, + basePath: "/api/v1/releases", + }), + ); + }), + + // GET /api/v1/releases/facets — distinct values for the inbox dropdowns + http.get("/api/v1/releases/facets", async () => { + await delay(100); + return HttpResponse.json(mockFacets); + }), + + // GET /api/v1/series/:id/releases — per-series release listing + http.get("/api/v1/series/:seriesId/releases", async ({ params, request }) => { + await delay(150); + const url = new URL(request.url); + const page = Math.max( + 1, + Number.parseInt(url.searchParams.get("page") || "1", 10), + ); + const pageSize = Number.parseInt( + url.searchParams.get("pageSize") || "50", + 10, + ); + const filtered = mockEntries.filter((e) => e.seriesId === params.seriesId); + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return HttpResponse.json( + createPaginatedResponse(items, { + page, + pageSize, + total: filtered.length, + basePath: `/api/v1/series/${params.seriesId}/releases`, + }), + ); + }), + + // Per-row writes — return the row with the new state so React Query's + // optimistic update path stays happy. + http.post("/api/v1/releases/:id/dismiss", async ({ params }) => { + await delay(80); + const entry = mockEntries.find((e) => e.id === params.id); + if (!entry) { + return HttpResponse.json({ error: "Not found" }, { status: 404 }); + } + return HttpResponse.json({ ...entry, state: "dismissed" }); + }), + + http.post("/api/v1/releases/:id/mark-acquired", async ({ params }) => { + await delay(80); + const entry = mockEntries.find((e) => e.id === params.id); + if (!entry) { + return HttpResponse.json({ error: "Not found" }, { status: 404 }); + } + return HttpResponse.json({ ...entry, state: "marked_acquired" }); + }), + + http.delete("/api/v1/releases/:id", async () => { + await delay(80); + return HttpResponse.json({ deleted: true }); + }), + + http.post("/api/v1/releases/bulk", async ({ request }) => { + await delay(150); + const body = (await request.json()) as { + ids: string[]; + action: string; + }; + return HttpResponse.json({ + affected: body.ids.length, + action: body.action, + }); + }), +]; diff --git a/web/src/pages/BookDetail.tsx b/web/src/pages/BookDetail.tsx index 6dba96a2..5f34ecfe 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -60,6 +60,7 @@ import { import { BookMetadataEditModal } from "@/components/books/BookMetadataEditModal"; import { ExternalIdEditModal } from "@/components/common"; import { MetadataApplyFlow } from "@/components/metadata"; +import { DownloadButton } from "@/components/offline/DownloadButton"; import { CustomMetadataDisplay, ExternalLinks, @@ -691,6 +692,11 @@ export function BookDetail() { > Download + - + + FILE - + {book.filePath.split("/").pop() || book.filePath} diff --git a/web/src/pages/ReleasesInbox.test.tsx b/web/src/pages/ReleasesInbox.test.tsx index 13bb9782..bffa9950 100644 --- a/web/src/pages/ReleasesInbox.test.tsx +++ b/web/src/pages/ReleasesInbox.test.tsx @@ -9,7 +9,12 @@ import { } from "@/api/releases"; import { useReleaseAnnouncementsStore } from "@/store/releaseAnnouncementsStore"; import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; -import { ReleasesInbox } from "./ReleasesInbox"; +import { + buildLanguageOptions, + buildLibraryOptions, + buildSeriesOptions, + ReleasesInbox, +} from "./ReleasesInbox"; vi.mock("@/api/releases", () => ({ releasesApi: { @@ -288,3 +293,40 @@ describe("ReleasesInbox", () => { }); }); }); + +describe("ReleasesInbox option builders", () => { + // Mock-mode and partial-response defenses: facets may arrive with one or + // more dimensions missing. The builders must return a valid (possibly + // empty) option array rather than throwing on iteration. + it("buildSeriesOptions returns the All-series sentinel when facets.series is undefined", () => { + const partial = { + libraries: [], + languages: [], + } as unknown as ReleaseFacets; + expect(buildSeriesOptions(partial)).toEqual([ + { value: "__all__", label: "All series" }, + ]); + }); + + it("buildLibraryOptions returns the All-libraries sentinel when facets.libraries is undefined", () => { + const partial = { series: [], languages: [] } as unknown as ReleaseFacets; + const result = buildLibraryOptions(partial); + expect(result).toEqual([{ value: "__all__", label: "All libraries" }]); + }); + + it("buildLanguageOptions returns the All-languages sentinel when facets.languages is undefined", () => { + const partial = { series: [], libraries: [] } as unknown as ReleaseFacets; + const result = buildLanguageOptions(partial); + expect(result).toEqual([{ value: "__all__", label: "All languages" }]); + }); + + it("all builders return their empty-but-valid form when facets itself is undefined", () => { + expect(buildSeriesOptions(undefined)).toEqual([]); + expect(buildLibraryOptions(undefined)).toEqual([ + { value: "__all__", label: "All libraries" }, + ]); + expect(buildLanguageOptions(undefined)).toEqual([ + { value: "__all__", label: "All languages" }, + ]); + }); +}); diff --git a/web/src/pages/ReleasesInbox.tsx b/web/src/pages/ReleasesInbox.tsx index 76ef812a..5b2a6e53 100644 --- a/web/src/pages/ReleasesInbox.tsx +++ b/web/src/pages/ReleasesInbox.tsx @@ -48,13 +48,13 @@ const PAGE_SIZE = 50; const ALL_VALUE = "__all__"; /** Build the grouped, alphabetised series options for the Mantine Select. */ -function buildSeriesOptions(facets: ReleaseFacets | undefined) { +export function buildSeriesOptions(facets: ReleaseFacets | undefined) { if (!facets) return []; const byLibrary = new Map< string, { libraryName: string; items: { value: string; label: string }[] } >(); - for (const s of facets.series) { + for (const s of facets.series ?? []) { // Fall back to the id when title/library are missing so the option // still renders something searchable instead of an empty string. const libraryName = s.libraryName || "Unknown library"; @@ -82,9 +82,9 @@ function buildSeriesOptions(facets: ReleaseFacets | undefined) { ]; } -function buildLibraryOptions(facets: ReleaseFacets | undefined) { +export function buildLibraryOptions(facets: ReleaseFacets | undefined) { if (!facets) return [{ value: ALL_VALUE, label: "All libraries" }]; - const opts = facets.libraries + const opts = (facets.libraries ?? []) .map((l) => ({ value: l.libraryId, label: `${l.libraryName || "Unknown"} (${l.count})`, @@ -93,9 +93,9 @@ function buildLibraryOptions(facets: ReleaseFacets | undefined) { return [{ value: ALL_VALUE, label: "All libraries" }, ...opts]; } -function buildLanguageOptions(facets: ReleaseFacets | undefined) { +export function buildLanguageOptions(facets: ReleaseFacets | undefined) { if (!facets) return [{ value: ALL_VALUE, label: "All languages" }]; - const opts = facets.languages + const opts = (facets.languages ?? []) .map((l) => ({ value: l.language, label: `${l.language} (${l.count})`, diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index 542e66b7..4324ef74 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -53,6 +53,7 @@ import { AuthorsList } from "@/components/book/AuthorsList"; import { ExternalIdEditModal } from "@/components/common"; import { BulkSelectionToolbar } from "@/components/library/BulkSelectionToolbar"; import { MetadataApplyFlow } from "@/components/metadata"; +import { SeriesDownloadButton } from "@/components/offline/SeriesDownloadButton"; import { AlternateTitles, BehindByBadge, @@ -893,6 +894,17 @@ export function SeriesDetail() { > Download + {seriesBooks && seriesBooks.length > 0 && ( + ({ + id: b.id, + fileFormat: b.fileFormat, + pageCount: b.pageCount, + fileSize: b.fileSize, + }))} + /> + )} ; + estimate: ReturnType; +} + +let originalStorageDescriptor: PropertyDescriptor | undefined; + +function installStorage(stub: StorageStub) { + originalStorageDescriptor = Object.getOwnPropertyDescriptor( + globalThis.navigator, + "storage", + ); + Object.defineProperty(globalThis.navigator, "storage", { + configurable: true, + value: stub, + }); +} + +function restoreStorage() { + if (originalStorageDescriptor) { + Object.defineProperty( + globalThis.navigator, + "storage", + originalStorageDescriptor, + ); + } else { + Object.defineProperty(globalThis.navigator, "storage", { + configurable: true, + value: undefined, + }); + } + originalStorageDescriptor = undefined; +} + +function makeStorageStub( + persistValue: StoragePersistence, + usage: number, + quota: number, +): StorageStub { + return { + persist: vi.fn(async () => (persistValue === null ? false : persistValue)), + estimate: vi.fn(async () => ({ usage, quota })), + }; +} + +beforeEach(() => { + setDbContext({ indexedDB: new IDBFactory() }); +}); + +afterEach(() => { + setDbContext(null); + _resetForTests(); + _resetPersistenceForTests(); + restoreStorage(); +}); + +function makeDownload( + id: string, + overrides: Partial = {}, +): DownloadRecord { + return { + id, + format: "epub", + status: "complete", + bytes: 1024, + pageCount: 1, + downloadedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe("DownloadsSettings: empty state", () => { + it("shows the empty alert when nothing is downloaded", async () => { + installStorage(makeStorageStub(true, 0, 1_000_000)); + renderWithProviders(); + expect( + await screen.findByText(/No offline downloads yet/i), + ).toBeInTheDocument(); + }); +}); + +describe("DownloadsSettings: list rendering", () => { + it("lists every downloaded book with size and format", async () => { + installStorage(makeStorageStub(true, 100, 1000)); + await putDownload(makeDownload("book-a", { bytes: 2_500_000 })); + await putDownload( + makeDownload("book-b", { + format: "cbz", + bytes: 12_000_000, + pageCount: 22, + }), + ); + await putDownload( + makeDownload("book-c", { + format: "pdf", + bytes: 4_500_000, + status: "downloading", + }), + ); + + renderWithProviders(); + + expect(await screen.findByText("book-a")).toBeInTheDocument(); + expect(screen.getByText("book-b")).toBeInTheDocument(); + expect(screen.getByText("book-c")).toBeInTheDocument(); + + // Total = sum of complete records only (book-a + book-b). book-c is + // still downloading so its bytes do not contribute. + expect(screen.getByText(/3 books saved/i)).toBeInTheDocument(); + }); + + it("shows the storage quota meter when navigator.storage.estimate is available", async () => { + installStorage(makeStorageStub(true, 500_000_000, 1_000_000_000)); + await putDownload(makeDownload("book-a")); + renderWithProviders(); + + expect(await screen.findByText(/Storage used/i)).toBeInTheDocument(); + // 500 MB / 1 GB rounds to 47.68 MB usage, 953.67 MB available with the + // helper's formatting; just check the slash format is rendered. + expect(screen.getByText(/available/i)).toBeInTheDocument(); + }); + + it("surfaces the persistence indicator when persist() resolves true", async () => { + installStorage(makeStorageStub(true, 0, 1)); + renderWithProviders(); + expect( + await screen.findByText(/Storage is persistent/i), + ).toBeInTheDocument(); + }); + + it("warns when persist() resolves false", async () => { + installStorage(makeStorageStub(false, 0, 1)); + renderWithProviders(); + expect( + await screen.findByText(/Storage is not marked persistent/i), + ).toBeInTheDocument(); + }); +}); + +describe("DownloadsSettings: remove flow", () => { + it("removing a book deletes its IDB row and refreshes the list", async () => { + installStorage(makeStorageStub(true, 0, 1)); + await putDownload(makeDownload("book-a")); + await putDownload(makeDownload("book-b")); + + renderWithProviders(); + + const removeButton = await screen.findByRole("button", { + name: /Remove offline copy of book-a/i, + }); + await userEvent.click(removeButton); + + await waitFor(async () => { + expect(await getDownload("book-a")).toBeUndefined(); + }); + await waitFor(() => { + expect(screen.queryByText("book-a")).toBeNull(); + }); + expect(screen.getByText("book-b")).toBeInTheDocument(); + }); +}); + +describe("DownloadsSettings: clear-all flow", () => { + it("Clear all asks for confirmation and then removes every record", async () => { + installStorage(makeStorageStub(true, 0, 1)); + await putDownload(makeDownload("book-a")); + await putDownload(makeDownload("book-b")); + + renderWithProviders(); + + const clearTrigger = await screen.findByRole("button", { + name: /Clear all downloads/i, + }); + await userEvent.click(clearTrigger); + + const confirm = await screen.findByRole("button", { name: /Remove all/i }); + await userEvent.click(confirm); + + await waitFor(() => { + expect(screen.queryByText("book-a")).toBeNull(); + expect(screen.queryByText("book-b")).toBeNull(); + }); + expect( + await screen.findByText(/No offline downloads yet/i), + ).toBeInTheDocument(); + }); + + it("Cancelling the confirmation modal keeps everything", async () => { + installStorage(makeStorageStub(true, 0, 1)); + await putDownload(makeDownload("book-a")); + + renderWithProviders(); + await userEvent.click( + await screen.findByRole("button", { name: /Clear all downloads/i }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /^Cancel$/i }), + ); + + expect(screen.getByText("book-a")).toBeInTheDocument(); + expect(await getDownload("book-a")).toBeDefined(); + }); +}); + +describe("DownloadsSettings: broadcast updates", () => { + it("picks up a new download from a broadcast", async () => { + installStorage(makeStorageStub(true, 0, 1)); + renderWithProviders(); + expect( + await screen.findByText(/No offline downloads yet/i), + ).toBeInTheDocument(); + + const record = makeDownload("book-broadcast"); + await putDownload(record); + broadcastDownloadsChange({ kind: "put", record }); + + await waitFor(() => { + expect(screen.getByText("book-broadcast")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/pages/settings/DownloadsSettings.tsx b/web/src/pages/settings/DownloadsSettings.tsx new file mode 100644 index 00000000..dd6bfc97 --- /dev/null +++ b/web/src/pages/settings/DownloadsSettings.tsx @@ -0,0 +1,534 @@ +import { + ActionIcon, + Alert, + Badge, + Box, + Button, + Card, + Group, + Loader, + Modal, + Progress, + Stack, + Table, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertCircle, + IconCloudOff, + IconRefresh, + IconShieldCheck, + IconShieldOff, + IconTrash, +} from "@tabler/icons-react"; +import { formatDistanceToNow } from "date-fns"; +import { useCallback, useEffect, useState } from "react"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui/ResponsiveTable"; +import { + broadcastDownloadsChange, + clearDownloads, + DOWNLOADS_BROADCAST_CHANNEL, + type DownloadRecord, + type DownloadsBroadcast, + deleteDownload, + getAllDownloads, +} from "@/lib/offline/db"; +import { + getStoragePersistence, + requestStoragePersistence, + type StoragePersistence, +} from "@/lib/offline/downloadManager"; +import { cacheNameForBook } from "@/lib/offline/routeMatcher"; + +/** + * Phase 12 T7: Downloads management page. + * + * Lists every book currently stored in IndexedDB's `downloads` store with + * its size, format, last-read timestamp, and a Remove action. Surfaces the + * Storage Manager's quota estimate at the top, alongside the "Storage + * durability" (`navigator.storage.persist()` result) so users on iOS Safari + * have a visible signal that the browser may evict their offline copies. + * Subscribes to the `codex:downloads` BroadcastChannel so the list updates + * live while downloads run in this or any other tab. + * + * This is device-local data, so the page renders for every authenticated + * user, not just admins. + */ + +interface QuotaEstimate { + usage: number | null; + quota: number | null; +} + +function formatBytes(bytes: number | null): string { + if (bytes === null || bytes === undefined) return "-"; + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +} + +function formatLastRead(record: DownloadRecord): string { + const ts = record.lastReadAt ?? record.downloadedAt; + if (!ts) return "never"; + try { + return formatDistanceToNow(new Date(ts), { addSuffix: true }); + } catch { + return "unknown"; + } +} + +function formatLabel(record: DownloadRecord): string { + switch (record.status) { + case "complete": + return "Saved"; + case "downloading": + return "Downloading"; + case "queued": + return "Queued"; + case "error": + return "Error"; + } +} + +function statusColor(status: DownloadRecord["status"]): string { + switch (status) { + case "complete": + return "green"; + case "downloading": + return "blue"; + case "queued": + return "gray"; + case "error": + return "red"; + } +} + +export function DownloadsSettings() { + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); + const [records, setRecords] = useState(null); + const [quota, setQuota] = useState({ + usage: null, + quota: null, + }); + const [persistence, setPersistence] = useState( + getStoragePersistence(), + ); + const [clearOpen, { open: openClear, close: closeClear }] = + useDisclosure(false); + const [busyId, setBusyId] = useState(null); + + const refreshRecords = useCallback(async () => { + try { + const all = await getAllDownloads(); + all.sort((a, b) => (b.downloadedAt ?? 0) - (a.downloadedAt ?? 0)); + setRecords(all); + } catch { + setRecords([]); + } + }, []); + + const refreshQuota = useCallback(async () => { + if ( + typeof navigator === "undefined" || + !navigator.storage || + typeof navigator.storage.estimate !== "function" + ) { + setQuota({ usage: null, quota: null }); + return; + } + try { + const estimate = await navigator.storage.estimate(); + setQuota({ + usage: typeof estimate.usage === "number" ? estimate.usage : null, + quota: typeof estimate.quota === "number" ? estimate.quota : null, + }); + } catch { + setQuota({ usage: null, quota: null }); + } + }, []); + + const refreshPersistence = useCallback(async () => { + // Opportunistically request persistence when the user lands here so the + // indicator can flip to "granted" without waiting on a download. + const result = await requestStoragePersistence(); + setPersistence(result); + }, []); + + useEffect(() => { + void refreshRecords(); + void refreshQuota(); + void refreshPersistence(); + + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== "undefined") { + channel = new BroadcastChannel(DOWNLOADS_BROADCAST_CHANNEL); + channel.addEventListener("message", handleBroadcast); + } + + function handleBroadcast(_ev: MessageEvent) { + // Refresh both lists on any broadcast; the channel volume is low + // (one message per IDB write) and refreshing the page-local view + // from IDB ordering is simpler than maintaining a delta in memory. + void refreshRecords(); + void refreshQuota(); + } + + return () => { + if (channel) { + channel.removeEventListener("message", handleBroadcast); + channel.close(); + } + }; + }, [refreshRecords, refreshQuota, refreshPersistence]); + + const handleRemove = useCallback( + async (id: string) => { + setBusyId(id); + try { + await deleteDownload(id); + broadcastDownloadsChange({ kind: "delete", id }); + if (typeof caches !== "undefined") { + await caches.delete(cacheNameForBook(id)); + } + await refreshRecords(); + await refreshQuota(); + } catch (err) { + notifications.show({ + color: "red", + title: "Could not remove offline copy", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusyId(null); + } + }, + [refreshRecords, refreshQuota], + ); + + const handleClearAll = useCallback(async () => { + closeClear(); + try { + const all = await getAllDownloads(); + await clearDownloads(); + broadcastDownloadsChange({ kind: "clear" }); + if (typeof caches !== "undefined") { + await Promise.all( + all.map((r) => caches.delete(cacheNameForBook(r.id))), + ); + } + await refreshRecords(); + await refreshQuota(); + notifications.show({ + color: "green", + title: "Offline downloads cleared", + message: `Removed ${all.length} downloaded book${all.length === 1 ? "" : "s"}.`, + }); + } catch (err) { + notifications.show({ + color: "red", + title: "Could not clear offline downloads", + message: err instanceof Error ? err.message : String(err), + }); + } + }, [closeClear, refreshRecords, refreshQuota]); + + const usagePct = + quota.quota && quota.usage !== null + ? Math.min(100, Math.round((quota.usage / quota.quota) * 100)) + : null; + + const totalBytes = (records ?? []).reduce( + (acc, r) => acc + (r.status === "complete" ? r.bytes : 0), + 0, + ); + + return ( + + + + Offline downloads + + Books you have saved to read without a network connection. These are + stored in this browser only; opening Codex in another browser or on + another device will not see this list. + + + + + + + + + Storage used + + + + {formatBytes(quota.usage)} + + {quota.quota !== null && ( + + / {formatBytes(quota.quota)} available + + )} + + + + void refreshQuota()} + aria-label="Refresh quota estimate" + > + + + + + {usagePct !== null && ( + 80 ? "orange" : "blue"} + aria-label="Storage usage" + /> + )} + + + + + {records === null && ( + + + + )} + + {records !== null && records.length === 0 && ( + } + color="gray" + variant="light" + title="No offline downloads yet" + > + Tap the cloud-down icon on a book to save it for offline reading. + EPUB, PDF, CBZ, and CBR are all supported. + + )} + + {records !== null && records.length > 0 && ( + <> + + + {records.length} book{records.length === 1 ? "" : "s"} saved,{" "} + {formatBytes(totalBytes)} total. + + + + {isMobile ? ( + + ) : ( + + )} + + )} + + + + + + This will remove every book you have saved offline on this device. + Books on the server are not affected. + + + + + + + + + ); +} + +function PersistenceIndicator({ value }: { value: StoragePersistence }) { + if (value === true) { + return ( + + + + Storage is persistent: the browser will not evict your offline + downloads under ordinary storage pressure. + + + ); + } + if (value === false) { + return ( + + + + Storage is not marked persistent. Some browsers (notably iOS Safari in + a tab) may clear your downloads after a period of inactivity. + Installing Codex to your home screen can improve durability. + + + ); + } + return ( + + + + Storage durability is unknown in this browser. Downloads may or may not + survive a long period of inactivity. + + + ); +} + +interface RowProps { + records: DownloadRecord[]; + busyId: string | null; + onRemove: (id: string) => void; +} + +function DesktopRecordTable({ records, busyId, onRemove }: RowProps) { + return ( + + + + Book id + Format + Status + Size + Saved + + + + + {records.map((r) => ( + + + + {r.id} + + + + + {r.format.toUpperCase()} + + + + + {formatLabel(r)} + + + + {formatBytes(r.bytes)} + + + + {formatLastRead(r)} + + + + + onRemove(r.id)} + aria-label={`Remove offline copy of ${r.id}`} + > + + + + + + ))} + +
+ ); +} + +function MobileRecordList({ records, busyId, onRemove }: RowProps) { + return ( + + {records.map((r) => ( + + + + + + {r.id} + + + + {r.format.toUpperCase()} + + + {formatLabel(r)} + + + + onRemove(r.id)} + aria-label={`Remove offline copy of ${r.id}`} + > + + + + + + {formatBytes(r.bytes)} + + + {formatLastRead(r)} + + + + + ))} + + ); +} diff --git a/web/src/pages/settings/DuplicatesSettings.tsx b/web/src/pages/settings/DuplicatesSettings.tsx index deb8165a..9c5ab0d5 100644 --- a/web/src/pages/settings/DuplicatesSettings.tsx +++ b/web/src/pages/settings/DuplicatesSettings.tsx @@ -9,7 +9,6 @@ import { Group, Loader, Stack, - Table, Text, Title, Tooltip, @@ -27,6 +26,7 @@ import { useEffect, useRef, useState } from "react"; import { api } from "@/api/client"; import { type DuplicateGroup, duplicatesApi } from "@/api/duplicates"; import { AppLink } from "@/components/common/AppLink"; +import { ResponsiveTable } from "@/components/ui"; import { useTaskProgress } from "@/hooks/useTaskProgress"; import type { Book } from "@/types"; @@ -87,77 +87,93 @@ function DuplicateGroupCard({
{expanded && ( - - - - Book - Library - Series - Path - Size - - - - {books.map((book, index) => ( - - + + data={books} + columns={[ + { + key: "book", + header: "Book", + mobilePrimary: true, + thProps: { style: { width: "20%" } }, + accessor: (book) => ( + + {book.title} + + ), + }, + { + key: "library", + header: "Library", + thProps: { style: { width: "15%" } }, + accessor: (book) => ( + + {book.libraryName || "-"} + + ), + }, + { + key: "series", + header: "Series", + thProps: { style: { width: "15%" } }, + accessor: (book) => + book.seriesId ? ( - {book.title} + {book.seriesName || "-"} - - - - {book.libraryName || "-"} - - - - {book.seriesId ? ( - - {book.seriesName || "-"} - - ) : ( - - - - - )} - - - - - {book.filePath} - - - - - - {book.fileSize - ? `${(book.fileSize / 1024 / 1024).toFixed(2)} MB` - : "-"} + ) : ( + + - - - - ))} - -
+ ), + }, + { + key: "path", + header: "Path", + thProps: { style: { width: "35%" } }, + mobileFullWidth: true, + accessor: (book) => ( + + + {book.filePath} + + + ), + }, + { + key: "size", + header: "Size", + thProps: { style: { width: "15%" } }, + accessor: (book) => ( + + {book.fileSize + ? `${(book.fileSize / 1024 / 1024).toFixed(2)} MB` + : "-"} + + ), + }, + ]} + getRowKey={(book, index) => `${book.id}-${index}`} + tableProps={{ layout: "fixed" }} + /> )} diff --git a/web/src/pages/settings/MetricsSettings.tsx b/web/src/pages/settings/MetricsSettings.tsx index d05f9c48..6e1be8bd 100644 --- a/web/src/pages/settings/MetricsSettings.tsx +++ b/web/src/pages/settings/MetricsSettings.tsx @@ -4,6 +4,7 @@ import { Button, Card, Center, + Collapse, Grid, Group, Loader, @@ -216,147 +217,7 @@ function TaskTypeRow({ metrics }: { metrics: TaskTypeMetricsDto }) { {opened && ( - - -
- - Succeeded - - - {metrics.succeeded.toLocaleString()} - -
-
- - Failed - - 0 ? "red" : undefined} - > - {metrics.failed.toLocaleString()} - -
-
- - Retried - - 0 ? "yellow" : undefined} - > - {metrics.retried.toLocaleString()} - -
-
- - Error Rate - - 5 - ? "red" - : metrics.errorRatePct > 1 - ? "yellow" - : undefined - } - > - {metrics.errorRatePct.toFixed(2)}% - -
-
- - Min Duration - - - {formatDuration(metrics.minDurationMs)} - -
-
- - Max Duration - - - {formatDuration(metrics.maxDurationMs)} - -
-
- - P50 Duration - - - {formatDuration(metrics.p50DurationMs)} - -
-
- - P95 Duration - - - {formatDuration(metrics.p95DurationMs)} - -
-
- - Avg Queue Wait - - - {formatDuration(metrics.avgQueueWaitMs)} - -
-
- - Bytes Processed - - - {formatBytes(metrics.bytesProcessed)} - -
-
- - Throughput - - - {metrics.throughputPerSec.toFixed(1)}/sec - -
- {metrics.lastErrorAt && ( -
- - Last Error At - - - {new Date(metrics.lastErrorAt).toLocaleString()} - -
- )} -
- {metrics.lastError && ( - - - - - Last Error - - - - {metrics.lastError} - - - )} -
+
)} @@ -364,6 +225,246 @@ function TaskTypeRow({ metrics }: { metrics: TaskTypeMetricsDto }) { ); } +function TaskTypeDetails({ metrics }: { metrics: TaskTypeMetricsDto }) { + return ( + + +
+ + Succeeded + + + {metrics.succeeded.toLocaleString()} + +
+
+ + Failed + + 0 ? "red" : undefined}> + {metrics.failed.toLocaleString()} + +
+
+ + Retried + + 0 ? "yellow" : undefined} + > + {metrics.retried.toLocaleString()} + +
+
+ + Error Rate + + 5 + ? "red" + : metrics.errorRatePct > 1 + ? "yellow" + : undefined + } + > + {metrics.errorRatePct.toFixed(2)}% + +
+
+ + Min Duration + + + {formatDuration(metrics.minDurationMs)} + +
+
+ + Max Duration + + + {formatDuration(metrics.maxDurationMs)} + +
+
+ + P50 Duration + + + {formatDuration(metrics.p50DurationMs)} + +
+
+ + P95 Duration + + + {formatDuration(metrics.p95DurationMs)} + +
+
+ + Avg Queue Wait + + + {formatDuration(metrics.avgQueueWaitMs)} + +
+
+ + Bytes Processed + + + {formatBytes(metrics.bytesProcessed)} + +
+
+ + Throughput + + + {metrics.throughputPerSec.toFixed(1)}/sec + +
+ {metrics.lastErrorAt && ( +
+ + Last Error At + + + {new Date(metrics.lastErrorAt).toLocaleString()} + +
+ )} +
+ {metrics.lastError && ( + + + + + Last Error + + + + {metrics.lastError} + + + )} +
+ ); +} + +function TaskTypeMobileCard({ metrics }: { metrics: TaskTypeMetricsDto }) { + const [opened, { toggle }] = useDisclosure(false); + const successRate = + metrics.executed > 0 + ? ((metrics.succeeded / metrics.executed) * 100).toFixed(1) + : "0"; + const successColor = + Number.parseFloat(successRate) >= 95 + ? "green" + : Number.parseFloat(successRate) >= 80 + ? "yellow" + : "red"; + + return ( + + + + {opened ? ( + + ) : ( + + )} + + {metrics.taskType.replace(/_/g, " ")} + + + {metrics.lastError ? ( + } + > + {metrics.failed} errors + + ) : ( + + Healthy + + )} + + +
+ + Executed + + + {metrics.executed.toLocaleString()} + +
+
+ + Success rate + + + + {successRate}% + +
+
+ + Avg duration + + + {formatDuration(metrics.avgDurationMs)} + +
+
+ + P50 / P95 + + + {formatDuration(metrics.p50DurationMs)} /{" "} + {formatDuration(metrics.p95DurationMs)} + +
+
+ + + + + +
+ ); +} + // Inventory tab content function InventoryTab({ metrics }: { metrics: MetricsDto }) { return ( @@ -603,29 +704,41 @@ function TaskMetricsTab({ metrics }: { metrics: TaskMetricsResponse }) { Task Performance by Type - - - - Task Type - Executed - Success Rate - Avg Duration - P50 / P95 - Items Processed - Status - - - - {[...byType] - .sort((a, b) => a.taskType.localeCompare(b.taskType)) - .map((taskMetrics) => ( - - ))} - -
+ + + + + Task Type + Executed + Success Rate + Avg Duration + P50 / P95 + Items Processed + Status + + + + {[...byType] + .sort((a, b) => a.taskType.localeCompare(b.taskType)) + .map((taskMetrics) => ( + + ))} + +
+
+ + {[...byType] + .sort((a, b) => a.taskType.localeCompare(b.taskType)) + .map((taskMetrics) => ( + + ))} + )} @@ -704,150 +817,240 @@ function PluginMetricsRow({ metrics }: { metrics: PluginMetricsDto }) { {opened && ( - - -
- - Succeeded - - - {(metrics.requestsSuccess ?? 0).toLocaleString()} - -
-
- - Failed - - 0 ? "red" : undefined} - > - {(metrics.requestsFailed ?? 0).toLocaleString()} - -
-
- - Error Rate - - 10 - ? "red" - : (metrics.errorRatePct ?? 0) > 5 - ? "yellow" - : undefined - } - > - {(metrics.errorRatePct ?? 0).toFixed(2)}% - -
-
- - Rate Limit Hits + + + + )} + + ); +} + +function PluginMetricsDetails({ metrics }: { metrics: PluginMetricsDto }) { + return ( + + +
+ + Succeeded + + + {(metrics.requestsSuccess ?? 0).toLocaleString()} + +
+
+ + Failed + + 0 ? "red" : undefined} + > + {(metrics.requestsFailed ?? 0).toLocaleString()} + +
+
+ + Error Rate + + 10 + ? "red" + : (metrics.errorRatePct ?? 0) > 5 + ? "yellow" + : undefined + } + > + {(metrics.errorRatePct ?? 0).toFixed(2)}% + +
+
+ + Rate Limit Hits + + 0 ? "yellow" : undefined} + > + {(metrics.rateLimitRejections ?? 0).toLocaleString()} + +
+ {metrics.lastSuccess && ( +
+ + Last Success + + + {new Date(metrics.lastSuccess).toLocaleString()} + +
+ )} + {metrics.lastFailure && ( +
+ + Last Failure + + + {new Date(metrics.lastFailure).toLocaleString()} + +
+ )} +
+ + {/* Method breakdown */} + {metrics.byMethod && Object.keys(metrics.byMethod).length > 0 && ( + + + By Method + + + {Object.entries(metrics.byMethod).map(([method, methodMetrics]) => ( + + + + {method} - 0 - ? "yellow" - : undefined - } - > - {(metrics.rateLimitRejections ?? 0).toLocaleString()} + + {methodMetrics.requestsTotal} calls + + + + + {methodMetrics.requestsSuccess} ok -
- {metrics.lastSuccess && ( -
- - Last Success + {(methodMetrics.requestsFailed ?? 0) > 0 && ( + + {methodMetrics.requestsFailed} failed - - {new Date(metrics.lastSuccess).toLocaleString()} - -
- )} - {metrics.lastFailure && ( -
- - Last Failure - - - {new Date(metrics.lastFailure).toLocaleString()} - -
- )} -
- - {/* Method breakdown */} - {metrics.byMethod && Object.keys(metrics.byMethod).length > 0 && ( - - - By Method + )} + + avg {formatDuration(methodMetrics.avgDurationMs)} - - {Object.entries(metrics.byMethod).map( - ([method, methodMetrics]) => ( - - - - {method} - - - {methodMetrics.requestsTotal} calls - - - - - {methodMetrics.requestsSuccess} ok - - {(methodMetrics.requestsFailed ?? 0) > 0 && ( - - {methodMetrics.requestsFailed} failed - - )} - - avg {formatDuration(methodMetrics.avgDurationMs)} - - - - ), - )} - - - )} - - {/* Failure breakdown */} - {metrics.failureCounts && - Object.keys(metrics.failureCounts).length > 0 && ( - - - Failures by Type - - - {Object.entries(metrics.failureCounts).map( - ([code, count]) => ( - - {code}: {count} - - ), - )} - - - )} -
-
-
+
+ + ))} + + )} - + + {/* Failure breakdown */} + {metrics.failureCounts && + Object.keys(metrics.failureCounts).length > 0 && ( + + + Failures by Type + + + {Object.entries(metrics.failureCounts).map(([code, count]) => ( + + {code}: {count} + + ))} + + + )} + + ); +} + +function PluginMetricsMobileCard({ metrics }: { metrics: PluginMetricsDto }) { + const [opened, { toggle }] = useDisclosure(false); + const successRate = + metrics.requestsTotal > 0 + ? ( + ((metrics.requestsSuccess ?? 0) / metrics.requestsTotal) * + 100 + ).toFixed(1) + : "0"; + const successColor = + Number.parseFloat(successRate) >= 95 + ? "green" + : Number.parseFloat(successRate) >= 80 + ? "yellow" + : "red"; + const healthColor = + metrics.healthStatus === "healthy" + ? "green" + : metrics.healthStatus === "degraded" + ? "yellow" + : metrics.healthStatus === "unhealthy" + ? "red" + : "gray"; + + return ( + + + + {opened ? ( + + ) : ( + + )} + + {metrics.pluginName} + + + + {metrics.healthStatus} + + + +
+ + Requests + + + {metrics.requestsTotal.toLocaleString()} + +
+
+ + Success rate + + + + {successRate}% + +
+
+ + Avg duration + + + {formatDuration(metrics.avgDurationMs ?? 0)} + +
+
+ + Rate limited + + + {(metrics.rateLimitRejections ?? 0).toLocaleString()} + +
+
+ + + + + +
); } @@ -990,25 +1193,37 @@ function PluginMetricsTab({ metrics }: { metrics: PluginMetricsResponse }) { Plugin Performance - - - - Plugin - Requests - Success Rate - Avg Duration - Rate Limited - Health - - - - {[...plugins] - .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) - .map((plugin) => ( - - ))} - -
+ + + + + Plugin + Requests + Success Rate + Avg Duration + Rate Limited + Health + + + + {[...plugins] + .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) + .map((plugin) => ( + + ))} + +
+
+ + {[...plugins] + .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) + .map((plugin) => ( + + ))} + ) : ( diff --git a/web/src/pages/settings/PluginStorageSettings.tsx b/web/src/pages/settings/PluginStorageSettings.tsx index 236a89f2..856ebe5e 100644 --- a/web/src/pages/settings/PluginStorageSettings.tsx +++ b/web/src/pages/settings/PluginStorageSettings.tsx @@ -9,7 +9,6 @@ import { Modal, SimpleGrid, Stack, - Table, Text, Title, } from "@mantine/core"; @@ -30,6 +29,7 @@ import type { PluginStorageStatsDto, } from "@/api/pluginStorage"; import { pluginStorageApi } from "@/api/pluginStorage"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; @@ -123,6 +123,25 @@ export function PluginStorageSettings() { const hasPlugins = (stats?.plugins.length || 0) > 0; + const pluginStorageColumns: ResponsiveTableColumn[] = [ + { + key: "name", + header: "Plugin Name", + mobilePrimary: true, + accessor: (plugin) => {plugin.pluginName}, + }, + { + key: "fileCount", + header: "File Count", + accessor: (plugin) => plugin.fileCount.toLocaleString(), + }, + { + key: "size", + header: "Size", + accessor: (plugin) => formatBytes(plugin.totalBytes), + }, + ]; + if (isLoading) { return ( @@ -192,37 +211,22 @@ export function PluginStorageSettings() { Per-Plugin Storage {hasPlugins ? ( - - - - Plugin Name - File Count - Size - Actions - - - - {stats?.plugins.map((plugin) => ( - - - {plugin.pluginName} - - {plugin.fileCount.toLocaleString()} - {formatBytes(plugin.totalBytes)} - - setCleanupTarget(plugin)} - aria-label={`Delete storage for ${plugin.pluginName}`} - > - - - - - ))} - -
+ plugin.pluginName} + tableProps={{ striped: true, highlightOnHover: true }} + rowActions={(plugin) => ( + setCleanupTarget(plugin)} + aria-label={`Delete storage for ${plugin.pluginName}`} + > + + + )} + /> ) : ( No plugins have stored any files yet. )} diff --git a/web/src/pages/settings/PluginsSettings.tsx b/web/src/pages/settings/PluginsSettings.tsx index 3447bd7a..f601d8bf 100644 --- a/web/src/pages/settings/PluginsSettings.tsx +++ b/web/src/pages/settings/PluginsSettings.tsx @@ -19,7 +19,7 @@ import { Tooltip, } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertCircle, @@ -43,6 +43,7 @@ import { pluginsApi, } from "@/api/plugins"; import { PluginConfigModal } from "@/components/forms/PluginConfigModal"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { type OfficialPlugin, OfficialPlugins, @@ -84,6 +85,12 @@ export function PluginsSettings() { const plugins = pluginsResponse?.plugins ?? []; + // Below xs we render a card stack instead of the wide Table. Using + // useMediaQuery here (rather than `visibleFrom`/`hiddenFrom`) ensures only + // one DOM tree is rendered at a time so tests that query for plugin names + // don't see duplicate matches. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + // Fetch libraries for the library filter dropdown const { data: libraries = [] } = useQuery({ queryKey: ["libraries"], @@ -425,173 +432,359 @@ export function PluginsSettings() { Failed to load plugins. Please try again. ) : plugins.length > 0 ? ( - - - - - - - Plugin - Command - Status - Health - Actions - - - - {plugins.map((plugin) => ( - + <> + {!isMobile && ( + + +
+ - - toggleRowExpansion(plugin.id)} - > - {expandedRows.has(plugin.id) ? ( - - ) : ( - - )} - - - - - -
- {plugin.displayName} - - {plugin.name} - -
-
-
- - {plugin.command} - - - - plugin.enabled - ? disableMutation.mutate(plugin.id) - : enableMutation.mutate(plugin.id) - } - disabled={ - enableMutation.isPending || - disableMutation.isPending - } - /> - - - - - {plugin.healthStatus} - - {plugin.failureCount > 0 && ( - - - {plugin.failureCount} - - - )} - - - - - + + Plugin + Command + Status + Health + Actions +
+
+ + {plugins.map((plugin) => ( + + + testMutation.mutate(plugin.id)} - loading={ - testMutation.isPending && - testMutation.variables === plugin.id + size="sm" + onClick={() => toggleRowExpansion(plugin.id)} + aria-label={ + expandedRows.has(plugin.id) + ? "Collapse details" + : "Expand details" } > - + {expandedRows.has(plugin.id) ? ( + + ) : ( + + )} - - {plugin.failureCount > 0 && ( - - - resetFailuresMutation.mutate(plugin.id) - } - loading={ - resetFailuresMutation.isPending && - resetFailuresMutation.variables === - plugin.id + + + + +
+ {plugin.displayName} + + {plugin.name} + +
+
+
+ + {plugin.command} + + + + plugin.enabled + ? disableMutation.mutate(plugin.id) + : enableMutation.mutate(plugin.id) + } + disabled={ + enableMutation.isPending || + disableMutation.isPending + } + /> + + + + - - - - )} - - setConfigPlugin(plugin)} - > - - - - - handleEditPlugin(plugin)} - > - - - - - handleDeletePlugin(plugin)} - > - - - - - -
- - - - + {plugin.failureCount > 0 && ( + + + {plugin.failureCount} + + + )} + + + + + + + testMutation.mutate(plugin.id) + } + loading={ + testMutation.isPending && + testMutation.variables === plugin.id + } + aria-label="Test connection" + > + + + + {plugin.failureCount > 0 && ( + + + resetFailuresMutation.mutate(plugin.id) + } + loading={ + resetFailuresMutation.isPending && + resetFailuresMutation.variables === + plugin.id + } + aria-label="Reset failures" + > + + + + )} + + setConfigPlugin(plugin)} + aria-label="Configure plugin" + > + + + + + handleEditPlugin(plugin)} + aria-label="Edit plugin" + > + + + + + handleDeletePlugin(plugin)} + > + + + + + + + + + + + + + + + +
+ ))} +
+
+
+
+ )} + {isMobile && ( + + {plugins.map((plugin) => ( + + + + +
+ + {plugin.displayName} + + + {plugin.name} + +
+
+ + plugin.enabled + ? disableMutation.mutate(plugin.id) + : enableMutation.mutate(plugin.id) + } + disabled={ + enableMutation.isPending || disableMutation.isPending + } + aria-label={ + plugin.enabled ? "Disable plugin" : "Enable plugin" + } + /> +
+ + + + Command: + + + {plugin.command} + + + + + {plugin.healthStatus} + + {plugin.failureCount > 0 && ( + + + {plugin.failureCount} + + + )} + + + + + + + testMutation.mutate(plugin.id)} + loading={ + testMutation.isPending && + testMutation.variables === plugin.id + } + aria-label="Test connection" + > + + + + {plugin.failureCount > 0 && ( + + + resetFailuresMutation.mutate(plugin.id) + } + loading={ + resetFailuresMutation.isPending && + resetFailuresMutation.variables === plugin.id + } + aria-label="Reset failures" > - -
- - - - - ))} - - - - + + + + )} + + setConfigPlugin(plugin)} + aria-label="Configure plugin" + > + + + + + handleEditPlugin(plugin)} + aria-label="Edit plugin" + > + + + + + handleDeletePlugin(plugin)} + aria-label="Delete plugin" + > + + + +
+
+ + + + + + + ))} + + )} + ) : ( } diff --git a/web/src/pages/settings/ReleaseTrackingSettings.tsx b/web/src/pages/settings/ReleaseTrackingSettings.tsx index d96624d5..693b6d5b 100644 --- a/web/src/pages/settings/ReleaseTrackingSettings.tsx +++ b/web/src/pages/settings/ReleaseTrackingSettings.tsx @@ -11,7 +11,6 @@ import { MultiSelect, Stack, Switch, - Table, TagsInput, Text, Title, @@ -38,6 +37,7 @@ import { pluginsApi } from "@/api/plugins"; import type { ReleaseSource } from "@/api/releases"; import { settingsApi } from "@/api/settings"; import { CronInput } from "@/components/forms/CronInput"; +import { ResponsiveTable } from "@/components/ui"; import { usePollAllReleaseSourcesNow, usePollReleaseSourceNow, @@ -228,62 +228,116 @@ export function ReleaseTrackingSettings() { ) : ( - - - - - Source - Plugin - Interval - Last poll - Status - Enabled - - - - - {(sourcesQuery.data ?? []).map((source) => ( - - update.mutate({ - sourceId: source.id, - update: { enabled }, - }) - } - onCronScheduleChange={(cronSchedule) => - update.mutate({ - sourceId: source.id, - // Send `null` to clear the override and revert to - // inheriting the server-wide default. - update: { cronSchedule }, - }) - } - onPollNow={() => { - addId(setPollingIds, source.id); - pollNow.mutate(source.id, { - onSettled: () => removeId(setPollingIds, source.id), - }); - }} - pollNowPending={pollingIds.has(source.id)} - onReset={() => { - if ( - window.confirm( - `Reset "${source.displayName}"?\n\nThis deletes every release ledger row for this source and clears its poll state (etag, last poll time). User-managed settings (enabled, interval, name) are preserved. The next poll will re-record everything as new.\n\nThis cannot be undone.`, - ) - ) { - addId(setResettingIds, source.id); - reset.mutate(source.id, { - onSettled: () => removeId(setResettingIds, source.id), - }); + + + data={sourcesQuery.data ?? []} + columns={[ + { + key: "source", + header: "Source", + mobilePrimary: true, + accessor: (source) => , + }, + { + key: "plugin", + header: "Plugin", + accessor: (source) => , + }, + { + key: "interval", + header: "Interval", + mobileFullWidth: true, + accessor: (source) => ( + + update.mutate({ + sourceId: source.id, + update: { cronSchedule }, + }) + } + /> + ), + }, + { + key: "lastPoll", + header: "Last poll", + mobileFullWidth: true, + accessor: (source) => , + }, + { + key: "status", + header: "Status", + accessor: (source) => , + }, + { + key: "enabled", + header: "Enabled", + accessor: (source) => ( + + update.mutate({ + sourceId: source.id, + update: { + enabled: event.currentTarget.checked, + }, + }) } - }} - resetPending={resettingIds.has(source.id)} - /> - ))} - -
+ aria-label="Enable source" + /> + ), + }, + ]} + getRowKey={(source) => source.id} + tableProps={{ verticalSpacing: "sm" }} + rowActions={(source) => ( + <> + + { + addId(setPollingIds, source.id); + pollNow.mutate(source.id, { + onSettled: () => removeId(setPollingIds, source.id), + }); + }} + disabled={!source.enabled || pollingIds.has(source.id)} + loading={pollingIds.has(source.id)} + aria-label="Poll now" + > + + + + + { + if ( + window.confirm( + `Reset "${source.displayName}"?\n\nThis deletes every release ledger row for this source and clears its poll state (etag, last poll time). User-managed settings (enabled, interval, name) are preserved. The next poll will re-record everything as new.\n\nThis cannot be undone.`, + ) + ) { + addId(setResettingIds, source.id); + reset.mutate(source.id, { + onSettled: () => + removeId(setResettingIds, source.id), + }); + } + }} + loading={resettingIds.has(source.id)} + aria-label="Reset source" + > + + + + + )} + rowActionsHeader="" + />
)} @@ -609,26 +663,92 @@ function NotificationPreferencesCard() { ); } -interface RowProps { - source: ReleaseSource; - onToggle: (enabled: boolean) => void; - /** `null` clears the override and reverts to the server-wide default. */ - onCronScheduleChange: (cronSchedule: string | null) => void; - onPollNow: () => void; - pollNowPending: boolean; - onReset: () => void; - resetPending: boolean; +function SourceCell({ source }: { source: ReleaseSource }) { + return ( + + + {source.displayName} + + + {source.sourceKey} + + + ); } -function ReleaseSourceRow({ +function PluginCell({ source }: { source: ReleaseSource }) { + return ( + + {source.pluginId} + + ); +} + +function LastPollCell({ source }: { source: ReleaseSource }) { + const lastPolled = source.lastPolledAt + ? formatDistanceToNow(new Date(source.lastPolledAt), { addSuffix: true }) + : "—"; + return ( + + {lastPolled} + {source.lastSummary && ( + + {source.lastSummary} + + )} + + ); +} + +function StatusCell({ source }: { source: ReleaseSource }) { + if (source.lastError) { + return ( + + + Errored + + + ); + } + if (source.lastPolledAt) { + // Wrap the OK badge in a tooltip carrying `lastSummary` so users can + // see *why* a poll returned nothing (no tracked series, 304, dropped + // below threshold, etc.) without grepping logs. + return ( + + + OK + + + ); + } + return ( + + Never polled + + ); +} + +function CronCell({ source, - onToggle, onCronScheduleChange, - onPollNow, - pollNowPending, - onReset, - resetPending, -}: RowProps) { +}: { + source: ReleaseSource; + /** `null` clears the override and reverts to the server-wide default. */ + onCronScheduleChange: (cronSchedule: string | null) => void; +}) { // Truthy `cronSchedule` means the row has a per-source override; render the // editor inline. The server omits the field entirely (rather than sending // `null`) when the row is inheriting, so accept both `null` and `undefined` @@ -639,10 +759,6 @@ function ReleaseSourceRow({ source.cronSchedule || source.effectiveCronSchedule, ); - const lastPolled = source.lastPolledAt - ? formatDistanceToNow(new Date(source.lastPolledAt), { addSuffix: true }) - : "—"; - const commitDraft = () => { const trimmed = draft.trim(); if (!trimmed) { @@ -663,145 +779,48 @@ function ReleaseSourceRow({ setDraft(source.effectiveCronSchedule); }; - return ( - - - - - {source.displayName} - - - {source.sourceKey} - - - - - - {source.pluginId} - - - - {isOverriding ? ( - - - - Reset to default - - - ) : ( - - - {describeCron(source.effectiveCronSchedule)}{" "} - - (Default) - - - { - setIsOverriding(true); - setDraft(source.effectiveCronSchedule); - }} - > - Override - - - )} - - - - {lastPolled} - {source.lastSummary && ( - - {source.lastSummary} - - )} - - - - {source.lastError ? ( - - - Errored - - - ) : source.lastPolledAt ? ( - // Wrap the OK badge in a tooltip carrying `lastSummary` so users - // can see *why* a poll returned nothing (no tracked series, 304, - // dropped below threshold, etc.) without grepping logs. - - - OK - - - ) : ( - - Never polled - - )} - - - onToggle(event.currentTarget.checked)} - aria-label="Enable source" + if (isOverriding) { + return ( + + - - - - - - - - - - - - - - - - + + Reset to default + + + ); + } + + return ( + + + {describeCron(source.effectiveCronSchedule)}{" "} + + (Default) + + + { + setIsOverriding(true); + setDraft(source.effectiveCronSchedule); + }} + > + Override + + ); } diff --git a/web/src/pages/settings/SeriesExportsSettings.tsx b/web/src/pages/settings/SeriesExportsSettings.tsx index 1c727a22..e5054541 100644 --- a/web/src/pages/settings/SeriesExportsSettings.tsx +++ b/web/src/pages/settings/SeriesExportsSettings.tsx @@ -11,7 +11,6 @@ import { Radio, SegmentedControl, Stack, - Table, Text, Title, Tooltip, @@ -27,7 +26,8 @@ import { import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { librariesApi } from "@/api/libraries"; -import type { ExportFieldDto } from "@/api/seriesExports"; +import type { ExportFieldDto, SeriesExportDto } from "@/api/seriesExports"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; import { useCreateSeriesExport, useDeleteSeriesExport, @@ -602,6 +602,82 @@ export function SeriesExportsSettings() { deleteMutation.mutate(id); }; + const exportColumns: ResponsiveTableColumn[] = [ + { + key: "created", + header: "Created", + mobilePrimary: true, + accessor: (exp) => ( + + {new Date(exp.createdAt).toLocaleString()} + + ), + }, + { + key: "type", + header: "Type", + accessor: (exp) => , + }, + { + key: "format", + header: "Format", + accessor: (exp) => ( + + {exp.format.toUpperCase()} + + ), + }, + { + key: "status", + header: "Status", + accessor: (exp) => ( + <> + + {exp.error && ( + + + {exp.error} + + + )} + + ), + }, + { + key: "libraries", + header: "Libraries", + mobileFullWidth: true, + accessor: (exp) => ( + + ), + }, + { + key: "rows", + header: "Rows", + accessor: (exp) => {exp.rowCount ?? "-"}, + }, + { + key: "size", + header: "Size", + accessor: (exp) => ( + {formatBytes(exp.fileSizeBytes ?? null)} + ), + }, + { + key: "expires", + header: "Expires", + accessor: (exp) => ( + {new Date(exp.expiresAt).toLocaleDateString()} + ), + }, + ]; + return ( @@ -634,105 +710,47 @@ export function SeriesExportsSettings() { ) : ( - - - - - Created - Type - Format - Status - Libraries - Rows - Size - Expires - Actions - - - - {exports.map((exp) => ( - - - - {new Date(exp.createdAt).toLocaleString()} - - - - - - - - {exp.format.toUpperCase()} - - - - - {exp.error && ( - - - {exp.error} - - - )} - - - - - - {exp.rowCount ?? "-"} - - - - {formatBytes(exp.fileSizeBytes ?? null)} - - - - - {new Date(exp.expiresAt).toLocaleDateString()} - - - - - {exp.status === "completed" && ( - - handleDownload(exp)} - > - - - - )} - - handleDelete(exp.id)} - > - - - - - - - ))} - -
+ + exp.id} + tableProps={{ striped: true, highlightOnHover: true }} + rowActions={(exp) => ( + <> + {exp.status === "completed" && ( + + handleDownload(exp)} + aria-label="Download export" + > + + + + )} + + handleDelete(exp.id)} + aria-label="Delete export" + > + + + + + )} + /> )} diff --git a/web/src/pages/settings/ServerSettings.tsx b/web/src/pages/settings/ServerSettings.tsx index af0f3f13..89ae3ef9 100644 --- a/web/src/pages/settings/ServerSettings.tsx +++ b/web/src/pages/settings/ServerSettings.tsx @@ -19,7 +19,7 @@ import { Title, Tooltip, } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertCircle, @@ -41,6 +41,7 @@ import { } from "@/api/settings"; import { TemplateEditor } from "@/components/forms/TemplateEditor"; import { TemplateSelector } from "@/components/forms/TemplateSelector"; +import { MOBILE_MEDIA_QUERY, ResponsiveTable } from "@/components/ui"; import { brandingQueryKey } from "@/hooks/useAppName"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -197,6 +198,144 @@ function SettingRow({ ); } +// Setting card for the mobile layout. Mirrors `SettingRow` but stacks the +// key/value/actions vertically and lets the value editor occupy the full +// card width below xs. +function SettingMobileCard({ + setting, + onUpdate, + onReset, + onViewHistory, +}: { + setting: SettingDto; + onUpdate: (key: string, value: string) => void; + onReset: (key: string) => void; + onViewHistory: (key: string) => void; +}) { + const [localValue, setLocalValue] = useState(setting.value); + const [isEditing, setIsEditing] = useState(false); + + const handleSave = () => { + if (localValue !== setting.value) { + onUpdate(setting.key, localValue); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setLocalValue(setting.value); + setIsEditing(false); + }; + + const renderInput = () => { + switch (setting.valueType) { + case "boolean": + return ( + { + const newValue = String(e.currentTarget.checked); + setLocalValue(newValue); + onUpdate(setting.key, newValue); + }} + /> + ); + case "integer": + return ( + setLocalValue(String(value))} + min={setting.minValue ?? undefined} + max={setting.maxValue ?? undefined} + onBlur={handleSave} + w="100%" + /> + ); + default: + return isEditing ? ( + + setLocalValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") handleCancel(); + }} + autoFocus + /> + + + + + + ) : ( + + setIsEditing(true)} + > + {setting.isSensitive ? "••••••••" : localValue || "(empty)"} + + + + ); + } + }; + + return ( + + + + + + {setting.key} + + + {setting.description} + + + + {setting.valueType} + + + {renderInput()} + + + onViewHistory(setting.key)} + aria-label="View history" + > + + + + + onReset(setting.key)} + disabled={setting.value === setting.defaultValue} + aria-label="Reset to default" + > + + + + + + + ); +} + // Template setting key constant const CUSTOM_METADATA_TEMPLATE_KEY = "display.custom_metadata_template"; @@ -312,6 +451,10 @@ function SettingsCategorySection({ onViewHistory: (key: string) => void; }) { const [opened, { toggle }] = useDisclosure(true); + // Below xs the four-column settings table clips on the right. Render a + // stack of `SettingMobileCard` instead — only one DOM tree is mounted so + // tests still address a single matching row per setting. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; return ( @@ -328,18 +471,10 @@ function SettingsCategorySection({ )}
- - - - Setting - Value - Type - Actions - - - + {isMobile ? ( + {settings.map((setting) => ( - ))} - -
+ + ) : ( + + + + Setting + Value + Type + Actions + + + + {settings.map((setting) => ( + + ))} + +
+ )}
); @@ -535,90 +692,98 @@ export function ServerSettings() { ) : history && history.length > 0 ? ( - - - - Previous Value - New Value - Changed At - Reason - Actions - - - - {history.map((entry: SettingHistoryDto, index: number) => { - // Get the current setting value to check if restore is needed - const currentValue = settings?.find( - (s) => s.key === historyKey, - )?.value; - const canRestore = - entry.oldValue !== null && entry.oldValue !== currentValue; - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: History entries have no unique ID - - - - {entry.oldValue ?? "(empty)"} - - - - - {entry.newValue} - - - - {new Date(entry.changedAt).toLocaleString()} - - {entry.changeReason || "-"} - - {canRestore ? ( - - { - if (historyKey) { - updateSettingMutation.mutate({ - key: historyKey, - value: entry.oldValue as string, - }); - } - }} - loading={updateSettingMutation.isPending} - > - - - - ) : ( - - - - - )} - - - ); - })} - -
+ + data={history.map((entry, index) => { + const currentValue = settings?.find( + (s) => s.key === historyKey, + )?.value; + return { + ...entry, + __index: index, + __canRestore: + entry.oldValue !== null && entry.oldValue !== currentValue, + }; + })} + columns={[ + { + key: "old", + header: "Previous Value", + mobileFullWidth: true, + accessor: (entry) => ( + + {entry.oldValue ?? "(empty)"} + + ), + }, + { + key: "new", + header: "New Value", + mobileFullWidth: true, + accessor: (entry) => ( + + {entry.newValue} + + ), + }, + { + key: "changedAt", + header: "Changed At", + accessor: (entry) => new Date(entry.changedAt).toLocaleString(), + }, + { + key: "reason", + header: "Reason", + accessor: (entry) => entry.changeReason || "-", + }, + ]} + getRowKey={(entry) => `${entry.__index}`} + rowActions={(entry) => + entry.__canRestore ? ( + + { + if (historyKey) { + updateSettingMutation.mutate({ + key: historyKey, + value: entry.oldValue as string, + }); + } + }} + loading={updateSettingMutation.isPending} + aria-label="Restore to this value" + > + + + + ) : ( + + - + + ) + } + /> ) : ( No history available for this setting. diff --git a/web/src/pages/settings/SharingTagsSettings.tsx b/web/src/pages/settings/SharingTagsSettings.tsx index f72586b7..67062912 100644 --- a/web/src/pages/settings/SharingTagsSettings.tsx +++ b/web/src/pages/settings/SharingTagsSettings.tsx @@ -10,7 +10,6 @@ import { Loader, Modal, Stack, - Table, Text, Textarea, TextInput, @@ -30,6 +29,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { Link } from "react-router-dom"; import { type SharingTagDto, sharingTagsApi } from "@/api/sharingTags"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; export function SharingTagsSettings() { const queryClient = useQueryClient(); @@ -166,6 +166,65 @@ export function SharingTagsSettings() { setDeleteModalOpened(true); }; + const sharingTagColumns: ResponsiveTableColumn[] = [ + { + key: "tag", + header: "Tag", + mobilePrimary: true, + accessor: (tag) => ( + + + {tag.name} + + ), + }, + { + key: "description", + header: "Description", + mobileFullWidth: true, + accessor: (tag) => ( + + {tag.description || "No description"} + + ), + }, + { + key: "series", + header: "Series", + accessor: (tag) => ( + + + {tag.seriesCount} series + + + ), + }, + { + key: "users", + header: "Users", + accessor: (tag) => ( + + + {tag.userCount} users + + + ), + }, + { + key: "created", + header: "Created", + accessor: (tag) => new Date(tag.createdAt).toLocaleDateString(), + }, + ]; + return ( @@ -194,93 +253,35 @@ export function SharingTagsSettings() { Failed to load sharing tags. Please try again. ) : sharingTags && sharingTags.length > 0 ? ( - - - - - Tag - Description - Series - Users - Created - Actions - - - - {sharingTags.map((tag) => ( - - - - - {tag.name} - - - - - {tag.description || "No description"} - - - - - - {tag.seriesCount} series - - - - - - - {tag.userCount} users - - - - - {new Date(tag.createdAt).toLocaleDateString()} - - - - - handleEditTag(tag)} - > - - - - - handleDeleteTag(tag)} - > - - - - - - - ))} - -
+ + tag.id} + rowActions={(tag) => ( + <> + + handleEditTag(tag)} + aria-label={`Edit ${tag.name}`} + > + + + + + handleDeleteTag(tag)} + aria-label={`Delete ${tag.name}`} + > + + + + + )} + /> ) : ( } color="gray" variant="light"> diff --git a/web/src/pages/settings/TasksSettings.tsx b/web/src/pages/settings/TasksSettings.tsx index 0182f2ad..747669ce 100644 --- a/web/src/pages/settings/TasksSettings.tsx +++ b/web/src/pages/settings/TasksSettings.tsx @@ -12,7 +12,6 @@ import { Select, SimpleGrid, Stack, - Table, Text, Title, Tooltip, @@ -34,6 +33,7 @@ import { fetchTasksByStatus, subscribeToTaskProgress, } from "@/api/tasks"; +import { ResponsiveTable } from "@/components/ui"; import type { TaskProgressEvent, TaskResponse } from "@/types"; // Stat card component @@ -65,8 +65,19 @@ function StatCard({ ); } -// Task row component -function TaskRow({ +function getTaskStatusColor(status: string): string { + return ( + { + pending: "yellow", + processing: "blue", + completed: "green", + failed: "red", + cancelled: "gray", + }[status] || "gray" + ); +} + +function TaskActions({ task, onCancel, onRetry, @@ -77,75 +88,45 @@ function TaskRow({ onRetry: () => void; onUnlock: () => void; }) { - const statusColor = - { - pending: "yellow", - processing: "blue", - completed: "green", - failed: "red", - cancelled: "gray", - }[task.status] || "gray"; - return ( - - - - {task.id.slice(0, 8)}... - - - - {task.taskType} - - - {task.status} - - - - {task.attempts}/{task.maxAttempts} - - - - {new Date(task.createdAt).toLocaleString()} - - - {task.lastError ? ( - - - {task.lastError} - - - ) : ( - - - - - )} - - - - {task.status === "pending" && ( - - - - - - )} - {task.status === "failed" && ( - - - - - - )} - {task.lockedBy && task.status === "processing" && ( - - - - - - )} - - - + <> + {task.status === "pending" && ( + + + + + + )} + {task.status === "failed" && ( + + + + + + )} + {task.lockedBy && task.status === "processing" && ( + + + + + + )} + ); } @@ -509,30 +490,86 @@ export function TasksSettings() { ) : tasks && tasks.length > 0 ? ( - - - - ID - Type - Status - Attempts - Created - Error - Actions - - - - {tasks.map((task: TaskResponse) => ( - cancelTaskMutation.mutate(task.id)} - onRetry={() => retryTaskMutation.mutate(task.id)} - onUnlock={() => unlockTaskMutation.mutate(task.id)} - /> - ))} - -
+ + data={tasks} + columns={[ + { + key: "id", + header: "ID", + accessor: (task) => ( + + {task.id.slice(0, 8)}... + + ), + }, + { + key: "type", + header: "Type", + mobilePrimary: true, + accessor: (task) => ( + {task.taskType} + ), + }, + { + key: "status", + header: "Status", + accessor: (task) => ( + + {task.status} + + ), + }, + { + key: "attempts", + header: "Attempts", + accessor: (task) => ( + + {task.attempts}/{task.maxAttempts} + + ), + }, + { + key: "created", + header: "Created", + accessor: (task) => ( + + {new Date(task.createdAt).toLocaleString()} + + ), + }, + { + key: "error", + header: "Error", + mobileFullWidth: true, + accessor: (task) => + task.lastError ? ( + + + {task.lastError} + + + ) : ( + + - + + ), + }, + ]} + getRowKey={(task) => task.id} + rowActions={(task) => ( + cancelTaskMutation.mutate(task.id)} + onRetry={() => retryTaskMutation.mutate(task.id)} + onUnlock={() => unlockTaskMutation.mutate(task.id)} + /> + )} + /> ) : ( No tasks found. @@ -546,34 +583,61 @@ export function TasksSettings() { By Task Type - - - - Type - Pending - Processing - Completed - Failed - Total - - - - {Object.entries(stats.byType) - .sort(([typeA], [typeB]) => typeA.localeCompare(typeB)) - .map(([type, typeStats]) => ( - - - {type} - - {typeStats.pending} - {typeStats.processing} - {typeStats.completed} - {typeStats.failed} - {typeStats.total} - - ))} - -
+ + data={Object.entries(stats.byType) + .sort(([typeA], [typeB]) => typeA.localeCompare(typeB)) + .map(([type, typeStats]) => ({ + type, + pending: typeStats.pending, + processing: typeStats.processing, + completed: typeStats.completed, + failed: typeStats.failed, + total: typeStats.total, + }))} + columns={[ + { + key: "type", + header: "Type", + mobilePrimary: true, + accessor: (row) => ( + {row.type} + ), + }, + { + key: "pending", + header: "Pending", + accessor: (row) => row.pending, + }, + { + key: "processing", + header: "Processing", + accessor: (row) => row.processing, + }, + { + key: "completed", + header: "Completed", + accessor: (row) => row.completed, + }, + { + key: "failed", + header: "Failed", + accessor: (row) => row.failed, + }, + { + key: "total", + header: "Total", + accessor: (row) => row.total, + }, + ]} + getRowKey={(row) => row.type} + />
)} diff --git a/web/src/pages/settings/UsersSettings.tsx b/web/src/pages/settings/UsersSettings.tsx index 114b9c95..f2c77135 100644 --- a/web/src/pages/settings/UsersSettings.tsx +++ b/web/src/pages/settings/UsersSettings.tsx @@ -15,7 +15,6 @@ import { Select, Stack, Switch, - Table, Text, TextInput, Title, @@ -41,6 +40,7 @@ import { useSearchParams } from "react-router-dom"; import { sharingTagsApi } from "@/api/sharingTags"; import { type UserDto, type UserListParams, usersApi } from "@/api/users"; import { PermissionPicker } from "@/components/common"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; import { UserSharingTagGrants } from "@/components/users"; import { useAuthStore } from "@/store/authStore"; import { type Permission, ROLE_PERMISSIONS } from "@/types/permissions"; @@ -310,6 +310,80 @@ export function UsersSettings() { const totalPages = usersResponse?.totalPages ?? 1; const showPagination = total > PAGE_SIZE; + const userColumns: ResponsiveTableColumn[] = [ + { + key: "user", + header: "User", + mobileLabel: "User", + mobilePrimary: true, + accessor: (user) => ( + + +
+ {user.username} + {user.id === currentUser?.id && ( + + (You) + + )} +
+
+ ), + }, + { + key: "email", + header: "Email", + accessor: (user) => ( + + {user.email} + + ), + }, + { + key: "role", + header: "Role", + accessor: (user) => ( + + {user.role === "admin" + ? "Admin" + : user.role === "maintainer" + ? "Maintainer" + : "Reader"} + + ), + }, + { + key: "status", + header: "Status", + accessor: (user) => ( + + {user.isActive ? "Active" : "Inactive"} + + ), + }, + { + key: "created", + header: "Created", + accessor: (user) => new Date(user.createdAt).toLocaleDateString(), + }, + { + key: "lastLogin", + header: "Last Login", + accessor: (user) => + user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleString() + : "Never", + }, + ]; + return ( @@ -436,92 +510,36 @@ export function UsersSettings() { )} - - - - - User - Email - Role - Status - Created - Last Login - Actions - - - - {users.map((user: UserDto) => ( - - - - -
- {user.username} - {user.id === currentUser?.id && ( - - (You) - - )} -
-
-
- {user.email} - - - {user.role === "admin" - ? "Admin" - : user.role === "maintainer" - ? "Maintainer" - : "Reader"} - - - - - {user.isActive ? "Active" : "Inactive"} - - - - {new Date(user.createdAt).toLocaleDateString()} - - - {user.lastLoginAt - ? new Date(user.lastLoginAt).toLocaleString() - : "Never"} - - - - - handleEditUser(user)} - > - - - - - handleDeleteUser(user)} - disabled={user.id === currentUser?.id} - > - - - - - -
- ))} -
-
+ + user.id} + rowActions={(user) => ( + <> + + handleEditUser(user)} + aria-label={`Edit ${user.username}`} + > + + + + + handleDeleteUser(user)} + disabled={user.id === currentUser?.id} + aria-label={`Delete ${user.username}`} + > + + + + + )} + /> {/* Bottom Pagination */} diff --git a/web/src/pages/settings/index.ts b/web/src/pages/settings/index.ts index c5e93afa..45f25b7b 100644 --- a/web/src/pages/settings/index.ts +++ b/web/src/pages/settings/index.ts @@ -1,5 +1,6 @@ export { BooksInErrorSettings } from "./BooksInErrorSettings"; export { CleanupSettings } from "./CleanupSettings"; +export { DownloadsSettings } from "./DownloadsSettings"; export { DuplicatesSettings } from "./DuplicatesSettings"; export { IntegrationsSettings } from "./IntegrationsSettings"; export { MetricsSettings } from "./MetricsSettings"; diff --git a/web/src/sw.ts b/web/src/sw.ts new file mode 100644 index 00000000..c849dab7 --- /dev/null +++ b/web/src/sw.ts @@ -0,0 +1,162 @@ +/// + +/** + * Codex service worker — Phase 12 (`injectManifest` mode). + * + * Mirrors the runtime caching that Phase 6's `generateSW` config provided, + * plus a new per-book CacheFirst route that serves downloaded books from a + * dedicated cache. The downloaded-id set is hydrated from IndexedDB at boot + * and kept in sync via a BroadcastChannel published by the page-side + * download manager. + * + * Update flow stays manual: `clientsClaim()` is not called and the SW only + * skips waiting in response to the SKIP_WAITING message that + * `PwaUpdatePrompt` sends when the user confirms. + */ + +import { CacheableResponsePlugin } from "workbox-cacheable-response"; +import { ExpirationPlugin } from "workbox-expiration"; +import { + cleanupOutdatedCaches, + createHandlerBoundToURL, + precacheAndRoute, +} from "workbox-precaching"; +import { NavigationRoute, registerRoute } from "workbox-routing"; +import { CacheFirst, NetworkFirst } from "workbox-strategies"; + +import { + DOWNLOADS_BROADCAST_CHANNEL, + type DownloadsBroadcast, + getAllDownloads, +} from "./lib/offline/db"; +import { + cacheNameForBook, + matchDownloadedBookRequest, +} from "./lib/offline/routeMatcher"; + +declare const self: ServiceWorkerGlobalScope & { + __WB_MANIFEST: Array<{ url: string; revision: string | null }>; +}; + +// 1) Precache the app shell using the manifest injected at build time. +precacheAndRoute(self.__WB_MANIFEST); +cleanupOutdatedCaches(); + +// 2) SPA navigation fallback. Serve /index.html for client-side routes so +// deep links (e.g. /library/123/series/abc) resolve under standalone +// display mode. Backend paths are excluded so they always hit network. +const NAVIGATION_DENYLIST = [ + /^\/api\//, + /^\/opds\//, + /^\/komga\//, + /^\/docs\//, + /^\/health$/, +]; +registerRoute( + new NavigationRoute(createHandlerBoundToURL("/index.html"), { + denylist: NAVIGATION_DENYLIST, + }), +); + +// 3) Downloaded books: per-book CacheFirst. Registered before the generic +// /api/* NetworkFirst route below so a downloaded book is served from +// its dedicated cache rather than the shared NetworkFirst flow. +const downloadedBookIds = new Set(); + +void (async () => { + try { + const downloads = await getAllDownloads(); + for (const record of downloads) { + if (record.status === "complete") { + downloadedBookIds.add(record.id); + } + } + } catch (err) { + // Database may not exist yet on the very first SW boot (before the page + // has written anything). Treat that as an empty set and move on. + console.warn("[sw] failed to hydrate downloaded book set", err); + } +})(); + +if (typeof BroadcastChannel !== "undefined") { + const channel = new BroadcastChannel(DOWNLOADS_BROADCAST_CHANNEL); + channel.addEventListener( + "message", + (ev: MessageEvent) => { + const payload = ev.data; + if (payload.kind === "put") { + if (payload.record.status === "complete") { + downloadedBookIds.add(payload.record.id); + } else { + downloadedBookIds.delete(payload.record.id); + } + } else if (payload.kind === "delete") { + downloadedBookIds.delete(payload.id); + } else if (payload.kind === "clear") { + downloadedBookIds.clear(); + } + }, + ); +} + +registerRoute( + ({ url, request }) => + matchDownloadedBookRequest(url, request.method, downloadedBookIds) !== null, + async ({ url, request, event }) => { + const match = matchDownloadedBookRequest( + url, + request.method, + downloadedBookIds, + ); + if (!match) { + // Race: the book was evicted between the matcher and the handler. + // Falling through to network keeps the response correct, just slow. + return fetch(request); + } + const handler = new CacheFirst({ + cacheName: cacheNameForBook(match.bookId), + plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })], + }); + return handler.handle({ request, event }); + }, +); + +// 4) Generic /api/* — NetworkFirst with a short cache TTL so a recent +// library listing stays visible offline without serving stale auth state. +registerRoute( + ({ url }) => url.pathname.startsWith("/api/"), + new NetworkFirst({ + cacheName: "codex-api", + networkTimeoutSeconds: 5, + plugins: [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ maxEntries: 64, maxAgeSeconds: 60 * 5 }), + ], + }), +); + +// 5) Fonts and images — CacheFirst, long TTL (rarely change). +registerRoute( + ({ request }) => + request.destination === "font" || request.destination === "image", + new CacheFirst({ + cacheName: "codex-assets", + plugins: [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 128, + maxAgeSeconds: 60 * 60 * 24 * 30, + }), + ], + }), +); + +// 6) Update flow. The page calls `updateServiceWorker(true)` from +// `PwaUpdatePrompt` which posts this message; we then activate the +// waiting SW immediately so the next navigation hits the fresh assets. +self.addEventListener("message", (event) => { + const data = event.data as { type?: string } | undefined; + if (data?.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); diff --git a/web/src/theme.ts b/web/src/theme.ts index c15bc679..246a1f55 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -45,6 +45,21 @@ export const theme = createTheme({ xl: "2rem", }, + // Breakpoints (em, matching Mantine's default scheme). + // + // We override `xs` to a phone-only line at ~482px (30.125em). This is below the + // common iPhone Pro Max portrait width (~430px) but above smaller phones, giving + // us a clean "phone vs tablet" cutoff. `sm` (768px) is kept at Mantine's default + // so existing `visibleFrom="sm"` / `hiddenFrom="sm"` sites are unaffected; new + // phone-tight behavior should use `xs` instead. + breakpoints: { + xs: "30.125em", + sm: "48em", + md: "62em", + lg: "75em", + xl: "88em", + }, + // Custom properties for layout other: { sidebarWidth: 240, diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 135d69aa..5f799580 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1,4 +1,6 @@ /// +/// +/// interface ImportMetaEnv { readonly VITE_MOCK_API: string; diff --git a/web/vite.config.ts b/web/vite.config.ts index c5b3a51a..46d450d4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,11 +1,41 @@ import path from "node:path"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; import tsconfigPaths from "vite-tsconfig-paths"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tsconfigPaths()], + plugins: [ + react(), + tsconfigPaths(), + VitePWA({ + registerType: "prompt", + // Skip the SW entirely in dev so MSW's mockServiceWorker.js owns the page. + // The hand-authored manifest at web/public/manifest.webmanifest is served + // as-is; vite-plugin-pwa only compiles the service worker source. + injectRegister: null, + manifest: false, + // Phase 12 moved from generateSW to injectManifest so the SW can own a + // custom route for per-book offline caches and the downloads broadcast + // bus. App-shell precache + the previous NetworkFirst/CacheFirst rules + // are reimplemented inside src/sw.ts. See tmp/implementation/planned/ + // mobile-support.md (Phase 12, T1). + strategies: "injectManifest", + srcDir: "src", + filename: "sw.ts", + injectManifest: { + globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], + // The main app bundle is currently ~2.9 MB. Allow precaching files up + // to 4 MiB so the full app shell loads instantly in standalone mode. + // Code-splitting would shrink this; revisit if the bundle grows further. + maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, + }, + devOptions: { + enabled: false, + }, + }), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"),