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/api/openapi.json b/docs/api/openapi.json index 7363429a..64cb6c11 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -27577,7 +27577,7 @@ ] } ], - "description": "Type-discriminated job config exposed over the wire.\n\nPhase 9 only ships the `metadata_refresh` variant; future job types\nextend the enum." + "description": "Type-discriminated job config exposed over the wire.\n\nCurrently only ships the `metadata_refresh` variant; future job types\nextend the enum." }, "LibraryJobDto": { "type": "object", @@ -28505,7 +28505,7 @@ }, "scope": { "$ref": "#/components/schemas/RefreshScope", - "description": "Refresh scope. Phase 9 only honours `series_only` at runtime." + "description": "Refresh scope. Currently only `series_only` is honoured at runtime." }, "skipRecentlySyncedWithinS": { "type": "integer", @@ -30107,7 +30107,7 @@ "null" ], "format": "float", - "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement** — Phase 6's MangaUpdates plugin owns the\ntranslation-release feed.", + "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement**; the MangaUpdates plugin owns the\ntranslation-release feed.", "example": 3.0 }, "upstreamGapProvider": { @@ -30115,7 +30115,7 @@ "string", "null" ], - "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe Phase 7 badge tooltip.", + "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe gap badge tooltip.", "example": "MangaBaka" }, "upstreamVolumeGap": { @@ -33137,7 +33137,7 @@ }, "RefreshScope": { "type": "string", - "description": "Scope of a metadata refresh job.\n\nPhase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The\nother variants are schema-accepted but rejected by the validator.", + "description": "Scope of a metadata refresh job.\n\nCurrently only [`RefreshScope::SeriesOnly`] is honoured at runtime. The\nother variants are schema-accepted but rejected by the validator.", "enum": [ "series_only", "books_only", @@ -35142,7 +35142,7 @@ "null" ], "format": "float", - "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement** — Phase 6's MangaUpdates plugin owns the\ntranslation-release feed.", + "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement**; the MangaUpdates plugin owns the\ntranslation-release feed.", "example": 3.0 }, "upstreamGapProvider": { @@ -35150,7 +35150,7 @@ "string", "null" ], - "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe Phase 7 badge tooltip.", + "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe gap badge tooltip.", "example": "MangaBaka" }, "upstreamVolumeGap": { 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/migration/src/lib.rs b/migration/src/lib.rs index 902fba6c..148f7c53 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -79,7 +79,7 @@ mod m20260201_000042_add_cover_lock; // Rate-limited task reschedule support mod m20260202_000043_add_task_reschedule_count; -// Book metadata expansion (Phase 1) +// Book metadata expansion mod m20260202_000044_book_metadata_expansion; mod m20260202_000046_create_book_external_ids; mod m20260202_000047_create_book_covers; @@ -137,25 +137,25 @@ mod m20260410_000066_add_export_type; // Split series_metadata.total_book_count into total_volume_count and total_chapter_count mod m20260502_000067_split_book_count; -// Drop legacy series_metadata.total_book_count and lock columns (Phase 9 hard removal) +// Drop legacy series_metadata.total_book_count and lock columns (hard removal) mod m20260502_000068_drop_book_count; -// Add chapter + chapter_lock columns to book_metadata (Phase 11 per-book classification) +// Add chapter + chapter_lock columns to book_metadata (per-book classification) mod m20260503_000069_add_book_chapter; -// Backfill volume/chapter from filename for already-scanned books (Phase 12) +// Backfill volume/chapter from filename for already-scanned books mod m20260503_000070_backfill_book_volume_chapter; -// Library jobs table for scheduled work (Phase 9 of scheduled-metadata-refresh). -// Filename retains the original Phase 1 name for git-history continuity; module +// Library jobs table for scheduled work. +// Filename retains the original name for git-history continuity; module // now creates the generic `library_jobs` table instead of adding a JSON column. mod m20260503_000071_add_metadata_refresh_config; -// Release tracking (Phase 1): series_tracking sidecar + series_aliases +// Release tracking: series_tracking sidecar + series_aliases mod m20260503_000072_create_release_tracking; -// Release tracking (Phase 2): release_sources + release_ledger +// Release tracking: release_sources + release_ledger mod m20260503_000073_create_release_ledger; -// Release tracking (Phase 6): per-series language preference for scanlation feeds +// Release tracking: per-series language preference for scanlation feeds mod m20260504_000074_add_tracking_languages; -// Release tracking (Phase 6): server-wide default language list +// Release tracking: server-wide default language list mod m20260504_000075_seed_release_tracking_languages; -// Release tracking (Phase 8 follow-up): server-wide notification filter settings +// Release tracking: server-wide notification filter settings mod m20260504_000076_seed_release_tracking_notify_filters; // Release tracking: per-source last-poll summary surfaced in the UI mod m20260505_000077_add_release_sources_last_summary; @@ -236,7 +236,7 @@ impl MigratorTrait for Migrator { Box::new(m20260201_000042_add_cover_lock::Migration), // Rate-limited task reschedule support Box::new(m20260202_000043_add_task_reschedule_count::Migration), - // Book metadata expansion (Phase 1) + // Book metadata expansion Box::new(m20260202_000044_book_metadata_expansion::Migration), Box::new(m20260202_000046_create_book_external_ids::Migration), Box::new(m20260202_000047_create_book_covers::Migration), @@ -278,23 +278,23 @@ impl MigratorTrait for Migrator { Box::new(m20260410_000066_add_export_type::Migration), // Split total_book_count into total_volume_count and total_chapter_count Box::new(m20260502_000067_split_book_count::Migration), - // Drop legacy total_book_count column and lock (Phase 9 hard removal) + // Drop legacy total_book_count column and lock (hard removal) Box::new(m20260502_000068_drop_book_count::Migration), - // Add chapter + chapter_lock columns to book_metadata (Phase 11) + // Add chapter + chapter_lock columns to book_metadata Box::new(m20260503_000069_add_book_chapter::Migration), - // Backfill book_metadata.volume / .chapter from filename (Phase 12) + // Backfill book_metadata.volume / .chapter from filename Box::new(m20260503_000070_backfill_book_volume_chapter::Migration), - // Per-library scheduled metadata refresh config (Phase 1) + // Per-library scheduled metadata refresh config Box::new(m20260503_000071_add_metadata_refresh_config::Migration), - // Release tracking (Phase 1) + // Release tracking Box::new(m20260503_000072_create_release_tracking::Migration), - // Release tracking (Phase 2) + // Release tracking Box::new(m20260503_000073_create_release_ledger::Migration), - // Release tracking (Phase 6): per-series language preference + // Release tracking: per-series language preference Box::new(m20260504_000074_add_tracking_languages::Migration), - // Release tracking (Phase 6): server-wide default language list + // Release tracking: server-wide default language list Box::new(m20260504_000075_seed_release_tracking_languages::Migration), - // Release tracking (Phase 8 follow-up): notification filter settings + // Release tracking: notification filter settings Box::new(m20260504_000076_seed_release_tracking_notify_filters::Migration), // Release tracking: per-source last-poll summary Box::new(m20260505_000077_add_release_sources_last_summary::Migration), diff --git a/migration/src/m20260502_000067_split_book_count.rs b/migration/src/m20260502_000067_split_book_count.rs index 0a2cb602..4e9d1f1f 100644 --- a/migration/src/m20260502_000067_split_book_count.rs +++ b/migration/src/m20260502_000067_split_book_count.rs @@ -1,8 +1,8 @@ //! Split `series_metadata.total_book_count` into `total_volume_count` and `total_chapter_count`. //! -//! Phase 1 of the metadata-count-split plan: adds new columns + locks and backfills -//! the new volume column from the existing single book count, preserving the lock state. -//! The legacy `total_book_count` column stays in place until Phase 9 (hard removal). +//! Adds new columns + locks and backfills the new volume column from the existing +//! single book count, preserving the lock state. The legacy `total_book_count` +//! column stays in place until a later hard-removal migration. //! //! Why: `total_book_count` is overloaded (volumes, chapters, or whatever). Splitting it //! lets chapter-organized libraries show real "behind by N" indicators against provider diff --git a/migration/src/m20260502_000068_drop_book_count.rs b/migration/src/m20260502_000068_drop_book_count.rs index 01271afe..a2c68b6a 100644 --- a/migration/src/m20260502_000068_drop_book_count.rs +++ b/migration/src/m20260502_000068_drop_book_count.rs @@ -1,17 +1,16 @@ //! Drop the legacy `series_metadata.total_book_count` and `total_book_count_lock` columns. //! -//! Phase 9 of the metadata-count-split plan: hard removal. The new -//! `total_volume_count` / `total_chapter_count` columns and their locks are now -//! the sole source of truth, written by `MetadataApplier` and surfaced through -//! every read site. The legacy column is no longer read or written anywhere in -//! the codebase, so we drop it to make any leftover reference fail at compile -//! or runtime. +//! Hard removal. The new `total_volume_count` / `total_chapter_count` columns +//! and their locks are now the sole source of truth, written by +//! `MetadataApplier` and surfaced through every read site. The legacy column +//! is no longer read or written anywhere in the codebase, so we drop it to make +//! any leftover reference fail at compile or runtime. //! //! Down: re-adds the legacy columns as nullable (value) and not-null+default //! (lock). No data restore is possible (volume data was already copied across //! in migration 067 but is not symmetric to a chapter-organized state). Down //! exists for symmetry and dev-environment reset; production rollback requires -//! restoring from a pre-Phase-9 backup. +//! restoring from a pre-removal backup. use sea_orm_migration::prelude::*; diff --git a/migration/src/m20260503_000069_add_book_chapter.rs b/migration/src/m20260503_000069_add_book_chapter.rs index 4e4a75aa..2d2330b4 100644 --- a/migration/src/m20260503_000069_add_book_chapter.rs +++ b/migration/src/m20260503_000069_add_book_chapter.rs @@ -1,4 +1,4 @@ -//! Add `chapter` and `chapter_lock` columns to `book_metadata` (Phase 11 of metadata-count-split). +//! Add `chapter` and `chapter_lock` columns to `book_metadata`. //! //! Per-book classification: `book_metadata` already has `volume Option` and //! `volume_lock`. This migration adds the sibling `chapter Option` plus diff --git a/migration/src/m20260503_000070_backfill_book_volume_chapter.rs b/migration/src/m20260503_000070_backfill_book_volume_chapter.rs index 0b2f534a..84a82344 100644 --- a/migration/src/m20260503_000070_backfill_book_volume_chapter.rs +++ b/migration/src/m20260503_000070_backfill_book_volume_chapter.rs @@ -1,7 +1,7 @@ //! Backfill `book_metadata.volume` and `book_metadata.chapter` from the -//! structured filename parser (Phase 12 of metadata-count-split). +//! structured filename parser. //! -//! Phase 11 added the `chapter` column; the scanner now writes both columns +//! The `chapter` column was added earlier; the scanner now writes both columns //! on insert/rescan. This migration handles the population for already-scanned //! libraries: re-parse each book's `file_name` and update `volume` / `chapter` //! where they are currently NULL and the parser has a value. diff --git a/migration/src/m20260503_000071_add_metadata_refresh_config.rs b/migration/src/m20260503_000071_add_metadata_refresh_config.rs index c2578dcf..5dbc2d53 100644 --- a/migration/src/m20260503_000071_add_metadata_refresh_config.rs +++ b/migration/src/m20260503_000071_add_metadata_refresh_config.rs @@ -1,13 +1,13 @@ -//! Create the `library_jobs` table (Phase 9 of scheduled-metadata-refresh). +//! Create the `library_jobs` table. //! -//! Replaces the original Phase 1 design (a `metadata_refresh_config` JSON -//! column on `libraries`) with a generic, type-discriminated table that -//! supports N independent jobs per library. The `type` column dispatches to -//! type-specific config; `metadata_refresh` is the first type. Future job -//! types (scan, cleanup) extend the discriminator without schema changes. +//! Replaces the original design (a `metadata_refresh_config` JSON column on +//! `libraries`) with a generic, type-discriminated table that supports N +//! independent jobs per library. The `type` column dispatches to type-specific +//! config; `metadata_refresh` is the first type. Future job types (scan, +//! cleanup) extend the discriminator without schema changes. //! //! The migration filename is preserved (timestamp stays the same) because -//! the original Phase 1 migration never shipped to production. +//! the original migration never shipped to production. use sea_orm_migration::prelude::*; diff --git a/migration/src/m20260503_000072_create_release_tracking.rs b/migration/src/m20260503_000072_create_release_tracking.rs index 49b98d08..e0b20135 100644 --- a/migration/src/m20260503_000072_create_release_tracking.rs +++ b/migration/src/m20260503_000072_create_release_tracking.rs @@ -1,4 +1,4 @@ -//! Create release-tracking schema (Phase 1 of release-tracking implementation). +//! Create release-tracking schema. //! //! Adds two tables that augment the existing `series` and `series_external_ids` //! tables for tracked-series support: diff --git a/migration/src/m20260503_000073_create_release_ledger.rs b/migration/src/m20260503_000073_create_release_ledger.rs index a3557db6..8486c9f0 100644 --- a/migration/src/m20260503_000073_create_release_ledger.rs +++ b/migration/src/m20260503_000073_create_release_ledger.rs @@ -1,4 +1,4 @@ -//! Create release-tracking ledger schema (Phase 2 of release-tracking implementation). +//! Create release-tracking ledger schema. //! //! Adds two tables that store release announcements emitted by source plugins: //! @@ -47,7 +47,7 @@ impl MigrationTrait for Migration { sources // Owning plugin. The string `"core"` is reserved for in-core - // synthetic sources (e.g., metadata-piggyback in Phase 5) so we + // synthetic sources (e.g., metadata-piggyback) so we // don't need a foreign key to plugins.id (which would force every // synthetic source to also have a plugins row). .col( diff --git a/migration/src/m20260504_000074_add_tracking_languages.rs b/migration/src/m20260504_000074_add_tracking_languages.rs index c853a16c..3df2831f 100644 --- a/migration/src/m20260504_000074_add_tracking_languages.rs +++ b/migration/src/m20260504_000074_add_tracking_languages.rs @@ -1,4 +1,4 @@ -//! Add `languages` column to `series_tracking` (Phase 6 of release-tracking). +//! Add `languages` column to `series_tracking`. //! //! Per-series language preference for release-source plugins (e.g. //! MangaUpdates) that aggregate scanlations across many languages. Stored as a diff --git a/migration/src/m20260504_000075_seed_release_tracking_languages.rs b/migration/src/m20260504_000075_seed_release_tracking_languages.rs index 0f451eee..9c703981 100644 --- a/migration/src/m20260504_000075_seed_release_tracking_languages.rs +++ b/migration/src/m20260504_000075_seed_release_tracking_languages.rs @@ -1,4 +1,4 @@ -//! Seed the server-wide `release_tracking.default_languages` setting (Phase 6). +//! Seed the server-wide `release_tracking.default_languages` setting. //! //! Aggregation feeds (e.g. MangaUpdates RSS) emit candidates in many languages. //! Plugins filter client-side using a per-series `series_tracking.languages` diff --git a/migration/src/m20260504_000076_seed_release_tracking_notify_filters.rs b/migration/src/m20260504_000076_seed_release_tracking_notify_filters.rs index 105a8c73..88afb4b8 100644 --- a/migration/src/m20260504_000076_seed_release_tracking_notify_filters.rs +++ b/migration/src/m20260504_000076_seed_release_tracking_notify_filters.rs @@ -1,5 +1,5 @@ //! Seed the server-wide `release_tracking.notify_languages` and -//! `release_tracking.notify_plugins` settings (Phase 8 follow-up). +//! `release_tracking.notify_plugins` settings. //! //! These two arrays filter the in-app `release_announced` notification stream //! (toasts + Releases nav badge): diff --git a/plugins/release-mangaupdates/src/index.ts b/plugins/release-mangaupdates/src/index.ts index 6f3dee29..e6064de8 100644 --- a/plugins/release-mangaupdates/src/index.ts +++ b/plugins/release-mangaupdates/src/index.ts @@ -205,7 +205,7 @@ async function* iterateTrackedSeries( * * However, the current `releases/list_tracked` response shape doesn't * expose per-series `languages` — see plan doc for this design choice. - * For Phase 6 the plugin reads its admin-level group blocklist and emits + * Currently the plugin reads its admin-level group blocklist and emits * candidates with the language tag from the parsed entry; the host's * `latest_known_*` advance gate enforces the per-series language list * authoritatively (see `services/release/languages.rs`). diff --git a/screenshots/scripts/capture.ts b/screenshots/scripts/capture.ts index c5adba5b..54926b64 100644 --- a/screenshots/scripts/capture.ts +++ b/screenshots/scripts/capture.ts @@ -2,7 +2,7 @@ import { chromium, Browser, BrowserContext, Page } from "playwright"; import { config } from "../playwright.config.js"; import { printScreenshotSummary } from "./utils/screenshot.js"; -// Import scenarios (will be implemented in Phase 3) +// Scenario imports (placeholders, not yet implemented): // import { runSetupScenario } from "./scenarios/setup.js"; // import { runLibrariesScenario } from "./scenarios/libraries.js"; // import { runSettingsScenario } from "./scenarios/settings.js"; diff --git a/src/api/docs.rs b/src/api/docs.rs index c8946817..6c091d78 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -263,7 +263,7 @@ The following paths are exempt from rate limiting: v1::handlers::tracking::create_series_alias, v1::handlers::tracking::delete_series_alias, - // Release ledger + sources (Phase 2) + // Release ledger + sources v1::handlers::releases::list_series_releases, v1::handlers::releases::list_release_inbox, v1::handlers::releases::update_release_entry, @@ -711,7 +711,7 @@ The following paths are exempt from rate limiting: v1::dto::tracking::SeriesAliasListResponse, v1::dto::tracking::CreateSeriesAliasRequest, - // Release-ledger + source DTOs (Phase 2) + // Release-ledger + source DTOs v1::dto::release::ReleaseSpanDto, v1::dto::release::ReleaseLedgerEntryDto, v1::dto::release::ReleaseLedgerListResponse, @@ -945,7 +945,7 @@ The following paths are exempt from rate limiting: v1::dto::EnqueueBulkAutoMatchRequest, v1::dto::EnqueueLibraryAutoMatchRequest, - // Library Jobs DTOs (Phase 9) + // Library Jobs DTOs v1::dto::LibraryJobDto, v1::dto::LibraryJobConfigDto, v1::dto::MetadataRefreshJobConfigDto, diff --git a/src/api/permissions.rs b/src/api/permissions.rs index d04d2f0e..e31848f1 100644 --- a/src/api/permissions.rs +++ b/src/api/permissions.rs @@ -34,7 +34,7 @@ impl UserRole { /// /// Admin can assign any role, Maintainer can only assign Reader, /// Reader cannot assign roles. - #[allow(dead_code)] // Used in Phase 2 for user role assignment API + #[allow(dead_code)] // Reserved for the user role assignment API pub fn can_assign(&self, target: UserRole) -> bool { match self { UserRole::Admin => true, @@ -44,7 +44,7 @@ impl UserRole { } /// Returns all possible role values - #[allow(dead_code)] // Used in Phase 2 for user role assignment API + #[allow(dead_code)] // Reserved for the user role assignment API pub fn all() -> &'static [UserRole] { &[UserRole::Reader, UserRole::Maintainer, UserRole::Admin] } diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index 579e4722..970c5e97 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -707,7 +707,7 @@ pub struct BookMetadataDto { pub editors: Vec, // ========================================================================== - // New book metadata fields (Phase 6) + // New book metadata fields // ========================================================================== /// Book type classification (comic, manga, novel, etc.) #[serde(skip_serializing_if = "Option::is_none")] @@ -919,7 +919,7 @@ pub struct ReplaceBookMetadataRequest { pub isbns: Option, // ========================================================================== - // New book metadata fields (Phase 6) + // New book metadata fields // ========================================================================== /// Book type classification (comic, manga, novel, etc.) #[schema(example = "novel")] @@ -1088,7 +1088,7 @@ pub struct PatchBookMetadataRequest { pub isbns: super::patch::PatchValue, // ========================================================================== - // New book metadata fields (Phase 6) + // New book metadata fields // ========================================================================== /// Book type classification (comic, manga, novel, etc.) #[serde(default)] @@ -1248,7 +1248,7 @@ pub struct BookMetadataResponse { pub isbns: Option, // ========================================================================== - // New book metadata fields (Phase 6) + // New book metadata fields // ========================================================================== /// Book type classification (comic, manga, novel, etc.) #[serde(skip_serializing_if = "Option::is_none")] @@ -1423,7 +1423,7 @@ pub struct BookMetadataLocks { pub isbns_lock: bool, // ========================================================================== - // New lock fields (Phase 6) + // New lock fields // ========================================================================== /// Whether book_type is locked #[schema(example = false)] @@ -1561,7 +1561,7 @@ pub struct UpdateBookMetadataLocksRequest { pub isbns_lock: Option, // ========================================================================== - // New lock fields (Phase 6) + // New lock fields // ========================================================================== /// Whether to lock book_type pub book_type_lock: Option, @@ -2011,7 +2011,7 @@ pub struct BookFullMetadata { pub isbns: Option, // ========================================================================== - // Phase 6 fields (book-specific rich metadata) + // Book-specific rich metadata fields // ========================================================================== /// Book type classification (comic, manga, novel, etc.) #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/api/routes/v1/dto/library_jobs.rs b/src/api/routes/v1/dto/library_jobs.rs index 4ab74054..f596be85 100644 --- a/src/api/routes/v1/dto/library_jobs.rs +++ b/src/api/routes/v1/dto/library_jobs.rs @@ -1,4 +1,4 @@ -//! DTOs for `/api/v1/libraries/{id}/jobs` (Phase 9). +//! DTOs for `/api/v1/libraries/{id}/jobs`. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -11,7 +11,7 @@ use crate::services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, /// Type-discriminated job config exposed over the wire. /// -/// Phase 9 only ships the `metadata_refresh` variant; future job types +/// Currently only ships the `metadata_refresh` variant; future job types /// extend the enum. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -25,7 +25,7 @@ pub enum LibraryJobConfigDto { pub struct MetadataRefreshJobConfigDto { /// Plugin reference, e.g. `"plugin:mangabaka"`. pub provider: String, - /// Refresh scope. Phase 9 only honours `series_only` at runtime. + /// Refresh scope. Currently only `series_only` is honoured at runtime. #[serde(default)] pub scope: RefreshScope, /// Series-side field groups (snake_case identifiers). diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 4dd25986..32a4c568 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -1007,7 +1007,7 @@ pub fn available_credential_delivery_methods() -> Vec<&'static str> { } // ============================================================================= -// Plugin Actions DTOs (Phase 4) +// Plugin Actions DTOs // ============================================================================= /// A plugin action available for a specific scope @@ -1214,7 +1214,7 @@ pub struct PluginSearchResponse { } // ============================================================================= -// Metadata Preview/Apply DTOs (Phase 4) +// Metadata Preview/Apply DTOs // ============================================================================= /// Status of a field during metadata preview diff --git a/src/api/routes/v1/dto/release.rs b/src/api/routes/v1/dto/release.rs index 85d513d3..1eb9bf8f 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/src/api/routes/v1/dto/release.rs @@ -6,7 +6,7 @@ //! admin source management UI. //! //! Note: this module deliberately does NOT introduce a new `ReleaseAnnounced` -//! event variant - that lands in Phase 7 along with the frontend inbox UI. +//! event variant; that lands later along with the frontend inbox UI. //! State-change endpoints in this module emit `SeriesUpdated` events with a //! `releases` field marker so the existing event broadcaster carries them. diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index 1d44ae73..a69e822d 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -265,7 +265,7 @@ pub struct SeriesDto { /// Always `None` unless the series is tracked AND `track_chapters` is /// enabled AND the provider count is populated AND the rounded-to-1- /// decimal gap is positive. **This is an informational signal, not a - /// release announcement** — Phase 6's MangaUpdates plugin owns the + /// release announcement**; the MangaUpdates plugin owns the /// translation-release feed. #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 3.0)] @@ -282,7 +282,7 @@ pub struct SeriesDto { /// Display name of the metadata provider that supplied the upstream /// counts (e.g., "MangaBaka", "AniList"). Set whenever at least one of /// `upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by - /// the Phase 7 badge tooltip. + /// the gap badge tooltip. #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = "MangaBaka")] pub upstream_gap_provider: Option, diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 8e97486f..f1270ded 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -485,7 +485,7 @@ pub async fn books_to_full_dtos_batched( chapter: meta.chapter, count: meta.count, isbns: meta.isbns.clone(), - // Phase 6 fields + // Book-specific rich metadata fields book_type: meta .book_type .as_ref() @@ -1074,7 +1074,7 @@ pub async fn get_book( letterers: extract_authors_by_role(&meta.authors_json, "letterer"), cover_artists: extract_authors_by_role(&meta.authors_json, "cover_artist"), editors: extract_authors_by_role(&meta.authors_json, "editor"), - // New Phase 6 fields + // New book-specific rich metadata fields book_type: meta .book_type .as_ref() @@ -1227,7 +1227,7 @@ pub async fn patch_book( chapter: Set(None), count: Set(None), isbns: Set(None), - // New Phase 1 fields + // New book metadata fields book_type: Set(None), subtitle: Set(None), authors_json: Set(None), @@ -1259,7 +1259,7 @@ pub async fn patch_book( chapter_lock: Set(false), count_lock: Set(false), isbns_lock: Set(false), - // New Phase 1 lock fields + // New book metadata lock fields book_type_lock: Set(false), subtitle_lock: Set(false), authors_json_lock: Set(false), @@ -2196,7 +2196,7 @@ pub async fn replace_book_metadata( chapter: Set(request.chapter), count: Set(request.count), isbns: Set(request.isbns.clone()), - // New Phase 1 fields + // New book metadata fields book_type: Set(None), subtitle: Set(None), authors_json: Set(new_authors_json), @@ -2228,7 +2228,7 @@ pub async fn replace_book_metadata( chapter_lock: Set(request.chapter.is_some()), count_lock: Set(request.count.is_some()), isbns_lock: Set(request.isbns.is_some()), - // New Phase 1 lock fields + // New book metadata lock fields book_type_lock: Set(false), subtitle_lock: Set(false), authors_json_lock: Set(any_author_set), @@ -2313,7 +2313,7 @@ pub async fn replace_book_metadata( chapter: updated.chapter, count: updated.count, isbns: updated.isbns, - // New Phase 6 fields + // New book-specific rich metadata fields book_type: updated .book_type .as_ref() @@ -2587,7 +2587,7 @@ pub async fn patch_book_metadata( } has_changes = true; } - // New Phase 6 fields + // New book-specific rich metadata fields if let Some(opt) = request.book_type.into_nested_option() { let book_type_str = opt.as_ref().map(|bt| bt.to_string()); active.book_type = Set(book_type_str); @@ -2729,7 +2729,7 @@ pub async fn patch_book_metadata( let chapter_opt = request.chapter.into_option(); let count_opt = request.count.into_option(); let isbns_opt = request.isbns.into_option(); - // New Phase 6 fields + // New book-specific rich metadata fields let book_type_opt = request.book_type.into_option(); let book_type_str = book_type_opt.as_ref().map(|bt| bt.to_string()); let subtitle_opt = request.subtitle.into_option(); @@ -2795,7 +2795,7 @@ pub async fn patch_book_metadata( chapter: Set(chapter_opt), count: Set(count_opt), isbns: Set(isbns_opt.clone()), - // New Phase 6 fields + // New book-specific rich metadata fields book_type: Set(book_type_str.clone()), subtitle: Set(subtitle_opt.clone()), authors_json: Set(merged_authors_json), @@ -2829,7 +2829,7 @@ pub async fn patch_book_metadata( chapter_lock: Set(chapter_opt.is_some()), count_lock: Set(count_opt.is_some()), isbns_lock: Set(isbns_opt.is_some()), - // New Phase 6 lock fields + // New book-specific rich metadata lock fields book_type_lock: Set(book_type_str.is_some()), subtitle_lock: Set(subtitle_opt.is_some()), authors_json_lock: Set(any_author_set), @@ -2916,7 +2916,7 @@ pub async fn patch_book_metadata( chapter: updated.chapter, count: updated.count, isbns: updated.isbns, - // New Phase 6 fields + // New book-specific rich metadata fields book_type: updated .book_type .as_ref() @@ -3052,7 +3052,7 @@ pub async fn get_book_metadata_locks( chapter_lock: metadata.chapter_lock, count_lock: metadata.count_lock, isbns_lock: metadata.isbns_lock, - // New Phase 6 lock fields + // New book-specific rich metadata lock fields book_type_lock: metadata.book_type_lock, subtitle_lock: metadata.subtitle_lock, authors_json_lock: metadata.authors_json_lock, @@ -3181,7 +3181,7 @@ pub async fn update_book_metadata_locks( if let Some(v) = request.isbns_lock { active.isbns_lock = Set(v); } - // New Phase 6 lock fields + // New book-specific rich metadata lock fields if let Some(v) = request.book_type_lock { active.book_type_lock = Set(v); } @@ -3268,7 +3268,7 @@ pub async fn update_book_metadata_locks( chapter_lock: updated.chapter_lock, count_lock: updated.count_lock, isbns_lock: updated.isbns_lock, - // New Phase 6 lock fields + // New book-specific rich metadata lock fields book_type_lock: updated.book_type_lock, subtitle_lock: updated.subtitle_lock, authors_json_lock: updated.authors_json_lock, diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs index cc8c56b6..a823e348 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -1,4 +1,4 @@ -//! Library jobs CRUD + run-now + dry-run handlers (Phase 9). +//! Library jobs CRUD + run-now + dry-run handlers. use axum::{ Json, @@ -426,7 +426,7 @@ pub async fn dry_run_job( .unwrap_or(DRY_RUN_DEFAULT_SAMPLE) .min(DRY_RUN_MAX_SAMPLE) as usize; - // For Phase 9 we return a planner-only sample (no plugin call). Phase 6 + // We return a planner-only sample (no plugin call). An earlier design // executed plugin calls per pair; the downsides (slow, brittle) outweigh // the marginal benefit when the user is previewing a single provider. let mut sample = Vec::new(); diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index 1f85cbef..041f4a52 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -1,4 +1,4 @@ -//! Plugin Actions API handlers (Phase 4) +//! Plugin Actions API handlers //! //! Provides endpoints for plugin action discovery and execution: //! - GET /api/v1/plugins/actions - Get available plugin actions for a scope diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index d087110d..5cb3e18e 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -9,8 +9,8 @@ //! 3. Source admin (`GET /release-sources`, `PATCH /release-sources/{id}`, //! `POST /release-sources/{id}/poll-now`) - admin-only source management. //! -//! Phase 2 keeps `poll-now` as a stub returning HTTP 501; Phase 4 wires it -//! into the task queue. +//! `poll-now` started as a stub returning HTTP 501; it is now wired into the +//! task queue. use axum::{ Json, diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 5cd292e7..b243b8a0 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -183,8 +183,8 @@ async fn series_to_dto( .map(|m| m.title.clone()) .unwrap_or_else(|| "Unknown Series".to_string()); - // Phase 5 of release-tracking: compute the upstream-publication gap - // signal. Skipped entirely for untracked series. + // Compute the upstream-publication gap signal. Skipped entirely for + // untracked series. let tracking = SeriesTrackingRepository::get(db, series.id) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch series tracking: {:?}", e)))?; @@ -454,7 +454,7 @@ async fn series_to_full_dtos_batched( .map(|ids| ids.iter().cloned().map(SeriesExternalIdDto::from).collect()) .unwrap_or_default(); - // Phase 5 of release-tracking: upstream-publication gap signal. + // Upstream-publication gap signal. let series_external_ids = ext_ids_map .get(&series_id) .map(|v| v.as_slice()) diff --git a/src/api/routes/v1/routes/libraries.rs b/src/api/routes/v1/routes/libraries.rs index c20c93d6..fe0d0b81 100644 --- a/src/api/routes/v1/routes/libraries.rs +++ b/src/api/routes/v1/routes/libraries.rs @@ -91,7 +91,7 @@ pub fn routes(_state: Arc) -> Router> { "/libraries/{library_id}/analyze-unanalyzed", post(handlers::trigger_library_unanalyzed_analysis), ) - // Plugin auto-match for library (Phase 5.5) + // Plugin auto-match for library .route( "/libraries/{library_id}/metadata/auto-match/task", post(handlers::plugin_actions::enqueue_library_auto_match_tasks), @@ -101,7 +101,7 @@ pub fn routes(_state: Arc) -> Router> { "/libraries/{library_id}/series/titles/reprocess", post(handlers::task_queue::reprocess_library_series_titles), ) - // Library jobs (Phase 9): generic CRUD for per-library scheduled work. + // Library jobs: generic CRUD for per-library scheduled work. .route( "/libraries/{library_id}/jobs", get(handlers::library_jobs::list_jobs).post(handlers::library_jobs::create_job), diff --git a/src/api/routes/v1/routes/series.rs b/src/api/routes/v1/routes/series.rs index 432aa18f..87abe4c0 100644 --- a/src/api/routes/v1/routes/series.rs +++ b/src/api/routes/v1/routes/series.rs @@ -309,7 +309,7 @@ pub fn routes(_state: Arc) -> Router> { "/series/bulk/genres", post(handlers::bulk_modify_series_genres), ) - // Series metadata from plugins (Phase 4) + // Series metadata from plugins .route( "/series/{series_id}/metadata/search-title", get(handlers::plugin_actions::get_series_search_title), @@ -326,7 +326,7 @@ pub fn routes(_state: Arc) -> Router> { "/series/{series_id}/metadata/auto-match", post(handlers::plugin_actions::auto_match_series_metadata), ) - // Task-based auto-match endpoints (Phase 5.5 - Worker plugin integration) + // Task-based auto-match endpoints (worker plugin integration) .route( "/series/{series_id}/metadata/auto-match/task", post(handlers::plugin_actions::enqueue_auto_match_task), @@ -367,7 +367,7 @@ pub fn routes(_state: Arc) -> Router> { "/series/{series_id}/aliases/{alias_id}", delete(handlers::tracking::delete_series_alias), ) - // Per-series release ledger (Phase 2) + // Per-series release ledger .route( "/series/{series_id}/releases", get(handlers::releases::list_series_releases), diff --git a/src/db/entities/book_metadata.rs b/src/db/entities/book_metadata.rs index ae7e8fae..5b17d5dd 100644 --- a/src/db/entities/book_metadata.rs +++ b/src/db/entities/book_metadata.rs @@ -137,7 +137,7 @@ pub struct Model { pub chapter: Option, pub count: Option, pub isbns: Option, - // New book metadata fields (Phase 1) + // Extended book metadata fields /// Book type classification (comic, manga, novel, etc.) pub book_type: Option, /// Book subtitle @@ -183,7 +183,7 @@ pub struct Model { pub chapter_lock: bool, pub count_lock: bool, pub isbns_lock: bool, - // New lock fields for Phase 1 fields + // Lock fields for the extended book metadata fields pub book_type_lock: bool, pub subtitle_lock: bool, pub authors_json_lock: bool, diff --git a/src/db/entities/library_jobs.rs b/src/db/entities/library_jobs.rs index 63cd4424..19e9d27f 100644 --- a/src/db/entities/library_jobs.rs +++ b/src/db/entities/library_jobs.rs @@ -2,7 +2,7 @@ //! //! Generic across job types via the `r#type` discriminator. The `config` //! column carries a JSON payload whose shape depends on `r#type`. -//! Phase 9 introduces the `metadata_refresh` type; future work can add +//! Currently the `metadata_refresh` type is supported; future work can add //! `scan`, `cleanup`, etc. without schema changes. use chrono::{DateTime, Utc}; diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index e5382880..6eeda350 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -435,10 +435,10 @@ pub enum PluginPermission { /// Update ISBN identifiers #[serde(rename = "metadata:write:isbn")] MetadataWriteIsbn, - /// Update per-book volume number (Phase 12 of metadata-count-split) + /// Update per-book volume number #[serde(rename = "metadata:write:volume")] MetadataWriteVolume, - /// Update per-book chapter number (Phase 12 of metadata-count-split) + /// Update per-book chapter number #[serde(rename = "metadata:write:chapter")] MetadataWriteChapter, diff --git a/src/db/entities/release_sources.rs b/src/db/entities/release_sources.rs index 178aef0e..20e968c0 100644 --- a/src/db/entities/release_sources.rs +++ b/src/db/entities/release_sources.rs @@ -17,7 +17,7 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, /// Owning plugin id (string). The literal `"core"` is reserved for in-core - /// synthetic sources (e.g., metadata-piggyback in Phase 5). + /// synthetic sources (e.g., metadata-piggyback). /// /// This is the plugin's manifest *name* — the identifier plugins use to /// self-reference over RPC. It is *not* the canonical lifecycle anchor; @@ -89,9 +89,9 @@ impl ActiveModelBehavior for ActiveModel {} /// Canonical strings for `plugin_id`. pub mod plugin_id { - /// In-core synthetic sources (e.g., metadata-piggyback in Phase 5). Not a + /// In-core synthetic sources (e.g., metadata-piggyback). Not a /// real plugin; bypasses plugin-host lookup. - #[allow(dead_code)] // wired up in Phase 5 (metadata piggyback) + #[allow(dead_code)] // wired up by the metadata piggyback path pub const CORE: &str = "core"; } diff --git a/src/db/entities/series.rs b/src/db/entities/series.rs index b190fb54..c750c4b2 100644 --- a/src/db/entities/series.rs +++ b/src/db/entities/series.rs @@ -65,7 +65,7 @@ pub enum Relation { SeriesTracking, #[sea_orm(has_many = "super::series_aliases::Entity")] SeriesAliases, - // Release ledger entries for this series (Phase 2). + // Release ledger entries for this series. #[sea_orm(has_many = "super::release_ledger::Entity")] ReleaseLedger, } diff --git a/src/db/repositories/metadata.rs b/src/db/repositories/metadata.rs index d1d68016..c583d246 100644 --- a/src/db/repositories/metadata.rs +++ b/src/db/repositories/metadata.rs @@ -48,7 +48,7 @@ impl BookMetadataRepository { chapter: Set(metadata_model.chapter), count: Set(metadata_model.count), isbns: Set(metadata_model.isbns.clone()), - // New Phase 1 fields + // Extended book metadata fields book_type: Set(metadata_model.book_type.clone()), subtitle: Set(metadata_model.subtitle.clone()), authors_json: Set(metadata_model.authors_json.clone()), @@ -81,7 +81,7 @@ impl BookMetadataRepository { chapter_lock: Set(metadata_model.chapter_lock), count_lock: Set(metadata_model.count_lock), isbns_lock: Set(metadata_model.isbns_lock), - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: Set(metadata_model.book_type_lock), subtitle_lock: Set(metadata_model.subtitle_lock), authors_json_lock: Set(metadata_model.authors_json_lock), @@ -191,7 +191,7 @@ impl BookMetadataRepository { chapter: Set(metadata_model.chapter), count: Set(metadata_model.count), isbns: Set(metadata_model.isbns.clone()), - // New Phase 1 fields + // Extended book metadata fields book_type: Set(metadata_model.book_type.clone()), subtitle: Set(metadata_model.subtitle.clone()), authors_json: Set(metadata_model.authors_json.clone()), @@ -224,7 +224,7 @@ impl BookMetadataRepository { chapter_lock: Set(metadata_model.chapter_lock), count_lock: Set(metadata_model.count_lock), isbns_lock: Set(metadata_model.isbns_lock), - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: Set(metadata_model.book_type_lock), subtitle_lock: Set(metadata_model.subtitle_lock), authors_json_lock: Set(metadata_model.authors_json_lock), @@ -355,7 +355,7 @@ impl BookMetadataRepository { chapter: Set(None), count: Set(None), isbns: Set(None), - // New Phase 1 fields + // Extended book metadata fields book_type: Set(None), subtitle: Set(None), authors_json: Set(None), @@ -387,7 +387,7 @@ impl BookMetadataRepository { chapter_lock: Set(false), count_lock: Set(false), isbns_lock: Set(false), - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: Set(false), subtitle_lock: Set(false), authors_json_lock: Set(false), @@ -493,7 +493,7 @@ mod tests { chapter: None, count: None, isbns: None, - // New Phase 1 fields + // Extended book metadata fields book_type: None, subtitle: None, authors_json: None, @@ -525,7 +525,7 @@ mod tests { chapter_lock: false, count_lock: false, isbns_lock: false, - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: false, subtitle_lock: false, authors_json_lock: false, @@ -722,7 +722,7 @@ mod tests { assert!(!retrieved.publisher_lock); } - // -- Per-book volume/chapter classification (Phase 11) -- + // -- Per-book volume/chapter classification -- #[tokio::test] async fn test_chapter_round_trip_fractional() { diff --git a/src/db/repositories/release_sources.rs b/src/db/repositories/release_sources.rs index ae8da516..4719dde5 100644 --- a/src/db/repositories/release_sources.rs +++ b/src/db/repositories/release_sources.rs @@ -4,7 +4,7 @@ //! relationship is many-to-one: e.g., a single Nyaa plugin instance exposes //! one source per uploader subscription. CRUD here, plus state-tracking //! helpers (`record_poll_success`, `record_poll_error`) used by the polling -//! task in Phase 4. +//! task. #![allow(dead_code)] @@ -185,8 +185,8 @@ impl ReleaseSourceRepository { } /// Get-or-create a synthetic in-core source (used by the metadata-piggyback - /// path in Phase 5). Distinct from `create` so callers don't accidentally - /// create duplicate synthetic rows. + /// path). Distinct from `create` so callers don't accidentally create + /// duplicate synthetic rows. pub async fn get_or_create( db: &DatabaseConnection, params: NewReleaseSource, diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index f1b09a10..e511d04e 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -197,7 +197,7 @@ impl From for series::Model { /// `book_metadata.chapter`. /// /// Used by series DTOs to render `/` counts when structured -/// data has been populated by the scanner (Phase 12). All fields are +/// data has been populated by the scanner. All fields are /// `None` when no books in the series have the underlying value set, /// keeping the legacy `/` display intact for /// unclassified libraries. diff --git a/src/parsers/comic_info.rs b/src/parsers/comic_info.rs index d306168e..834585d7 100644 --- a/src/parsers/comic_info.rs +++ b/src/parsers/comic_info.rs @@ -127,10 +127,10 @@ pub fn parse_comic_info(xml_content: &str) -> Result`. ComicInfo's `` field is overloaded — issue, chapter, - // or part depending on the producer. v1: read it as a chapter; users whose - // files use it for issues can lock `chapter` after manual fix. + // Derive a structured `chapter` from ``. ComicInfo's `` + // field is overloaded (issue, chapter, or part depending on the producer). + // v1: read it as a chapter; users whose files use it for issues can lock + // `chapter` after manual fix. let chapter = xml_info .number .as_deref() @@ -360,8 +360,8 @@ mod tests { #[test] fn test_parse_comic_info_derives_chapter_from_number() { - // Phase 12 of metadata-count-split: ComicInfo `` is the chapter - // axis on the parsed struct. Integer parses cleanly; fractional preserved. + // ComicInfo `` is the chapter axis on the parsed struct. + // Integer parses cleanly; fractional preserved. let xml = r#" 42 diff --git a/src/parsers/metadata.rs b/src/parsers/metadata.rs index 8eba29a4..0f369766 100644 --- a/src/parsers/metadata.rs +++ b/src/parsers/metadata.rs @@ -214,9 +214,9 @@ pub struct ComicInfo { pub count: Option, pub volume: Option, /// Chapter number derived from ComicInfo ``. ComicInfo overloads - /// `` (issue / chapter / part) — Phase 12 of metadata-count-split - /// reads it as a chapter unconditionally and lets users lock the field if - /// their files use `` for issues instead. + /// `` (issue / chapter / part); we read it as a chapter + /// unconditionally and let users lock the field if their files use + /// `` for issues instead. pub chapter: Option, pub summary: Option, pub year: Option, diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index a3b78c8a..11e8a571 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -285,7 +285,7 @@ async fn analyze_single_book( // Resolve book title using the library's book metadata strategy. // Note: title and number are now stored in book_metadata, not books table. let resolved_title = resolve_book_title(db, &book, &metadata, resolved_number).await; - // Phase 11: resolve structured volume/chapter via the same strategy. + // Resolve structured volume/chapter via the same strategy. let resolved_classification = resolve_book_classification(db, &book, &metadata, resolved_number).await; let resolved_number_decimal = @@ -531,7 +531,7 @@ async fn analyze_single_book( } else { isbns_json.clone() }, - // New Phase 1 fields - preserve existing values (not populated from ComicInfo) + // Extended book metadata fields - preserve existing values (not populated from ComicInfo) book_type: existing.book_type.clone(), subtitle: existing.subtitle.clone(), translator: existing.translator.clone(), @@ -562,7 +562,7 @@ async fn analyze_single_book( chapter_lock: existing.chapter_lock, count_lock: existing.count_lock, isbns_lock: existing.isbns_lock, - // New Phase 1 lock fields - preserve existing + // Extended book metadata lock fields - preserve existing book_type_lock: existing.book_type_lock, subtitle_lock: existing.subtitle_lock, authors_json_lock: existing.authors_json_lock, @@ -607,7 +607,7 @@ async fn analyze_single_book( chapter: resolved_classification.chapter, count: comic_info.count, isbns: isbns_json, - // New Phase 1 fields + // Extended book metadata fields book_type: None, subtitle: None, authors_json: comic_info.authors_json.clone(), @@ -639,7 +639,7 @@ async fn analyze_single_book( chapter_lock: false, count_lock: false, isbns_lock: false, - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: false, subtitle_lock: false, authors_json_lock: false, @@ -780,7 +780,7 @@ async fn analyze_single_book( }, count: existing.count, isbns: existing.isbns.clone(), - // New Phase 1 fields - preserve existing values + // Extended book metadata fields - preserve existing values book_type: existing.book_type.clone(), subtitle: existing.subtitle.clone(), authors_json: existing.authors_json.clone(), @@ -812,7 +812,7 @@ async fn analyze_single_book( chapter_lock: existing.chapter_lock, count_lock: existing.count_lock, isbns_lock: existing.isbns_lock, - // New Phase 1 lock fields - preserve existing + // Extended book metadata lock fields - preserve existing book_type_lock: existing.book_type_lock, subtitle_lock: existing.subtitle_lock, authors_json_lock: existing.authors_json_lock, @@ -853,7 +853,7 @@ async fn analyze_single_book( chapter: resolved_classification.chapter, count: None, isbns: None, - // New Phase 1 fields + // Extended book metadata fields book_type: None, subtitle: None, authors_json: None, @@ -884,7 +884,7 @@ async fn analyze_single_book( chapter_lock: false, count_lock: false, isbns_lock: false, - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: false, subtitle_lock: false, authors_json_lock: false, @@ -1083,7 +1083,7 @@ async fn analyze_single_book( } /// Per-book classification output from the active book metadata strategy. -/// Phase 11: lets scanner write `book_metadata.volume` and `book_metadata.chapter` +/// Lets the scanner write `book_metadata.volume` and `book_metadata.chapter` /// alongside the title. #[derive(Debug, Default, Clone, Copy)] struct BookClassification { @@ -1612,7 +1612,7 @@ mod tests { chapter: None, count: None, isbns: None, - // New Phase 1 fields + // Extended book metadata fields book_type: None, subtitle: None, authors_json: Some(r#"[{"name":"Test Writer","role":"writer"}]"#.to_string()), @@ -1644,7 +1644,7 @@ mod tests { chapter_lock: false, count_lock: false, isbns_lock: false, - // New Phase 1 lock fields + // Extended book metadata lock fields book_type_lock: false, subtitle_lock: false, authors_json_lock: false, diff --git a/src/scanner/strategies/book/custom.rs b/src/scanner/strategies/book/custom.rs index f083b98c..4b88483f 100644 --- a/src/scanner/strategies/book/custom.rs +++ b/src/scanner/strategies/book/custom.rs @@ -130,10 +130,10 @@ impl BookNamingStrategy for CustomStrategy { } /// Volume from the user's `(?P\d+)` named group. `extract_volume` - /// returns `f32` (Custom predates Phase 11); the trait narrows to `i32` for - /// schema compat. Fractional values are rejected rather than truncated — - /// silent truncation would discard user-meaningful information. If a user - /// hits this we widen the column. + /// returns `f32` (Custom predates the structured-volume schema); the trait + /// narrows to `i32` for schema compat. Fractional values are rejected + /// rather than truncated; silent truncation would discard user-meaningful + /// information. If a user hits this we widen the column. fn resolve_volume( &self, file_name: &str, diff --git a/src/scanner/strategies/book/filename.rs b/src/scanner/strategies/book/filename.rs index 07b6c8ea..0a77c548 100644 --- a/src/scanner/strategies/book/filename.rs +++ b/src/scanner/strategies/book/filename.rs @@ -1,10 +1,9 @@ //! Filename book metadata strategy //! //! Always uses the filename without extension for the title (Komga-compatible). -//! Phase 11: also extracts structured volume/chapter numbers from canonical -//! filename patterns (`v01`, `c042`, `v15 - c126`, etc.) so per-book -//! classification can drive the new `local_max_volume` / `local_max_chapter` -//! aggregations. +//! Also extracts structured volume/chapter numbers from canonical filename +//! patterns (`v01`, `c042`, `v15 - c126`, etc.) so per-book classification can +//! drive the `local_max_volume` / `local_max_chapter` aggregations. use lazy_static::lazy_static; use regex::Regex; @@ -178,7 +177,7 @@ mod tests { ); } - // -- Structured volume/chapter tests (Phase 11 table from plan) -- + // -- Structured volume/chapter tests -- fn parse(file_name: &str) -> (Option, Option) { let s = FilenameStrategy::new(); diff --git a/src/scanner/strategies/book/mod.rs b/src/scanner/strategies/book/mod.rs index b0ee612c..be3e5895 100644 --- a/src/scanner/strategies/book/mod.rs +++ b/src/scanner/strategies/book/mod.rs @@ -3,8 +3,8 @@ //! Book metadata strategies determine how per-book facts (title, volume, //! chapter) are resolved from filesystem paths and embedded metadata. The //! framing is "which sources do we trust for facts about this book"; title is -//! just the first fact we extract. Volume and chapter classification (Phase 11 -//! of metadata-count-split) live on the same trait, mirroring the title flow. +//! just the first fact we extract. Volume and chapter classification live on +//! the same trait, mirroring the title flow. //! //! TODO: Remove allow(dead_code) once all book strategy features are fully integrated @@ -57,11 +57,11 @@ pub struct BookMetadata { /// Trait for per-book metadata strategy implementations. /// -/// Renamed from `BookNamingStrategy` (Phase 11 of metadata-count-split): the -/// trait is no longer purely about titles. It now resolves three independent -/// facts about a book — title, volume, chapter — from the same trio of inputs -/// (filename, metadata, context). Strategies remain free to implement only the -/// methods they have meaningful answers for; the rest return `None` defaults. +/// Renamed from `BookNamingStrategy`: the trait is no longer purely about +/// titles. It now resolves three independent facts about a book (title, +/// volume, chapter) from the same trio of inputs (filename, metadata, +/// context). Strategies remain free to implement only the methods they have +/// meaningful answers for; the rest return `None` defaults. pub trait BookMetadataStrategy: Send + Sync { /// Get the strategy type fn strategy_type(&self) -> BookStrategy; @@ -103,8 +103,8 @@ pub trait BookMetadataStrategy: Send + Sync { /// Backwards-compat alias. Prefer `BookMetadataStrategy` in new code; the /// `BookNamingStrategy` name is kept as a re-export for now to keep the -/// downstream cascade narrow during Phase 11. Remove in a follow-up once all -/// call sites are updated. +/// downstream cascade narrow during the trait rename. Remove in a follow-up +/// once all call sites are updated. pub use self::BookMetadataStrategy as BookNamingStrategy; /// Remove file extension from filename diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 133da74b..e15d4c08 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -569,7 +569,7 @@ impl Scheduler { /// Load library-jobs cron entries. /// /// Walks `library_jobs` rows where `enabled = true` and dispatches by - /// `r#type`. Phase 9 only handles `metadata_refresh`; future job types + /// `r#type`. Currently only handles `metadata_refresh`; future job types /// extend the match. async fn load_library_metadata_refresh_schedules(&mut self) -> Result<()> { let jobs = LibraryJobRepository::list_enabled(&self.db, None).await?; @@ -607,7 +607,7 @@ impl Scheduler { /// the same library can run concurrently because the guard scopes /// per-job, not per-library. pub async fn add_library_job_schedule(&mut self, job: &library_jobs::Model) -> Result<()> { - // Type dispatch. Phase 9: only metadata_refresh. + // Type dispatch. Currently only metadata_refresh. let cfg = match parse_job_config(&job.r#type, &job.config) { Ok(c) => c, Err(e) => { diff --git a/src/services/library_jobs/mod.rs b/src/services/library_jobs/mod.rs index c149d2bb..cff40409 100644 --- a/src/services/library_jobs/mod.rs +++ b/src/services/library_jobs/mod.rs @@ -5,7 +5,7 @@ //! The repository layer ([`crate::db::repositories::LibraryJobRepository`]) //! persists strings; the parsing, default-filling, and validation lives here. //! -//! Phase 9 introduces the `metadata_refresh` type. Future job types extend +//! Currently the `metadata_refresh` type is supported. Future job types extend //! [`LibraryJobConfig`] without schema changes. //! //! [`library_jobs`]: crate::db::entities::library_jobs diff --git a/src/services/library_jobs/types.rs b/src/services/library_jobs/types.rs index 2c149d0e..6081138d 100644 --- a/src/services/library_jobs/types.rs +++ b/src/services/library_jobs/types.rs @@ -1,7 +1,7 @@ //! Typed configs for [`library_jobs`]. //! //! [`LibraryJobConfig`] is a discriminated union keyed on the `type` row -//! column. Phase 9 ships with `metadata_refresh`; future variants extend +//! column. Currently ships with `metadata_refresh`; future variants extend //! the enum. //! //! [`library_jobs`]: crate::db::entities::library_jobs @@ -48,8 +48,8 @@ impl LibraryJobType { /// Type-discriminated payload stored in [`library_jobs.config`]. /// /// The serde representation is **internally tagged** under the JSON key -/// `type`. Each variant carries its own typed payload. Phase 9 only ships -/// the `metadata_refresh` variant. +/// `type`. Each variant carries its own typed payload. Currently only the +/// `metadata_refresh` variant ships. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LibraryJobConfig { @@ -67,7 +67,7 @@ impl LibraryJobConfig { /// Scope of a metadata refresh job. /// -/// Phase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The +/// Currently only [`RefreshScope::SeriesOnly`] is honoured at runtime. The /// other variants are schema-accepted but rejected by the validator. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Default)] #[serde(rename_all = "snake_case")] @@ -113,7 +113,7 @@ pub struct MetadataRefreshJobConfig { /// resolve this to an installed plugin (disabled is fine). pub provider: String, - /// Refresh scope. Phase 9 only allows [`RefreshScope::SeriesOnly`]. + /// Refresh scope. Currently only [`RefreshScope::SeriesOnly`] is allowed. #[serde(default)] pub scope: RefreshScope, @@ -126,12 +126,12 @@ pub struct MetadataRefreshJobConfig { #[serde(default)] pub extra_fields: Vec, - /// Reserved for the book-scope future work. Phase 9 rejects non-empty + /// Reserved for the book-scope future work. Currently rejects non-empty /// values when [`Self::scope`] is `series_only`. #[serde(default)] pub book_field_groups: Vec, - /// Reserved for the book-scope future work. Phase 9 rejects non-empty + /// Reserved for the book-scope future work. Currently rejects non-empty /// values when [`Self::scope`] is `series_only`. #[serde(default)] pub book_extra_fields: Vec, diff --git a/src/services/library_jobs/validation.rs b/src/services/library_jobs/validation.rs index 42d5326b..ee8dbdb0 100644 --- a/src/services/library_jobs/validation.rs +++ b/src/services/library_jobs/validation.rs @@ -74,7 +74,7 @@ pub async fn validate_metadata_refresh_config( None }; - // Phase 9: only series_only is honoured at runtime. + // Only series_only is honoured at runtime. match config.scope { RefreshScope::SeriesOnly => { if !config.book_field_groups.is_empty() || !config.book_extra_fields.is_empty() { diff --git a/src/services/metadata/book_apply.rs b/src/services/metadata/book_apply.rs index b4439dc4..47a60ee8 100644 --- a/src/services/metadata/book_apply.rs +++ b/src/services/metadata/book_apply.rs @@ -391,11 +391,11 @@ impl BookMetadataApplier { } } - // Volume (Phase 12 of metadata-count-split): per-book volume number. - // Plugin protocol carries `volume: Option`; book_metadata stores it - // as `i32` (no fractional volumes today — matches the structured - // filename parser's strictness). Reject fractional with a skip rather - // than truncating: silent truncation would lose information. + // Volume: per-book volume number. Plugin protocol carries + // `volume: Option`; book_metadata stores it as `i32` (no fractional + // volumes today; matches the structured filename parser's strictness). + // Reject fractional with a skip rather than truncating: silent + // truncation would lose information. if should_apply_field("volume") && let Some(volume) = metadata.volume { @@ -419,10 +419,10 @@ impl BookMetadataApplier { } } - // Chapter (Phase 12 of metadata-count-split): per-book chapter number. - // Stored as `f32` to preserve fractional chapters (e.g. side stories at - // 47.5). Plugin protocol uses `f64`; we narrow with a debug-asserted - // cast since chapter numbers in practice never exceed `f32` precision. + // Chapter: per-book chapter number. Stored as `f32` to preserve + // fractional chapters (e.g. side stories at 47.5). Plugin protocol + // uses `f64`; we narrow with a debug-asserted cast since chapter + // numbers in practice never exceed `f32` precision. if should_apply_field("chapter") && let Some(chapter) = metadata.chapter { diff --git a/src/services/metadata/field_groups.rs b/src/services/metadata/field_groups.rs index 7fe2468f..e072b79b 100644 --- a/src/services/metadata/field_groups.rs +++ b/src/services/metadata/field_groups.rs @@ -150,9 +150,9 @@ pub fn fields_for_group(group: FieldGroup) -> &'static [&'static str] { /// Expand a slice of group names into a deduplicated set of field names. /// -/// Unknown group strings are silently ignored — callers that want strict +/// Unknown group strings are silently ignored; callers that want strict /// validation should call [`FieldGroup::from_str`] up front (the PATCH -/// endpoint will, in Phase 6). +/// endpoint does so). /// /// Returns `None` when both `groups` and `extras` are empty, matching the /// "no filter, apply everything" semantics of diff --git a/src/services/metadata/refresh_planner.rs b/src/services/metadata/refresh_planner.rs index e89db9fc..801a5a8e 100644 --- a/src/services/metadata/refresh_planner.rs +++ b/src/services/metadata/refresh_planner.rs @@ -1,8 +1,8 @@ //! Planner that decides which `(series, provider)` pairs the scheduled //! metadata refresh should touch in a given run. //! -//! Phase 9: each job carries a single provider, so the planner now resolves -//! one `"plugin:"` reference, lists the library's series, and emits one +//! Each job carries a single provider, so the planner resolves one +//! `"plugin:"` reference, lists the library's series, and emits one //! `PlannedRefresh` per series (or skipped reason). The previous //! many-providers-per-config model has been removed alongside the per-provider //! override hatch. diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index 94cb06a3..ac0dfa2b 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -452,7 +452,7 @@ pub enum ReleaseSourceKind { /// Generic API-driven feed. ApiFeed, /// Metadata-derived signal (informational; usually doesn't write the - /// ledger - see Phase 5). + /// ledger). MetadataFeed, } @@ -1942,10 +1942,11 @@ mod tests { #[test] fn test_protocol_version_is_minor_bumped() { - // Phase 9 of metadata-count-split bumps the protocol from 1.1 to 1.2: - // legacy `totalBookCount` field and `metadata:write:total_book_count` - // permission are removed. Plugins that still emit the legacy field - // round-trip through serde silently (the field is dropped on decode). + // The metadata-count-split hard removal bumps the protocol from 1.1 + // to 1.2: legacy `totalBookCount` field and + // `metadata:write:total_book_count` permission are removed. Plugins + // that still emit the legacy field round-trip through serde silently + // (the field is dropped on decode). assert_eq!(PROTOCOL_VERSION, "1.2"); } diff --git a/src/services/plugin/releases_handler.rs b/src/services/plugin/releases_handler.rs index 7fc8402f..75c5fc29 100644 --- a/src/services/plugin/releases_handler.rs +++ b/src/services/plugin/releases_handler.rs @@ -38,7 +38,7 @@ use crate::services::release::languages::{includes, resolve_for_series}; use crate::services::release::matcher::{evaluate, resolve_threshold}; /// Default page size for `releases/list_tracked` when the caller doesn't -/// specify one. Matches the Phase 3 risk-mitigation note. +/// specify one. Bounded to keep the response small on first load. const DEFAULT_TRACKED_PAGE_SIZE: u64 = 200; /// Hard cap on `limit` to keep a single page bounded. const MAX_TRACKED_PAGE_SIZE: u64 = 1_000; @@ -2023,7 +2023,7 @@ mod tests { } // ------------------------------------------------------------------------- - // latest_known_* advancement tests (Phase 6) + // latest_known_* advancement tests // ------------------------------------------------------------------------- async fn record_candidate( diff --git a/src/services/release/candidate.rs b/src/services/release/candidate.rs index 352bfe5b..bebb94d3 100644 --- a/src/services/release/candidate.rs +++ b/src/services/release/candidate.rs @@ -1,7 +1,7 @@ //! Wire-format `ReleaseCandidate` and parsing helpers. //! //! Plugins emit candidates over `releases/record` (and as the response of -//! `releases/poll` in Phase 4). The host rejects malformed candidates and +//! `releases/poll`). The host rejects malformed candidates and //! drops below-threshold candidates before reaching the ledger. use chrono::{DateTime, Utc}; diff --git a/src/services/release/mod.rs b/src/services/release/mod.rs index c1e989fd..76960bb6 100644 --- a/src/services/release/mod.rs +++ b/src/services/release/mod.rs @@ -9,9 +9,8 @@ //! same domain. //! - [`schedule`] — interval resolution and jitter for the polling //! scheduler. -//! - [`upstream_gap`] — Phase 5 metadata-derived publication-gap signal -//! surfaced on the series DTO. Read-side only; does not write to the -//! release ledger. +//! - [`upstream_gap`] — metadata-derived publication-gap signal surfaced on +//! the series DTO. Read-side only; does not write to the release ledger. //! - [`seed`] — derives tracking defaults (aliases, `latest_known_*`, //! per-axis tracking flags) from existing series data so a user toggling //! tracking on doesn't have to fill in a setup form. diff --git a/src/services/release/upstream_gap.rs b/src/services/release/upstream_gap.rs index e9bf97dd..f3a93de2 100644 --- a/src/services/release/upstream_gap.rs +++ b/src/services/release/upstream_gap.rs @@ -1,4 +1,4 @@ -//! Upstream-publication gap signal (Phase 5 of release-tracking). +//! Upstream-publication gap signal. //! //! Computes the per-series delta between *original-language* publication //! counts (from MangaBaka / AniList / etc., stored as @@ -9,9 +9,7 @@ //! The gap is purely a UI signal — it does **not** write `release_ledger` //! rows and does **not** advance `series_tracking.latest_known_*`. Original- //! language publication facts are not the same category as -//! translation/scanlation releases (which Phase 6's MangaUpdates plugin -//! handles). See the `release-tracking` plan, Key Technical Decisions, for -//! the three-signal separation. +//! translation/scanlation releases (which the MangaUpdates plugin handles). use crate::db::entities::series_external_ids::Model as SeriesExternalId; use crate::db::entities::series_tracking::Model as SeriesTrackingRow; @@ -124,8 +122,7 @@ fn compute_volume_gap(total: Option, local_max: Option) -> Option /// counts. /// /// Returns `None` when no recognized provider source is attached to the -/// series; the badge tooltip in Phase 7 then falls back to a generic -/// message. +/// series; the badge tooltip then falls back to a generic message. fn pick_provider(external_ids: &[SeriesExternalId]) -> Option { const PRIORITY: &[(&str, &str)] = &[ ("plugin:mangabaka", "MangaBaka"), diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index 7a131e93..52f1e132 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -186,12 +186,12 @@ impl TaskHandler for PollReleaseSourceHandler { )); } - // Synthetic in-core sources (Phase 5 metadata-piggyback) don't - // route through a plugin process. We don't have a code path for - // them yet; record a benign skip so the scheduler doesn't loop. + // Synthetic in-core sources (metadata-piggyback) don't route + // through a plugin process. We don't have a code path for them + // yet; record a benign skip so the scheduler doesn't loop. if source.plugin_id == source_plugin_id::CORE { debug!( - "Task {}: Source {} is in-core (plugin_id=core); skipping (Phase 5 territory)", + "Task {}: Source {} is in-core (plugin_id=core); skipping (no in-core poll path)", task.id, source.id ); return Ok(TaskResult::success_with_data( diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/src/tasks/handlers/refresh_library_metadata.rs index 86e2e012..901ffeae 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/src/tasks/handlers/refresh_library_metadata.rs @@ -1,15 +1,15 @@ //! Per-job metadata refresh handler. //! -//! Phase 9 entry point: the task carries a `job_id`, the handler loads the +//! Entry point: the task carries a `job_id`, the handler loads the //! [`library_jobs`] row, decodes its [`LibraryJobConfig`] (must be //! `MetadataRefresh` to land here), resolves the library, builds a //! [`RefreshPlan`] via [`RefreshPlanner`], and walks the plan one //! `(series, plugin)` pair at a time. //! -//! Scope: Phase 9 only honours `RefreshScope::SeriesOnly`. The validator -//! gates this at PATCH time, but the handler also rejects non-series scopes -//! at run time so a job that somehow persisted with a deferred scope -//! short-circuits with a clear failure status. +//! Scope: only `RefreshScope::SeriesOnly` is honoured. The validator gates +//! this at PATCH time, but the handler also rejects non-series scopes at run +//! time so a job that somehow persisted with a deferred scope short-circuits +//! with a clear failure status. //! //! [`library_jobs`]: crate::db::entities::library_jobs @@ -130,9 +130,9 @@ impl TaskHandler for RefreshLibraryMetadataHandler { .context("Failed to decode library job config")?; let LibraryJobConfig::MetadataRefresh(cfg) = cfg; - // 2. Phase 9 scope guard. The validator should have rejected - // non-series scopes already; this is defense-in-depth so a - // persisted bad row fails loudly rather than silently no-op. + // 2. Scope guard. The validator should have rejected non-series + // scopes already; this is defense-in-depth so a persisted bad + // row fails loudly rather than silently no-op. if cfg.scope != RefreshScope::SeriesOnly { let msg = format!( "Book-scope refresh ('{}') not yet implemented", diff --git a/tests/api/komga.rs b/tests/api/komga.rs index f0a828f6..dac920c8 100644 --- a/tests/api/komga.rs +++ b/tests/api/komga.rs @@ -2061,7 +2061,7 @@ async fn test_komga_mark_series_as_unread_not_found() { } // ============================================================================ -// Sort and Filter Tests (Phase 3 - Komga sort/filter fixes) +// Sort and Filter Tests (Komga sort/filter fixes) // ============================================================================ /// Helper to create a book_metadata record with release date diff --git a/tests/api/library_jobs.rs b/tests/api/library_jobs.rs index 3ce29585..bac671a3 100644 --- a/tests/api/library_jobs.rs +++ b/tests/api/library_jobs.rs @@ -1,4 +1,4 @@ -// Library jobs API integration tests (Phase 9). +// Library jobs API integration tests. #![allow(unused_variables)] diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 6d7ead33..7c75f711 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -1365,7 +1365,7 @@ async fn test_book_metadata_locks_all_phase6_fields() { let token = create_admin_and_token(&db, &state).await; let app = create_test_router(state).await; - // Verify all Phase 6 lock fields are present in the response + // Verify all extended book metadata lock fields are present in the response let request = get_request_with_auth(&format!("/api/v1/books/{}/metadata/locks", book_id), &token); let (status, response): (StatusCode, Option) = @@ -1374,7 +1374,7 @@ async fn test_book_metadata_locks_all_phase6_fields() { assert_eq!(status, StatusCode::OK); let body = response.unwrap(); - // Phase 6 lock fields + // Extended book metadata lock fields assert!( body.get("bookTypeLock").is_some(), "bookTypeLock field should be present" diff --git a/tests/api/plugins.rs b/tests/api/plugins.rs index c9925e14..68adcc25 100644 --- a/tests/api/plugins.rs +++ b/tests/api/plugins.rs @@ -782,7 +782,7 @@ async fn test_list_plugins_with_data() { } // ============================================================================= -// Plugin Actions API Tests (Phase 4) +// Plugin Actions API Tests // ============================================================================= use codex::api::routes::v1::dto::{ExecutePluginResponse, PluginActionsResponse}; @@ -1017,7 +1017,7 @@ async fn test_apply_series_metadata_requires_auth() { } // ============================================================================= -// Permission-Based Access Tests (Phase 8) +// Permission-Based Access Tests // ============================================================================= /// Create a maintainer user and return a JWT token. @@ -1441,7 +1441,7 @@ async fn test_get_search_title_with_search_query_template() { } // ============================================================================= -// Unified Series Context Integration Tests (Phase 4) +// Unified Series Context Integration Tests // ============================================================================= use codex::db::repositories::{GenreRepository, SeriesMetadataRepository, TagRepository}; diff --git a/tests/api/series.rs b/tests/api/series.rs index 6f1b889e..86b7ed7c 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -697,7 +697,7 @@ async fn test_get_series_classification_aggregates_absent_when_unclassified() { } // ============================================================================ -// Phase 5: Upstream-publication gap signal +// Upstream-publication gap signal // ============================================================================ /// Inputs for [`setup_tracked_series_with_gap`]. diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index b8695589..a09e2450 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -510,7 +510,7 @@ async fn test_migration_067_backfill_sqlite() { } // -- Migration 068 (drop_book_count) tests -- -// Verifies the Phase 9 hard-removal migration drops the legacy total_book_count +// Verifies the hard-removal migration drops the legacy total_book_count // + total_book_count_lock columns while leaving the split-count columns intact. #[tokio::test] @@ -540,8 +540,8 @@ async fn test_migration_068_drop_legacy_sqlite() { } // -- Migration 069 (add_book_chapter) tests -- -// Phase 11 of metadata-count-split: adds `chapter` and `chapter_lock` to -// book_metadata. Verifies up/down behavior and default values for existing rows. +// Adds `chapter` and `chapter_lock` to book_metadata. Verifies up/down +// behavior and default values for existing rows. /// Helper: run all migrations through 068 so tests can apply 069 in isolation. async fn setup_db_before_migration_069() -> (Database, TempDir) { @@ -654,9 +654,9 @@ async fn test_migration_069_down_drops_chapter_columns_sqlite() { } // -- Migration 070 (backfill_book_volume_chapter) tests -- -// Phase 12 of metadata-count-split: re-parse each book's filename and populate -// `book_metadata.volume` / `chapter` where currently NULL. Idempotent and -// strictly additive — never overwrites a populated value. +// Re-parse each book's filename and populate `book_metadata.volume` / +// `chapter` where currently NULL. Idempotent and strictly additive; never +// overwrites a populated value. #[tokio::test] async fn test_migration_070_backfills_from_filename_sqlite() { diff --git a/tests/parsers/pdf_rendering.rs b/tests/parsers/pdf_rendering.rs index 1e85459c..b0a59598 100644 --- a/tests/parsers/pdf_rendering.rs +++ b/tests/parsers/pdf_rendering.rs @@ -1,6 +1,6 @@ //! PDF Rendering Integration Tests //! -//! Tests for the PDF rendering infrastructure (Phase 5 of pdf-page-rendering.md). +//! Tests for the PDF rendering infrastructure. //! //! These tests cover: //! 1. Text-only PDF rendering diff --git a/tests/scanner/book_naming_strategy.rs b/tests/scanner/book_naming_strategy.rs index 0cafc03b..98c0d2b3 100644 --- a/tests/scanner/book_naming_strategy.rs +++ b/tests/scanner/book_naming_strategy.rs @@ -546,7 +546,7 @@ async fn test_custom_book_strategy_persistence() { } // ============================================================================ -// Per-book volume/chapter classification (Phase 12 of metadata-count-split) +// Per-book volume/chapter classification // ============================================================================ // // These tests cover the strategy x parse-case matrix the scanner now relies on diff --git a/tests/services/book_metadata_apply.rs b/tests/services/book_metadata_apply.rs index f827365e..d7337eb0 100644 --- a/tests/services/book_metadata_apply.rs +++ b/tests/services/book_metadata_apply.rs @@ -1,6 +1,6 @@ -//! Tests for BookMetadataApplier — Phase 12 of metadata-count-split focus on -//! the new per-book volume / chapter write blocks. Uses the same plugin-permission -//! shape as the series-side `metadata_apply.rs` tests for consistency. +//! Tests for BookMetadataApplier focused on the per-book volume / chapter +//! write blocks. Uses the same plugin-permission shape as the series-side +//! `metadata_apply.rs` tests for consistency. #[path = "../common/mod.rs"] mod common; diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index 0c735098..bbe76ebd 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -291,7 +291,7 @@ async fn test_apply_title_sets_title_sort_when_none() { } // ============================================================================= -// total_volume_count / total_chapter_count Apply Tests (Phase 3) +// total_volume_count / total_chapter_count Apply Tests // ============================================================================= /// Build a plugin with the given permission strings (e.g. "metadata:write:total_volume_count"). 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/openapi.json b/web/openapi.json index 7363429a..64cb6c11 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -27577,7 +27577,7 @@ ] } ], - "description": "Type-discriminated job config exposed over the wire.\n\nPhase 9 only ships the `metadata_refresh` variant; future job types\nextend the enum." + "description": "Type-discriminated job config exposed over the wire.\n\nCurrently only ships the `metadata_refresh` variant; future job types\nextend the enum." }, "LibraryJobDto": { "type": "object", @@ -28505,7 +28505,7 @@ }, "scope": { "$ref": "#/components/schemas/RefreshScope", - "description": "Refresh scope. Phase 9 only honours `series_only` at runtime." + "description": "Refresh scope. Currently only `series_only` is honoured at runtime." }, "skipRecentlySyncedWithinS": { "type": "integer", @@ -30107,7 +30107,7 @@ "null" ], "format": "float", - "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement** — Phase 6's MangaUpdates plugin owns the\ntranslation-release feed.", + "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement**; the MangaUpdates plugin owns the\ntranslation-release feed.", "example": 3.0 }, "upstreamGapProvider": { @@ -30115,7 +30115,7 @@ "string", "null" ], - "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe Phase 7 badge tooltip.", + "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe gap badge tooltip.", "example": "MangaBaka" }, "upstreamVolumeGap": { @@ -33137,7 +33137,7 @@ }, "RefreshScope": { "type": "string", - "description": "Scope of a metadata refresh job.\n\nPhase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The\nother variants are schema-accepted but rejected by the validator.", + "description": "Scope of a metadata refresh job.\n\nCurrently only [`RefreshScope::SeriesOnly`] is honoured at runtime. The\nother variants are schema-accepted but rejected by the validator.", "enum": [ "series_only", "books_only", @@ -35142,7 +35142,7 @@ "null" ], "format": "float", - "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement** — Phase 6's MangaUpdates plugin owns the\ntranslation-release feed.", + "description": "Difference between the upstream original-language chapter count\n(`series_metadata.total_chapter_count`, supplied by metadata\nproviders like MangaBaka or AniList) and the highest locally-owned\nchapter (`local_max_chapter`).\n\nAlways `None` unless the series is tracked AND `track_chapters` is\nenabled AND the provider count is populated AND the rounded-to-1-\ndecimal gap is positive. **This is an informational signal, not a\nrelease announcement**; the MangaUpdates plugin owns the\ntranslation-release feed.", "example": 3.0 }, "upstreamGapProvider": { @@ -35150,7 +35150,7 @@ "string", "null" ], - "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe Phase 7 badge tooltip.", + "description": "Display name of the metadata provider that supplied the upstream\ncounts (e.g., \"MangaBaka\", \"AniList\"). Set whenever at least one of\n`upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by\nthe gap badge tooltip.", "example": "MangaBaka" }, "upstreamVolumeGap": { 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/api/tracking.test.ts b/web/src/api/tracking.test.ts new file mode 100644 index 00000000..da828e50 --- /dev/null +++ b/web/src/api/tracking.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./client"; +import { trackingApi } from "./tracking"; + +vi.mock("./client", () => ({ + api: { + get: vi.fn(), + patch: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +describe("trackingApi.listAliases", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it("returns the aliases array when present", async () => { + const aliases = [ + { id: "a1", seriesId: "s1", alias: "alt-name", source: "user" }, + ]; + vi.mocked(api.get).mockResolvedValueOnce({ data: { aliases } }); + + const result = await trackingApi.listAliases("s1"); + + expect(api.get).toHaveBeenCalledWith("/series/s1/aliases"); + expect(result).toEqual(aliases); + }); + + it("returns [] when the response body omits the aliases wrapper", async () => { + // Reproduces the production bug where TanStack Query rejected + // `undefined` from `response.data.aliases` and surfaced + // "Query data cannot be undefined" for every series detail visit. + vi.mocked(api.get).mockResolvedValueOnce({ data: {} }); + + const result = await trackingApi.listAliases("s1"); + + expect(result).toEqual([]); + }); + + it("returns [] when the response body is null", async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: null }); + + const result = await trackingApi.listAliases("s1"); + + expect(result).toEqual([]); + }); +}); diff --git a/web/src/api/tracking.ts b/web/src/api/tracking.ts index e860d797..7cfda2ad 100644 --- a/web/src/api/tracking.ts +++ b/web/src/api/tracking.ts @@ -28,10 +28,13 @@ export const trackingApi = { }, listAliases: async (seriesId: string): Promise => { - const response = await api.get<{ aliases: SeriesAlias[] }>( + const response = await api.get<{ aliases?: SeriesAlias[] } | null>( `/series/${seriesId}/aliases`, ); - return response.data.aliases; + // Backends or mock layers that omit the wrapper key (or return null + // for an unconfigured series) would otherwise propagate `undefined` + // to TanStack Query, which rejects undefined query data. + return response.data?.aliases ?? []; }, createAlias: async ( 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..ea4edea7 --- /dev/null +++ b/web/src/components/offline/DownloadButton.test.tsx @@ -0,0 +1,461 @@ +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 menu trigger for epub", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /^download options$/i }), + ).toBeInTheDocument(); + }); + + it("renders a download menu trigger for pdf", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /^download options$/i }), + ).toBeInTheDocument(); + }); + + it("renders a download menu trigger for cbz when pageCount is provided", async () => { + renderWithProviders( + , + ); + expect( + await screen.findByRole("button", { name: /^download options$/i }), + ).toBeInTheDocument(); + }); + + it("still renders when only a fileDownloadUrl is provided for an unsupported format", async () => { + // mobi can't be cached for offline reading, but the file URL is still + // surfaced as a "Download file" menu item so the user has a path to + // export the file. + renderWithProviders( + , + ); + expect( + await screen.findByRole("button", { name: /^download options$/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: /download options \(retry available\)/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(); + }); +}); + +// Each menuitem interaction can wait up to 5s for Mantine's portal+transition +// under heavy parallel test load, and these tests chain two menu round-trips +// (open → menuitem → cancel). Bump the per-test timeout so the chained waits +// fit comfortably within a single test budget. +describe( + "DownloadButton: download trigger and progress", + { timeout: 20000 }, + () => { + 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: /^download options$/i, + }); + await userEvent.click(trigger); + // The menu lives in a Mantine portal with a brief transition; under + // heavy test load the default 1000ms timeout can flake. Bump it so we + // catch the dropdown reliably. + const startItem = await screen.findByRole( + "menuitem", + { name: /save for offline/i }, + { timeout: 5000 }, + ); + await userEvent.click(startItem); + + 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: /^download options$/i, + }); + await userEvent.click(trigger); + const startItem = await screen.findByRole("menuitem", { + name: /save for offline/i, + }); + await userEvent.click(startItem); + + 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: /^download options$/i, + }); + await userEvent.click(trigger); + // The menu lives in a Mantine portal with a brief transition; under + // heavy test load the default 1000ms timeout can flake. Bump it so we + // catch the dropdown reliably. + const startItem = await screen.findByRole( + "menuitem", + { name: /save for offline/i }, + { timeout: 5000 }, + ); + await userEvent.click(startItem); + + 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: /^download options$/i }), + ).toBeInTheDocument(); + }); + }); + }, +); + +describe("DownloadButton: remove flow", { timeout: 20000 }, () => { + 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 }, + { timeout: 5000 }, + ); + await userEvent.click(removeItem); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /^download options$/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: /^download options$/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: /^download options$/i }), + ).toBeInTheDocument(); + }); + }); + + it("ignores broadcasts for other book ids", async () => { + renderWithProviders(); + expect( + await screen.findByRole("button", { name: /^download options$/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: /^download options$/i }), + ).toBeInTheDocument(); + }); +}); + +describe("DownloadButton: file URL fallback", { timeout: 20000 }, () => { + it("surfaces a 'Download file' menu item when fileDownloadUrl is provided", async () => { + renderWithProviders( + , + ); + const trigger = await screen.findByRole("button", { + name: /^download options$/i, + }); + await userEvent.click(trigger); + + const fileItem = await screen.findByRole( + "menuitem", + { name: /download file/i }, + { timeout: 5000 }, + ); + expect(fileItem).toHaveAttribute("href", "/api/v1/books/book-1/file"); + }); + + it("offers 'Download file' even when the format isn't cacheable for offline", async () => { + renderWithProviders( + , + ); + const trigger = await screen.findByRole("button", { + name: /^download options$/i, + }); + await userEvent.click(trigger); + + expect( + await screen.findByRole( + "menuitem", + { name: /download file/i }, + { timeout: 5000 }, + ), + ).toBeInTheDocument(); + // No offline-save entry for unsupported formats. + expect( + screen.queryByRole("menuitem", { name: /save for offline/i }), + ).toBeNull(); + }); +}); diff --git a/web/src/components/offline/DownloadButton.tsx b/web/src/components/offline/DownloadButton.tsx new file mode 100644 index 00000000..9b9670ac --- /dev/null +++ b/web/src/components/offline/DownloadButton.tsx @@ -0,0 +1,460 @@ +import { + ActionIcon, + Group, + Menu, + RingProgress, + Text, + Tooltip, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertCircle, + IconCloudCheck, + IconCloudDownload, + IconDeviceFloppy, + 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"; + +/** + * 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`. + * + * The series batch download wraps this component in a queue; the + * series-level "Download series" button is a separate component. + */ + +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; + /** + * Optional direct file URL. When provided, the button menu also exposes a + * "Download file" action that links to this URL. Lets BookDetail collapse + * its old `` button into the same dropdown so users + * see one unambiguous Download surface instead of two adjacent buttons + * that both say "Download". + */ + fileDownloadUrl?: 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", + fileDownloadUrl, +}: 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 comics later get flipped 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]); + + // When we have a fallback file URL, we always render *something* (the + // Menu with a "Download file" entry), even for formats that can't be + // cached for offline reading. + if (!supported && !fileDownloadUrl) return null; + + function maybeNudgeThenDownload() { + // 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 ( + + + + ); + } + + // Downloading state stays compact (ring progress + cancel) so the user can + // see progress and bail mid-flight without opening a menu. + if (state.kind === "downloading") { + const pct = progressPercent(state); + return ( + + + + + + + + + + + ); + } + + // All other states (not-downloaded / error / downloaded) render the same + // Menu shape so a single Download surface covers both the PWA offline cache + // and the file URL. Distinct icons + colors disambiguate state at a glance. + // We deliberately don't wrap the Menu.Target in a Tooltip — Mantine's Menu + // forwards its click handler via cloneElement, and a Tooltip in between + // intermittently swallows that click under test load. The aria-label below + // carries the same accessible name. + const targetIcon = (() => { + if (state.kind === "downloaded") return ; + if (state.kind === "error") return ; + return ; + })(); + const targetColor = + state.kind === "downloaded" + ? "green" + : state.kind === "error" + ? "red" + : undefined; + const targetAria = + state.kind === "downloaded" + ? "Offline download options" + : state.kind === "error" + ? "Download options (retry available)" + : "Download options"; + + return ( + <> + + + + {targetIcon} + + + + {state.kind === "downloaded" && ( + <> + + + Saved offline + + + {supported && ( + } + onClick={startDownload} + > + Re-download offline copy + + )} + } + color="red" + onClick={removeDownload} + > + Remove offline copy + + + )} + {state.kind === "not-downloaded" && supported && ( + } + onClick={maybeNudgeThenDownload} + > + {label} + + )} + {state.kind === "error" && supported && ( + } + onClick={maybeNudgeThenDownload} + > + Retry offline download + + )} + {fileDownloadUrl && ( + <> + {state.kind !== "not-downloaded" && } + } + component="a" + href={fileDownloadUrl} + > + Download file + + + )} + {state.kind === "downloaded" && ( + <> + + }> + + More controls in Settings + + + + )} + {state.kind === "error" && ( + <> + + + + {state.message} + + + + )} + + + { + setNudgeOpen(false); + void startDownload(); + }} + onClose={() => setNudgeOpen(false)} + /> + + ); +} diff --git a/web/src/components/offline/InstallNudgeModal.test.tsx b/web/src/components/offline/InstallNudgeModal.test.tsx new file mode 100644 index 00000000..1685c155 --- /dev/null +++ b/web/src/components/offline/InstallNudgeModal.test.tsx @@ -0,0 +1,165 @@ +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; +}); + +// Each test chains a menuitem interaction inside Mantine's portal/transition +// pipeline; under heavy parallel-test load the menu finder can need a few +// seconds to settle. Bump the per-test timeout so the chain fits comfortably. +describe("DownloadButton + InstallNudgeModal", { timeout: 20000 }, () => { + 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: /^download options$/i, + }); + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole( + "menuitem", + { name: /save for offline/i }, + { timeout: 5000 }, + ), + ); + + // 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: /^download options$/i }), + ); + await userEvent.click( + await screen.findByRole( + "menuitem", + { name: /save for offline/i }, + { timeout: 5000 }, + ), + ); + + 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: /^download options$/i }), + ); + await userEvent.click( + await screen.findByRole( + "menuitem", + { name: /save for offline/i }, + { timeout: 5000 }, + ), + ); + + 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..c5451ea9 --- /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"; + +/** + * 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..cfa8321b --- /dev/null +++ b/web/src/components/offline/SeriesDownloadButton.tsx @@ -0,0 +1,550 @@ +import { + ActionIcon, + Badge, + Button, + Card, + Group, + Menu, + Modal, + Progress, + ScrollArea, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconCheck, + IconCloudCheck, + IconCloudDownload, + IconDeviceFloppy, + 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"; + +/** + * "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; + /** + * Optional series-archive URL. When provided the dropdown exposes a + * "Download as archive" action that links directly to the URL alongside + * "Save series for offline". Lets SeriesDetail fold the legacy + * `/api/v1/series/:id/download` link into the same control instead of + * rendering a second adjacent button. + */ + archiveDownloadUrl?: 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", + archiveDownloadUrl, +}: 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(() => { + // 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; + + // Primary button: when an archive URL is provided, the visible Button + // opens a Menu so the user can choose between "Save series for offline" + // and "Download as archive". Without the archive URL we just open the + // modal directly (legacy single-purpose UX). + const primaryButton = archiveDownloadUrl ? ( + + + + + + } onClick={open}> + Save series for offline + + } + component="a" + href={archiveDownloadUrl} + > + Download as archive + + + + ) : ( + + ); + + return ( + <> + + {primaryButton} + {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..9b0bce4c 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); + // 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 }); @@ -341,13 +394,19 @@ export function ComicReader({ }; }, [resetHideTimeout]); - // Show toolbar on mouse move - const handleMouseMove = useCallback(() => { - if (!toolbarVisible) { - setToolbarVisible(true); - } - resetHideTimeout(); - }, [toolbarVisible, setToolbarVisible, resetHideTimeout]); + // Show toolbar on mouse / pen move. Skip touch — synthetic mouse events + // fire after every tap on touch devices, which would pop the toolbar open + // every time the user paged forward via a side-zone tap. + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType === "touch") return; + if (!toolbarVisible) { + setToolbarVisible(true); + } + resetHideTimeout(); + }, + [toolbarVisible, setToolbarVisible, resetHideTimeout], + ); // Wrapped handlers for single-page mode that set navigation direction const handleNextPageWithDirection = useCallback(() => { @@ -413,32 +472,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 +603,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 +649,17 @@ export function ComicReader({ // Build list of pages to preload (current page always included) const pagesToPreload = new Set([currentPage]); + // 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 +703,7 @@ export function ComicReader({ currentPage, totalPages, preloadPages, + isBookDownloaded, pageLayout, spreadConfig, getPageUrl, @@ -705,7 +733,7 @@ export function ComicReader({ ) { return (
@@ -716,7 +744,7 @@ export function ComicReader({ if (totalPages === 0) { return (
This book has no pages
@@ -726,10 +754,10 @@ export function ComicReader({ return ( + {/* 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 +861,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..60325b31 100644 --- a/web/src/components/reader/EpubReader.test.tsx +++ b/web/src/components/reader/EpubReader.test.tsx @@ -3,6 +3,17 @@ import { useReaderStore } from "@/store/readerStore"; import { renderWithProviders, screen } from "@/test/utils"; import { EpubReader } from "./EpubReader"; +// Captures the per-event handlers `EpubReader` registers on the rendition, +// so tests can fire (e.g.) the "click" handler to verify the toolbar toggle. +const renditionHandlers: Record void> = {}; +// Captures hooks.content.register callbacks so iframe pointer 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 mobile-styles 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", () => ({ ReactReader: vi.fn( @@ -11,8 +22,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 +48,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 +170,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, @@ -319,8 +361,8 @@ describe("EpubReader", () => { it("should render TOC drawer component", () => { renderWithProviders(); - // EpubTableOfContents is rendered in toolbar - // It's toggled by a button + // EpubTableOfContentsDrawer is rendered at the reader level so it + // survives the toolbar's auto-hide; the trigger lives in the toolbar. expect(screen.getByTestId("react-reader-mock")).toBeInTheDocument(); }); @@ -339,6 +381,234 @@ describe("EpubReader", () => { }); }); + describe("mobile tap-to-toggle toolbar", () => { + // The EPUB reader no longer wires an outer-container useTouchNav: the + // iframe-internal pointer handler is the sole authority for taps, to + // avoid double-classifying the same touch on iOS Safari. + + it("registers a content hook that wires pointer events on the iframe doc", 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", () => { + // The iframe hook listens for pointer events (not `click`). iOS Safari + // can suppress `click` inside sandboxed iframes (switching to it during + // earlier work lost navigation entirely on real devices), while pointer + // events fire reliably. Tap-zone width comes from + // `window.innerWidth` (the parent viewport) because in epub.js's + // paginated mode the iframe document is wider than the visible area + // due to CSS columns. + const dispatchPointerEvent = ( + doc: Document, + type: "pointerdown" | "pointerup" | "pointercancel", + x: number, + y: number, + init: { + pointerType?: "touch" | "mouse" | "pen"; + pointerId?: number; + isPrimary?: boolean; + button?: number; + target?: Element; + } = {}, + ) => { + const { + pointerType = "touch", + pointerId = 1, + isPrimary = true, + button = 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 }); + const dispatchTarget = target ?? doc.body; + dispatchTarget.dispatchEvent(event); + }; + + const mountAndGetIframeDoc = async () => { + // Pin the viewport so tap-zone classification is deterministic + // regardless of jsdom defaults or earlier-test mutations. The hook + // reads window.innerWidth/innerHeight to size the LTR/RTL thirds. + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 900, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 600, + }); + + renderWithProviders(); + // Two microtask flushes: the mocked ReactReader queues `getRendition` + // in a setTimeout, and the React effect that wires the content hook + // settles on the next tick. One flush isn't always enough when the + // suite runs in isolation. + await new Promise((r) => setTimeout(r, 0)); + 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("toggles the toolbar on a center-zone tap inside the iframe", async () => { + const doc = await mountAndGetIframeDoc(); + + // mountAndGetIframeDoc pins window.innerWidth=900, innerHeight=600; + // center third is x ∈ [300, 600], y ∈ [200, 400]. (450, 300) is dead-center. + useReaderStore.setState({ toolbarVisible: true }); + dispatchPointerEvent(doc, "pointerdown", 450, 300); + dispatchPointerEvent(doc, "pointerup", 451, 300); + expect(useReaderStore.getState().toolbarVisible).toBe(false); + + dispatchPointerEvent(doc, "pointerdown", 450, 300); + dispatchPointerEvent(doc, "pointerup", 450, 301); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("routes edge-zone taps to prev/next without toggling the toolbar (LTR)", async () => { + const doc = await mountAndGetIframeDoc(); + + const visibleBefore = useReaderStore.getState().toolbarVisible; + + // window 900 wide → left third < 300, right third > 600. + dispatchPointerEvent(doc, "pointerdown", 100, 300); + dispatchPointerEvent(doc, "pointerup", 100, 300); + dispatchPointerEvent(doc, "pointerdown", 800, 300); + dispatchPointerEvent(doc, "pointerup", 800, 300); + + 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", 450, 300, { target: link }); + dispatchPointerEvent(doc, "pointerup", 451, 301, { target: link }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + + dispatchPointerEvent(doc, "pointerdown", 450, 300, { target: input }); + dispatchPointerEvent(doc, "pointerup", 451, 301, { target: input }); + 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", 450, 300, { + isPrimary: false, + pointerId: 2, + }); + dispatchPointerEvent(doc, "pointerup", 450, 300, { + isPrimary: false, + pointerId: 2, + }); + expect(useReaderStore.getState().toolbarVisible).toBe(true); + }); + + it("ignores drags above the tap tolerance", async () => { + const doc = await mountAndGetIframeDoc(); + + useReaderStore.setState({ toolbarVisible: true }); + // A 200 px horizontal movement is well above the tap tolerance — + // the hook should leave the toolbar (and navigation) untouched so + // the browser keeps native pan / back-swipe behavior. + dispatchPointerEvent(doc, "pointerdown", 450, 300); + dispatchPointerEvent(doc, "pointerup", 250, 300); + 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", 450, 300); + dispatchPointerEvent(doc, "pointercancel", 450, 300); + dispatchPointerEvent(doc, "pointerup", 450, 300); + // Cancel cleared the gesture; pointerup should be a no-op. + 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", () => { + 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..2d6f3a35 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,28 @@ 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 { + EpubTableOfContentsDrawer, + EpubTableOfContentsTrigger, +} from "./EpubTableOfContents"; +import { classifyTapZone, isTap } 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 { MobileReaderBottomBar } from "./MobileReaderBottomBar"; +import { ReaderFirstRunHint } from "./ReaderFirstRunHint"; import { ReaderToolbar } from "./ReaderToolbar"; // EPUB theme definitions @@ -102,11 +127,28 @@ 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 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"; + // react-reader's default `reader` style hard-codes 50px L/R/T and 20px + // bottom insets around the EpubView (for its built-in title area and + // chevron arrows). Those add fixed margin on top of whatever body padding + // we set via `themes.override("padding", ...)`, so "None" (0%) still left + // a visible gutter. Zero them out so the user's margin slider is the sole + // source of margin. On desktop we keep a small inset so the side chevron + // arrows don't overlap edge text at margin=0; on mobile arrows are hidden + // so we go fully edge-to-edge. + const sideInset = isMobile ? 0 : 40; return { ...ReactReaderStyle, readerArea: { @@ -114,9 +156,17 @@ function getReaderStyles(theme: EpubTheme): IReactReaderStyle { backgroundColor: themeColors.body.background, transition: undefined, }, + reader: { + ...ReactReaderStyle.reader, + top: 0, + bottom: 0, + left: sideInset, + right: sideInset, + }, arrow: { ...ReactReaderStyle.arrow, color: isDark ? "#e0e0e0" : "#333", + ...(isMobile ? { display: "none" } : {}), }, arrowHover: { ...ReactReaderStyle.arrowHover, @@ -278,12 +328,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: 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 +355,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 +676,157 @@ export function EpubReader({ : location.start.href; saveLocationRef.current(cfi, percentage, fullHref); }); + + // Neutralize the "position: absolute; left: -999em" sr-only pattern + // (Standard Ebooks ships this on hidden titlepage/imprint/colophon + // headings for AT and old-reader compatibility). epub.js sizes each + // section's iframe to fit a `Range.getBoundingClientRect()` measurement + // of the body's contents; that rect includes the offscreen heading, so + // a 1-column section ends up rendered as 19–25 columns of blank space + // and `rendition.next()` just scrolls one empty column at a time + // instead of advancing chapters. Replace the offset hide with the + // modern clip-based hide which keeps the text in the DOM for screen + // readers without inflating the document's bounding box. + rendition.hooks.content.register((contents: { document: Document }) => { + const doc = contents.document; + if (!doc?.head) return; + const style = doc.createElement("style"); + style.textContent = ` + section[epub\\:type~="titlepage"] h1, + section[epub\\:type~="titlepage"] p, + section[epub\\:type~="colophon"] h2, + section[epub\\:type~="imprint"] h2, + section[class*="epub-type-contains-word-titlepage"] h1, + section[class*="epub-type-contains-word-titlepage"] p, + section[class*="epub-type-contains-word-colophon"] h2, + section[class*="epub-type-contains-word-imprint"] h2 { + position: absolute !important; + left: 0 !important; + top: 0 !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; + border: 0 !important; + } + `; + doc.head.appendChild(style); + }); + + // Bind tap pointer events *inside* the iframe document. This is the + // sole authority for taps; the outer container deliberately has no + // useTouchNav listener so the same tap never gets classified twice. + // + // Why this looks elaborate: + // - `click` doesn't work: iOS Safari suppresses it inside sandboxed + // iframes (epub.js sets sandbox="allow-same-origin"), which lost + // navigation entirely in real-device testing. + // - Plain `doc.addEventListener("pointerup", ...)` doesn't work + // either: iOS Safari intermittently doesn't bubble pointer + // events to `document` for taps that land on text nodes + // (it's arbitrating against the long-press selection gesture). + // The handler ends up firing for some taps and not others. + // - We attach on **both** `doc` and `doc.documentElement`, with + // `pointerdown` in the capture phase. documentElement catches + // events one node lower in the tree, capture fires before any + // child handler can stop propagation. The handler self-guards + // on `pointerId` so the second delivery is a no-op when both + // listeners do fire. + // - `-webkit-touch-callout: none` on the iframe body keeps iOS's + // selection arbitration from holding short taps. + // + // Tap zone math: `event.clientX` inside the iframe is iframe-local, + // and epub.js sizes each section's iframe to the FULL multi-column + // content width (e.g. 2700 for a 2-column chapter), then scrolls the + // wrapper to reveal one column at a time. So clientX on the visible + // page is in [scrollLeft, scrollLeft + viewport]. Comparing it + // directly against `window.innerWidth` (the parent viewport) put any + // tap on column 2 past the "next" threshold — taps on the LEFT side + // of column 2 would still register as next and cycle the user back + // and forth between two pages. Convert to parent-viewport coords by + // adding `frameElement.getBoundingClientRect().left` (negative once + // the iframe is scrolled off to the left). + rendition.hooks.content.register((contents: { document: Document }) => { + const doc = contents.document; + if (!doc) return; + + if (doc.body) { + doc.body.style.touchAction = "manipulation"; + doc.body.style.setProperty("-webkit-touch-callout", "none"); + } + + let pointerId: number | null = null; + let startX = 0; + let startY = 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 taps 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; + }; + + const onPointerUp = (event: PointerEvent) => { + if (pointerId === null || event.pointerId !== pointerId) return; + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + pointerId = null; + + if (!isTap(deltaX, deltaY)) return; + + // Prefer the EPUB's metadata-declared direction (e.g. manga marked + // RTL by the publisher) and fall back to the user's reader setting. + const metadataDirection = ( + renditionRef.current?.book.packaging?.metadata as + | { direction?: string } + | undefined + )?.direction; + const readingDirection = + metadataDirection === "rtl" ? "rtl" : readingDirectionRef.current; + + const frameRect = event.view?.frameElement?.getBoundingClientRect(); + const viewportX = event.clientX + (frameRect?.left ?? 0); + const viewportY = event.clientY + (frameRect?.top ?? 0); + const zone = classifyTapZone( + viewportX, + viewportY, + window.innerWidth, + window.innerHeight, + { readingDirection }, + ); + if (zone === "center") { + toggleToolbarRef.current(); + } else if (zone === "next") { + renditionRef.current?.next(); + } else { + renditionRef.current?.prev(); + } + }; + + const onPointerCancel = (event: PointerEvent) => { + if (pointerId === event.pointerId) pointerId = null; + }; + + doc.documentElement.addEventListener("pointerdown", onPointerDown, true); + doc.documentElement.addEventListener("pointerup", onPointerUp); + doc.documentElement.addEventListener("pointercancel", onPointerCancel); + doc.addEventListener("pointerdown", onPointerDown, true); + doc.addEventListener("pointerup", onPointerUp); + doc.addEventListener("pointercancel", onPointerCancel); + }); }, []); // Handle TOC navigation @@ -730,6 +955,40 @@ export function EpubReader({ renditionRef.current?.display(cfi); }, []); + // Page navigation handlers for the bottom mobile bar's prev / next + // buttons (the iframe-internal tap handler in handleGetRendition is the + // primary path for touch). Kept as named callbacks so the bar's onClick + // identities are stable. + 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]); + // 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. @@ -853,13 +1112,19 @@ export function EpubReader({ }; }, [resetHideTimeout]); - // Show toolbar on mouse move - const handleMouseMove = useCallback(() => { - if (!toolbarVisible) { - setToolbarVisible(true); - } - resetHideTimeout(); - }, [toolbarVisible, setToolbarVisible, resetHideTimeout]); + // Show toolbar on mouse / pen move. Skip touch — synthetic mouse events + // fire after every tap on touch devices, which would pop the toolbar open + // every time the user paged forward via a side-zone tap. + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType === "touch") return; + if (!toolbarVisible) { + setToolbarVisible(true); + } + resetHideTimeout(); + }, + [toolbarVisible, setToolbarVisible, resetHideTimeout], + ); // Get background color based on theme (with fallback for hydration) const getBackgroundColor = () => { @@ -867,13 +1132,24 @@ export function EpubReader({ return theme.body.background; }; + // Plain ref callback — kept for fullscreen handling. There's no + // outer-container touch listener: taps inside the EPUB are owned by the + // inside-iframe handler in handleGetRendition, and adding a second + // handler here would race with it on real iOS (iframe events leaking + // into the parent dispatcher cause the same tap to be classified + // twice). + const setContainerRef = useCallback((element: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject).current = + element; + }, []); + return ( setSettingsOpened(true)} showPageNavigation={false} + prevBook={adjacentBooks?.prev} + nextBook={adjacentBooks?.next} + onPrevBook={canGoPrevBook ? goToPrevBook : undefined} + onNextBook={canGoNextBook ? goToNextBook : undefined} leftActions={ - setTocOpened((prev) => !prev)} - onNavigate={handleTocNavigate} /> } rightActions={ @@ -944,8 +1220,63 @@ export function EpubReader({ /> } + mobileMenuItems={ + <> + + EPUB + } + onClick={() => setTocOpened(true)} + > + Table of contents + + } + onClick={() => setBookmarksOpened(true)} + > + Bookmarks + + } + onClick={() => setSearchOpened(true)} + > + Search + + + } + /> + + {/* TOC drawer is rendered outside the toolbar's Transition so it + stays open while the toolbar auto-hides (otherwise the drawer + would unmount with the toolbar 3 seconds after opening). */} + setTocOpened(false)} + onNavigate={handleTocNavigate} /> + {/* 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 */} { - describe("Toggle button", () => { - it("renders TOC toggle button", () => { - renderWithProviders( - , - ); +describe("EpubTableOfContentsTrigger", () => { + it("renders TOC toggle button", () => { + renderWithProviders(); - expect( - screen.getByRole("button", { name: /table of contents/i }), - ).toBeInTheDocument(); - }); + expect( + screen.getByRole("button", { name: /table of contents/i }), + ).toBeInTheDocument(); + }); - it("calls onToggle when button is clicked", async () => { - const user = userEvent.setup(); - const onToggle = vi.fn(); + it("calls onToggle when button is clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); - renderWithProviders( - , - ); + renderWithProviders(); - await user.click( - screen.getByRole("button", { name: /table of contents/i }), - ); + await user.click( + screen.getByRole("button", { name: /table of contents/i }), + ); - expect(onToggle).toHaveBeenCalledTimes(1); - }); + expect(onToggle).toHaveBeenCalledTimes(1); }); +}); +describe("EpubTableOfContentsDrawer", () => { describe("Drawer", () => { it("does not show drawer content when closed", () => { renderWithProviders( - , ); - // When drawer is closed, TOC items should not be visible expect( screen.queryByText("Chapter 1: Introduction"), ).not.toBeInTheDocument(); @@ -96,15 +84,14 @@ describe("EpubTableOfContents", () => { it("shows drawer content when opened", () => { renderWithProviders( - , ); - // Drawer title and content should be visible expect(screen.getByText("Table of Contents")).toBeInTheDocument(); expect(screen.getByText("Chapter 1: Introduction")).toBeInTheDocument(); }); @@ -113,10 +100,10 @@ describe("EpubTableOfContents", () => { describe("TOC items", () => { it("renders all top-level TOC items", () => { renderWithProviders( - , ); @@ -132,10 +119,10 @@ describe("EpubTableOfContents", () => { it("renders nested TOC items (subitems)", () => { renderWithProviders( - , ); @@ -144,16 +131,16 @@ describe("EpubTableOfContents", () => { expect(screen.getByText("2.2 Configuration")).toBeInTheDocument(); }); - it("calls onNavigate with href when TOC item is clicked", async () => { + it("calls onNavigate with href and closes drawer when TOC item is clicked", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); - const onToggle = vi.fn(); + const onClose = vi.fn(); renderWithProviders( - , ); @@ -161,7 +148,7 @@ describe("EpubTableOfContents", () => { await user.click(screen.getByText("Chapter 1: Introduction")); expect(onNavigate).toHaveBeenCalledWith("chapter1.xhtml"); - expect(onToggle).toHaveBeenCalledTimes(1); // Should close drawer + expect(onClose).toHaveBeenCalledTimes(1); }); it("calls onNavigate with nested item href", async () => { @@ -169,10 +156,10 @@ describe("EpubTableOfContents", () => { const onNavigate = vi.fn(); renderWithProviders( - , ); @@ -186,10 +173,10 @@ describe("EpubTableOfContents", () => { describe("Empty state", () => { it("shows empty message when TOC is empty", () => { renderWithProviders( - , ); @@ -203,17 +190,15 @@ describe("EpubTableOfContents", () => { describe("Current chapter highlighting", () => { it("renders current chapter link", () => { renderWithProviders( - , ); - // The NavLink component handles active state internally - // We verify the link is rendered correctly const chapter2Link = screen.getByText("Chapter 2: Getting Started"); expect(chapter2Link).toBeInTheDocument(); }); diff --git a/web/src/components/reader/EpubTableOfContents.tsx b/web/src/components/reader/EpubTableOfContents.tsx index afe90621..9a6c6c36 100644 --- a/web/src/components/reader/EpubTableOfContents.tsx +++ b/web/src/components/reader/EpubTableOfContents.tsx @@ -10,33 +10,59 @@ import { import { IconList } from "@tabler/icons-react"; import type { NavItem } from "epubjs"; -interface EpubTableOfContentsProps { +interface EpubTableOfContentsTriggerProps { + /** Callback to open/close the drawer */ + onToggle: () => void; +} + +/** + * Trigger ActionIcon for the EPUB TOC drawer. Lives inside the toolbar so it + * can sit alongside other reader actions on desktop. The {@link EpubTableOfContentsDrawer} + * is rendered separately at the reader level so it survives the toolbar's + * auto-hide unmount. + */ +export function EpubTableOfContentsTrigger({ + onToggle, +}: EpubTableOfContentsTriggerProps) { + return ( + + + + + + ); +} + +interface EpubTableOfContentsDrawerProps { /** Table of contents items from epub.js */ toc: NavItem[]; /** Currently active chapter href */ currentHref?: string; /** Whether the drawer is open */ opened: boolean; - /** Callback to open/close the drawer */ - onToggle: () => void; + /** Callback to close the drawer */ + onClose: () => void; /** Callback when a TOC item is clicked */ onNavigate: (href: string) => void; } /** - * Table of Contents drawer for EPUB reader. - * - * Displays a hierarchical navigation structure from the EPUB's NCX/nav document. - * Supports nested chapters (subitems) with indentation. + * Drawer-only TOC. Rendered at the reader level (outside the toolbar's + * `` subtree) so it stays mounted while the toolbar auto-hides. */ -export function EpubTableOfContents({ +export function EpubTableOfContentsDrawer({ toc, currentHref, opened, - onToggle, + onClose, onNavigate, -}: EpubTableOfContentsProps) { - // Render a single TOC item (potentially with children) +}: EpubTableOfContentsDrawerProps) { const renderTocItem = (item: NavItem, depth = 0) => { const isActive = currentHref === item.href; const hasChildren = item.subitems && item.subitems.length > 0; @@ -48,7 +74,7 @@ export function EpubTableOfContents({ active={isActive} onClick={() => { onNavigate(item.href); - onToggle(); // Close drawer after navigation + onClose(); }} pl={depth * 16 + 12} styles={{ @@ -67,43 +93,27 @@ export function EpubTableOfContents({ }; return ( - <> - {/* Toggle button */} - - - - - - - {/* TOC Drawer */} - - - {toc.length === 0 ? ( - - No table of contents available - - ) : ( - {toc.map((item) => renderTocItem(item))} - )} - - - + + + {toc.length === 0 ? ( + + No table of contents available + + ) : ( + {toc.map((item) => renderTocItem(item))} + )} + + ); } diff --git a/web/src/components/reader/MobileReaderBottomBar.test.tsx b/web/src/components/reader/MobileReaderBottomBar.test.tsx new file mode 100644 index 00000000..2eab33f6 --- /dev/null +++ b/web/src/components/reader/MobileReaderBottomBar.test.tsx @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useReaderStore } from "@/store/readerStore"; +import { fireEvent, renderWithProviders, screen, waitFor } from "@/test/utils"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; + +/** + * Force the phone breakpoint by reporting `matches: true` for max-width + * media queries. The shared test setup mocks matchMedia to always return + * `matches: false`, which is the desktop default. The MobileReaderBottomBar + * self-gates on `useMediaQuery("(max-width: 30.0625em)")` so without this + * override it would render nothing. + */ +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(), + })), + }); +} + +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/PageTransitionWrapper.test.tsx b/web/src/components/reader/PageTransitionWrapper.test.tsx index 2e6b0660..1e24a62a 100644 --- a/web/src/components/reader/PageTransitionWrapper.test.tsx +++ b/web/src/components/reader/PageTransitionWrapper.test.tsx @@ -493,9 +493,11 @@ describe("PageTransitionWrapper", () => { expect(screen.getByText("Page 2")).toBeInTheDocument(); expect(screen.getByText("Page 3")).toBeInTheDocument(); - // At 560ms (500ms duration + 50ms buffer + margin), transition should be complete + // At 580ms (500ms duration + 50ms buffer + ~16ms rAF + margin), + // transition should be complete. The rAF wait before the active + // phase exists so the new page's images can decode before sliding. await act(async () => { - vi.advanceTimersByTime(260); + vi.advanceTimersByTime(280); }); expect(screen.queryByText("Page 2")).not.toBeInTheDocument(); expect(screen.getByText("Page 3")).toBeInTheDocument(); diff --git a/web/src/components/reader/PageTransitionWrapper.tsx b/web/src/components/reader/PageTransitionWrapper.tsx index a84b53c3..502bba6f 100644 --- a/web/src/components/reader/PageTransitionWrapper.tsx +++ b/web/src/components/reader/PageTransitionWrapper.tsx @@ -6,6 +6,21 @@ import type { ReadingDirection, } from "@/store/readerStore"; +/** + * Wait for an image element to be decoded and ready to paint. + * Falls back to load/error events on browsers without decode() support. + */ +function whenImageReady(img: HTMLImageElement): Promise { + if (typeof img.decode === "function") { + return img.decode().catch(() => undefined); + } + if (img.complete) return Promise.resolve(); + return new Promise((resolve) => { + img.addEventListener("load", () => resolve(), { once: true }); + img.addEventListener("error", () => resolve(), { once: true }); + }); +} + interface PageTransitionWrapperProps { /** Current page key (used to detect page changes) */ pageKey: string; @@ -100,9 +115,15 @@ export function PageTransitionWrapper({ }); const transitionTimeoutRef = useRef(null); + const decodeTimeoutRef = useRef(null); const rafRef = useRef(null); const previousKeyRef = useRef(pageKey); const isInitialMountRef = useRef(true); + const currentBoxRef = useRef(null); + // Cancellation token for the in-flight transition. Set to {cancelled: true} + // when a new page change arrives so any pending image-decode promise short + // circuits and we don't fire setState into a stale transition. + const pendingTransitionRef = useRef<{ cancelled: boolean } | null>(null); // Cleanup timeouts on unmount useEffect(() => { @@ -110,9 +131,15 @@ export function PageTransitionWrapper({ if (transitionTimeoutRef.current) { clearTimeout(transitionTimeoutRef.current); } + if (decodeTimeoutRef.current) { + clearTimeout(decodeTimeoutRef.current); + } if (rafRef.current) { cancelAnimationFrame(rafRef.current); } + if (pendingTransitionRef.current) { + pendingTransitionRef.current.cancelled = true; + } }; }, []); @@ -136,15 +163,22 @@ export function PageTransitionWrapper({ // Page changed - start transition previousKeyRef.current = pageKey; - // Clear any pending transitions + // Cancel any in-flight transition if (transitionTimeoutRef.current) { clearTimeout(transitionTimeoutRef.current); transitionTimeoutRef.current = null; } + if (decodeTimeoutRef.current) { + clearTimeout(decodeTimeoutRef.current); + decodeTimeoutRef.current = null; + } if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } + if (pendingTransitionRef.current) { + pendingTransitionRef.current.cancelled = true; + } // Skip transition on initial mount (when first loading the book or reloading the page) // Also skip when navigationDirection is null - this means no user navigation has happened yet @@ -178,7 +212,13 @@ export function PageTransitionWrapper({ readingDirection, ); - // Start transition: set entering phase (positions elements) + // Commit the entering phase. The next useEffect below watches for + // phase === "entering" and schedules the active phase once the new + // page's images are decoded. Splitting into two effects guarantees + // React fully commits and paints the off-screen starting position + // before we begin the slide; doing both in one effect risks React + // batching the state updates so the browser never sees the starting + // position (the cause of "Sometimes slide is not applied"). setState((prev) => ({ currentContent: children, previousContent: prev.currentContent, @@ -186,68 +226,81 @@ export function PageTransitionWrapper({ phase: "entering", slideDirection, })); + }, [pageKey, children, transition, navigationDirection, readingDirection]); - // Trigger animation after browser paints initial position - // Use double rAF to ensure layout is complete before animating - rafRef.current = requestAnimationFrame(() => { - rafRef.current = requestAnimationFrame(() => { + // Drive the entering -> active -> idle phase progression. This runs + // after React commits the entering state, so by the time we query the + // DOM for elements they are already mounted with the new src + // and the browser has had a paint cycle to begin decoding. + useEffect(() => { + if (state.phase !== "entering") return; + + const token = { cancelled: false }; + pendingTransitionRef.current = token; + + const startActivePhase = () => { + if (token.cancelled) return; + token.cancelled = true; + if (decodeTimeoutRef.current) { + clearTimeout(decodeTimeoutRef.current); + decodeTimeoutRef.current = null; + } + setState((prev) => ({ ...prev, phase: "active" })); + + // End transition after duration (add buffer for paint cycles) + transitionTimeoutRef.current = setTimeout(() => { setState((prev) => ({ ...prev, - phase: "active", + previousContent: null, + phase: "idle", })); + transitionTimeoutRef.current = null; + }, duration + 50); + }; + + // Wait for the new page's images to be decoded before starting the + // slide. Without this, an image that's cached-but-not-yet-painted + // shows as the page's background color (typically black) for the + // first 1-2 frames of the slide, producing the "black flicker on + // the side it's sliding from". + const imgs = currentBoxRef.current + ? Array.from(currentBoxRef.current.querySelectorAll("img")) + : []; + + if (imgs.length === 0) { + // No images to wait for; just give the browser a frame to paint + // the entering position before we start the transition. + rafRef.current = requestAnimationFrame(() => { rafRef.current = null; + startActivePhase(); }); - }); + } else { + // Cap the wait so a slow/broken image doesn't stall the UI. + decodeTimeoutRef.current = setTimeout(startActivePhase, 250); - // End transition after duration (add buffer for rAF delays ~32ms for double rAF) - transitionTimeoutRef.current = setTimeout(() => { - setState((prev) => ({ - ...prev, - previousContent: null, - phase: "idle", - })); - transitionTimeoutRef.current = null; - }, duration + 50); - }, [ - pageKey, - children, - transition, - duration, - navigationDirection, - readingDirection, - ]); - - // When idle or no transition, render content in a stable container - // Use same structure as during transitions to prevent layout shifts - if (transition === "none" || state.phase === "idle") { - return ( - - - {state.currentContent} - - - ); - } + Promise.all(imgs.map(whenImageReady)).then(() => { + if (token.cancelled) return; + // One rAF after decode so the decoded pixels make it to the + // screen before the transform transition begins. + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + startActivePhase(); + }); + }); + } + + return () => { + token.cancelled = true; + }; + }, [state.phase, duration]); const isEntering = state.phase === "entering"; const isActive = state.phase === "active"; + const isTransitioning = state.phase !== "idle" && transition !== "none"; const { slideDirection } = state; // Calculate transforms for slide transition const getEnterTransform = () => { - if (transition === "fade") return undefined; switch (slideDirection) { case "right": return "translateX(100%)"; @@ -261,7 +314,6 @@ export function PageTransitionWrapper({ }; const getExitTransform = () => { - if (transition === "fade") return undefined; switch (slideDirection) { case "right": return "translateX(-100%)"; @@ -274,13 +326,18 @@ export function PageTransitionWrapper({ } }; - const getNoTransform = () => { - // Return the appropriate neutral transform based on direction - return slideDirection === "up" || slideDirection === "down" - ? "translateY(0)" - : "translateX(0)"; - }; - + // Render the SAME DOM structure regardless of phase so the current + // content's wrapper Box (and the page component inside it) is never + // remounted across the entering/active/idle transitions. Remounting + // would re-trigger the image's load-time opacity fade and produce a + // flicker at the end of the slide. + // + // For fade: keep the previous layer fully opaque underneath and only + // fade the new layer in over it. A true crossfade (both layers at 0.5 + // opacity at the midpoint) darkens because the bottom is composited + // over the transparent container, yielding ~0.25 contribution from + // the previous page and a visibly dark midpoint when pages have dark + // backgrounds. return ( - {/* Previous content (exits) */} - {state.previousContent && ( + {/* Previous content - only rendered during a transition. Stays + fully opaque under the new layer for fade; slides out for slide. */} + {state.previousContent && transition !== "none" && ( {state.previousContent} )} - {/* Current content (enters) */} + {/* Current content - ALWAYS rendered in the same DOM position so + React preserves the underlying page component across phases. */} 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..f846fcc7 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", () => { + 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..bc60a7db 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"); + // On a phone-sized viewport, `fit-page` produces an unreadably small page + // (portrait PDF scaled to a portrait viewport leaves 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(() => { @@ -426,13 +439,19 @@ export function PdfReader({ }; }, [resetHideTimeout]); - // Show toolbar on mouse move - const handleMouseMove = useCallback(() => { - if (!toolbarVisible) { - setToolbarVisible(true); - } - resetHideTimeout(); - }, [toolbarVisible, setToolbarVisible, resetHideTimeout]); + // Show toolbar on mouse / pen move. Skip touch — synthetic mouse events + // fire after every tap on touch devices, which would pop the toolbar open + // every time the user paged forward via a side-zone tap. + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType === "touch") return; + if (!toolbarVisible) { + setToolbarVisible(true); + } + resetHideTimeout(); + }, + [toolbarVisible, setToolbarVisible, resetHideTimeout], + ); // Keyboard navigation useKeyboardNav({ @@ -510,27 +529,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 +644,7 @@ export function PdfReader({ const containerStyle: CSSProperties = useMemo( () => ({ width: "100vw", - height: "100vh", + height: "100dvh", position: "relative", overflow: "hidden", backgroundColor: bgColor, @@ -654,14 +652,22 @@ export function PdfReader({ [bgColor], ); - // Page container style + // Page container style. + // The container shrinks to sit *below* the toolbar (and above the bottom + // bar) when they're visible, so a fit-width page taller than the viewport + // can scroll its top edge into view instead of staying hidden under the + // 64px toolbar. `margin: auto` on the page wrapper still centers short + // pages within whatever vertical space is available, and pins to the top + // (scrolling normally) when content overflows. const pageContainerStyle: CSSProperties = useMemo( () => ({ position: "absolute", - top: toolbarVisible ? 64 : 0, + top: toolbarVisible + ? "calc(env(safe-area-inset-top, 0px) + 64px)" + : "env(safe-area-inset-top, 0px)", left: 0, right: 0, - bottom: 0, + bottom: "env(safe-area-inset-bottom, 0px)", overflow: "auto", display: "flex", justifyContent: "center", @@ -676,7 +682,7 @@ export function PdfReader({ if (progressLoading && numPages === 0) { return (
@@ -686,7 +692,7 @@ export function PdfReader({ return ( {/* Toolbar */} @@ -703,6 +709,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") { @@ -756,11 +776,11 @@ export function PdfReader({ {pageError ? (
{pageError}
) : ( - - -
- } - > - {/* Wait for container dimensions before rendering pages in fit modes */} - {isFitMode && !containerReady ? ( -
- -
- ) : ( - - {/* Left page (or single page) */} - {spreadPages.left && ( - - -
- } - customTextRenderer={ - debouncedSearchText - ? ({ str }) => { - if (!debouncedSearchText) return str; - const regex = new RegExp( - `(${debouncedSearchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi", - ); - const parts = str.split(regex); - return parts - .map((part) => - regex.test(part) - ? `${part}` - : part, - ) - .join(""); - } - : undefined - } - /> - )} - {/* Right page (only in spread modes) */} - {spreadPages.right && ( - - - - } - customTextRenderer={ - debouncedSearchText - ? ({ str }) => { - if (!debouncedSearchText) return str; - const regex = new RegExp( - `(${debouncedSearchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi", - ); - const parts = str.split(regex); - return parts - .map((part) => - regex.test(part) - ? `${part}` - : part, - ) - .join(""); - } - : undefined - } - /> - )} -
- )} - + // `` is the direct flex child of the scrolling parent + // and renders a content-sized block div with no style prop. Apply + // `margin: auto` here so it consumes the cross-axis free space + // (centering short pages) while still pinning to the top when + // the page is taller than the viewport. + + + + + } + > + {/* Wait for container dimensions before rendering pages in fit modes */} + {isFitMode && !containerReady ? ( +
+ +
+ ) : ( + + {/* Left page (or single page) */} + {spreadPages.left && ( + + + + } + customTextRenderer={ + debouncedSearchText + ? ({ str }) => { + if (!debouncedSearchText) return str; + const regex = new RegExp( + `(${debouncedSearchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "gi", + ); + const parts = str.split(regex); + return parts + .map((part) => + regex.test(part) + ? `${part}` + : part, + ) + .join(""); + } + : undefined + } + /> + )} + {/* Right page (only in spread modes) */} + {spreadPages.right && ( + + + + } + customTextRenderer={ + debouncedSearchText + ? ({ str }) => { + if (!debouncedSearchText) return str; + const regex = new RegExp( + `(${debouncedSearchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "gi", + ); + const parts = str.split(regex); + return parts + .map((part) => + regex.test(part) + ? `${part}` + : part, + ) + .join(""); + } + : undefined + } + /> + )} + + )} +
+
)} )} 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 && ( + { + it("returns true for zero movement", () => { + expect(isTap(0, 0)).toBe(true); + }); + + it("returns true for movement within tolerance", () => { + expect(isTap(5, 5)).toBe(true); + expect(isTap(-9, 9)).toBe(true); + }); + + it("returns false at the tolerance boundary", () => { + expect(isTap(TAP_TOLERANCE, 0)).toBe(false); + }); + + it("returns false for horizontal movement above tolerance", () => { + expect(isTap(100, 0)).toBe(false); + expect(isTap(-100, 0)).toBe(false); + }); + + it("returns false for vertical movement above tolerance", () => { + expect(isTap(0, 100)).toBe(false); + expect(isTap(0, -100)).toBe(false); + }); + + it("honors a custom tapTolerance", () => { + // 15px movement is not a tap by default (tolerance=10), but is with tolerance=20. + expect(isTap(15, 0)).toBe(false); + expect(isTap(15, 0, 20)).toBe(true); + }); +}); + +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..78b96316 --- /dev/null +++ b/web/src/components/reader/hooks/swipeGesture.ts @@ -0,0 +1,87 @@ +/** + * Tap-gesture helpers shared by `useTouchNav` (outer-container pointer events) + * and `EpubReader`'s inside-iframe pointer hook. + * + * Click-only navigation: we intentionally do not classify swipes. A pointer + * movement above `TAP_TOLERANCE` is ignored so the browser keeps its native + * pan/scroll/back-swipe behavior intact. + * + * Kept input-agnostic on purpose: callers pass deltas in pixels; the helper + * has no knowledge of pointer events, touch events, or React. + */ + +/** Maximum movement in pixels still treated as a tap (not a drag/swipe). */ +export const TAP_TOLERANCE = 10; + +/** + * 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"; +} + +/** + * Returns true when the pointer barely moved between down and up. We treat + * anything within `TAP_TOLERANCE` (default 10px) as an intentional tap and + * ignore anything larger so the browser handles pan / scroll / back-swipe. + */ +export function isTap( + deltaX: number, + deltaY: number, + tapTolerance: number = TAP_TOLERANCE, +): boolean { + return Math.abs(deltaX) < tapTolerance && Math.abs(deltaY) < tapTolerance; +} + +/** + * 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; + 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"; +} 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..623095c1 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,77 +30,86 @@ 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. + 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 ( + const simulateGesture = 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)); - }); - }; - - describe("LTR mode", () => { - it("should call onNextPage when swiping left", async () => { - const { result } = renderHook(() => - useTouchNav({ - enabled: true, - onNextPage: mockNextPage, - onPrevPage: mockPrevPage, - minSwipeDistance: 50, + element.dispatchEvent( + createPointerEvent("pointerup", endX, endY, { + ...init, + timeStamp: duration, }), ); - - act(() => { - result.current.touchRef(element); - }); - - await simulateSwipe(200, 100, 100, 100); // Swipe left - - expect(mockNextPage).toHaveBeenCalledTimes(1); - expect(mockPrevPage).not.toHaveBeenCalled(); }); + }; - it("should call onPrevPage when swiping right", async () => { + describe("click-only navigation (no swipe)", () => { + it("ignores horizontal drags / swipes", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); @@ -111,19 +117,21 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 200, 100); // Swipe right + await simulateGesture(200, 100, 100, 100); + await simulateGesture(100, 100, 200, 100); - expect(mockPrevPage).toHaveBeenCalledTimes(1); expect(mockNextPage).not.toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); + expect(mockTap).not.toHaveBeenCalled(); }); - it("should not trigger navigation for small swipes", async () => { + it("ignores vertical drags / swipes", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); @@ -131,165 +139,190 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 120, 100); // Small swipe (20px) + await simulateGesture(100, 100, 100, 250); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); + expect(mockTap).not.toHaveBeenCalled(); }); }); - describe("RTL mode", () => { - beforeEach(() => { - useReaderStore.setState({ - readingDirectionOverride: "rtl", + describe("zone-aware tap dispatch", () => { + // jsdom doesn't compute layout, so we stub getBoundingClientRect to make + // the element 900x600 anchored at (0,0). + 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("should call onPrevPage when swiping left (reversed)", async () => { + it("calls onPrevPage for a tap in the left third (LTR)", async () => { + stubRect(); const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateGesture(100, 300, 100, 300); expect(mockPrevPage).toHaveBeenCalledTimes(1); + expect(mockTap).not.toHaveBeenCalled(); expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should call onNextPage when swiping right (reversed)", async () => { + it("calls onTap for a tap in the middle third (LTR)", async () => { + stubRect(); const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 200, 100); // Swipe right + await simulateGesture(450, 300, 450, 300); - expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockTap).toHaveBeenCalledTimes(1); expect(mockPrevPage).not.toHaveBeenCalled(); - }); - }); - - describe("TTB mode", () => { - beforeEach(() => { - useReaderStore.setState({ - readingDirectionOverride: "ttb", - }); + expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should call onNextPage when swiping up", async () => { + it("calls onNextPage for a tap in the right third (LTR)", async () => { + stubRect(); const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(100, 200, 100, 100); // Swipe up + await simulateGesture(800, 300, 800, 300); expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockTap).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); }); - it("should call onPrevPage when swiping down", async () => { + it("flips left/right zones in RTL", async () => { + stubRect(); + useReaderStore.setState({ readingDirectionOverride: "rtl" }); + const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 100, 200); // Swipe down + await simulateGesture(100, 300, 100, 300); + expect(mockNextPage).toHaveBeenCalledTimes(1); + await simulateGesture(800, 300, 800, 300); expect(mockPrevPage).toHaveBeenCalledTimes(1); - expect(mockNextPage).not.toHaveBeenCalled(); }); - it("should ignore horizontal swipes in TTB mode", async () => { + it("uses vertical thirds in TTB mode", async () => { + stubRect(); + useReaderStore.setState({ readingDirectionOverride: "ttb" }); + const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left (horizontal) + // Top third → prev. + await simulateGesture(450, 50, 450, 50); + expect(mockPrevPage).toHaveBeenCalledTimes(1); - expect(mockNextPage).not.toHaveBeenCalled(); - expect(mockPrevPage).not.toHaveBeenCalled(); - }); - }); + // Middle third → toolbar toggle. + await simulateGesture(450, 300, 450, 300); + expect(mockTap).toHaveBeenCalledTimes(1); - describe("webtoon mode", () => { - beforeEach(() => { - useReaderStore.setState({ - readingDirectionOverride: "webtoon", - }); + // Bottom third → next. + await simulateGesture(450, 550, 450, 550); + expect(mockNextPage).toHaveBeenCalledTimes(1); }); - it("should use vertical navigation like TTB", async () => { + it("treats every tap as a center tap when tapZones is false", async () => { + stubRect(); const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, - minSwipeDistance: 50, + onTap: mockTap, + tapZones: false, }), ); - act(() => { result.current.touchRef(element); }); - await simulateSwipe(100, 200, 100, 100); // Swipe up + await simulateGesture(100, 300, 100, 300); + await simulateGesture(800, 300, 800, 300); - expect(mockNextPage).toHaveBeenCalledTimes(1); + expect(mockTap).toHaveBeenCalledTimes(2); + expect(mockNextPage).not.toHaveBeenCalled(); + expect(mockPrevPage).not.toHaveBeenCalled(); }); }); - describe("tap detection", () => { - it("should call onTap for minimal movement", async () => { + describe("mouse input", () => { + it("treats a mouse click as a tap", async () => { + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 900, + bottom: 600, + width: 900, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, onTap: mockTap, - minSwipeDistance: 50, }), ); @@ -297,21 +330,18 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(100, 100, 102, 102); // Minimal movement (tap) + await simulateGesture(450, 300, 451, 301, { pointerType: "mouse" }); expect(mockTap).toHaveBeenCalledTimes(1); - expect(mockNextPage).not.toHaveBeenCalled(); - expect(mockPrevPage).not.toHaveBeenCalled(); }); - it("should not call onTap for swipes", async () => { + it("ignores non-primary mouse buttons (right-click drag)", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, onTap: mockTap, - minSwipeDistance: 50, }), ); @@ -319,19 +349,25 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe + await simulateGesture(300, 200, 300, 200, { + pointerType: "mouse", + button: 2, + }); expect(mockTap).not.toHaveBeenCalled(); + 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, onNextPage: mockNextPage, onPrevPage: mockPrevPage, + onTap: mockTap, }), ); @@ -339,20 +375,22 @@ describe("useTouchNav", () => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + await simulateGesture(450, 300, 450, 300); + expect(mockTap).not.toHaveBeenCalled(); expect(mockNextPage).not.toHaveBeenCalled(); expect(mockPrevPage).not.toHaveBeenCalled(); }); }); - describe("touch cancel", () => { - it("should handle touch cancel gracefully", async () => { + describe("pointer cancel", () => { + it("clears gesture state so a follow-up pointerup is ignored", async () => { const { result } = renderHook(() => useTouchNav({ enabled: true, onNextPage: mockNextPage, onPrevPage: mockPrevPage, + onTap: mockTap, }), ); @@ -361,27 +399,33 @@ describe("useTouchNav", () => { }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchstart", 200, 100)); + element.dispatchEvent( + createPointerEvent("pointerdown", 200, 100, { timeStamp: 0 }), + ); }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchcancel", 150, 100)); + element.dispatchEvent( + createPointerEvent("pointercancel", 200, 100, { timeStamp: 50 }), + ); }); await act(async () => { - element.dispatchEvent(createTouchEvent("touchend", 100, 100)); + element.dispatchEvent( + createPointerEvent("pointerup", 200, 100, { timeStamp: 100 }), + ); }); - // Should not trigger after cancel + expect(mockTap).not.toHaveBeenCalled(); expect(mockNextPage).not.toHaveBeenCalled(); 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, - onNextPage: mockNextPage, + onTap: mockTap, }), ); @@ -396,23 +440,22 @@ 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; tap on it must not fire. act(() => { - element.dispatchEvent(createTouchEvent("touchstart", 200, 100)); - element.dispatchEvent(createTouchEvent("touchend", 100, 100)); + element.dispatchEvent(createPointerEvent("pointerdown", 200, 200)); + element.dispatchEvent(createPointerEvent("pointerup", 200, 200)); }); - expect(mockNextPage).not.toHaveBeenCalled(); + expect(mockTap).not.toHaveBeenCalled(); document.body.removeChild(element2); }); - it("should handle null ref", () => { + it("handles null ref without throwing", () => { const { result } = renderHook(() => useTouchNav({ enabled: true, - onNextPage: mockNextPage, + onTap: mockTap, }), ); @@ -424,17 +467,28 @@ 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 () => { + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 900, + bottom: 600, + width: 900, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const storeNextPage = vi.spyOn(useReaderStore.getState(), "nextPage"); useReaderStore.setState({ @@ -442,18 +496,14 @@ describe("useTouchNav", () => { totalPages: 10, }); - const { result } = renderHook(() => - useTouchNav({ - enabled: true, - minSwipeDistance: 50, - }), - ); + const { result } = renderHook(() => useTouchNav({ enabled: true })); act(() => { result.current.touchRef(element); }); - await simulateSwipe(200, 100, 100, 100); // Swipe left + // Tap in the right third → next page. + await simulateGesture(800, 300, 800, 300); expect(storeNextPage).toHaveBeenCalled(); }); diff --git a/web/src/components/reader/hooks/useTouchNav.ts b/web/src/components/reader/hooks/useTouchNav.ts index 4e41ef57..abd5d3dd 100644 --- a/web/src/components/reader/hooks/useTouchNav.ts +++ b/web/src/components/reader/hooks/useTouchNav.ts @@ -1,214 +1,197 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { selectEffectiveReadingDirection, useReaderStore, } from "@/store/readerStore"; +import { classifyTapZone, isTap, TAP_TOLERANCE } 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; - /** Maximum time in ms for a swipe gesture (default: 300) */ - maxSwipeTime?: number; /** Custom handler for next page (overrides default store action) */ 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, +}; + /** - * Hook for touch/swipe navigation in the reader. + * Hook for tap navigation in the reader. Click/tap only — we intentionally + * do not implement swipe gestures; movement above {@link TAP_TOLERANCE} is + * ignored so the browser keeps its native pan/scroll/back-swipe behavior. + * + * Uses Pointer Events so a single code path covers touch (finger), mouse + * (desktop, Chrome mobile-viewport emulation), and pen input. * - * Supports: - * - Horizontal swipes for page navigation - * - Vertical swipes for page navigation (TTB/webtoon modes) - * - Tap detection for toolbar toggle + * Tap-zone mapping (when `tapZones` is true, the default): + * - LTR: left third → prev, middle → toolbar toggle, right third → next. + * - RTL: mirrored. + * - TTB / webtoon: top → prev, middle → toolbar, bottom → next. * - * Reading direction is respected: - * - LTR: Swipe left = next, Swipe right = prev - * - RTL: Swipe left = prev, Swipe right = next - * - TTB/Webtoon: Swipe up = next, Swipe down = prev + * With `tapZones: false`, every tap fires `onTap` (used by continuous-scroll + * modes where the whole surface is a toolbar toggle). * * @returns ref to attach to the touchable element */ export function useTouchNav({ enabled = true, - minSwipeDistance = 50, - maxSwipeTime = 300, onNextPage, onPrevPage, onTap, + tapZones = true, }: UseTouchNavOptions = {}) { const storeNextPage = useReaderStore((state) => state.nextPage); const storePrevPage = useReaderStore((state) => state.prevPage); const readingDirection = useReaderStore(selectEffectiveReadingDirection); - // Use custom handlers if provided, otherwise fall back to store actions 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) => { - if (!enabled) return; - - const touch = e.touches[0]; - touchState.current = { - startX: touch.clientX, - startY: touch.clientY, - startTime: Date.now(), - isTracking: true, - }; - }, - [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 - nextPage(); - } else { - // Swipe down = prev page - prevPage(); - } - } - } 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(); - } - } - } - } - }, - [ + // Stash live config in a ref so the attached listeners (whose identity is + // stable) always read fresh state without detach/reattach churn. + const configRef = useRef({ + enabled, + readingDirection, + nextPage, + prevPage, + onTap, + tapZones, + }); + useLayoutEffect(() => { + configRef.current = { enabled, - minSwipeDistance, - maxSwipeTime, readingDirection, nextPage, prevPage, onTap, - ], - ); + tapZones, + }; + }); + + const handlePointerDown = useCallback((e: PointerEvent) => { + const cfg = configRef.current; + if (!cfg.enabled) return; + if (!e.isPrimary) return; + if (e.pointerType === "mouse" && e.button !== 0) return; - const handleTouchCancel = useCallback(() => { - touchState.current.isTracking = false; + gestureState.current = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + }; + }, []); + + const handlePointerUp = useCallback((e: PointerEvent) => { + const cfg = configRef.current; + 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; + + gestureState.current = { ...INITIAL_GESTURE }; + if (!cfg.enabled) return; + if (!isTap(deltaX, deltaY)) return; + + if (!cfg.tapZones) { + cfg.onTap?.(); + return; + } + + const element = elementRef.current; + if (!element) { + cfg.onTap?.(); + return; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + cfg.onTap?.(); + return; + } + const zone = classifyTapZone( + e.clientX - rect.left, + e.clientY - rect.top, + rect.width, + rect.height, + { readingDirection: cfg.readingDirection }, + ); + if (zone === "center") { + cfg.onTap?.(); + } else if (zone === "next") { + cfg.nextPage(); + } else { + cfg.prevPage(); + } + }, []); + + const handlePointerCancel = useCallback((e: PointerEvent) => { + const state = gestureState.current; + if (state.pointerId === e.pointerId) { + gestureState.current = { ...INITIAL_GESTURE }; + } }, []); - // 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); + if (elementRef.current && elementRef.current !== element) { 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, - }); + if (element) { + element.addEventListener("pointerdown", handlePointerDown); + element.addEventListener("pointerup", handlePointerUp); + element.addEventListener("pointercancel", handlePointerCancel); } }, - [enabled, handleTouchStart, handleTouchEnd, handleTouchCancel], + [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..85b8a497 100644 --- a/web/src/components/reader/index.ts +++ b/web/src/components/reader/index.ts @@ -7,8 +7,12 @@ export { EpubBookmarks } from "./EpubBookmarks"; export { EpubReader } from "./EpubReader"; export { EpubReaderSettings } from "./EpubReaderSettings"; export { EpubSearch } from "./EpubSearch"; -export { EpubTableOfContents } from "./EpubTableOfContents"; +export { + EpubTableOfContentsDrawer, + EpubTableOfContentsTrigger, +} from "./EpubTableOfContents"; export * from "./hooks"; +export { MobileReaderBottomBar } from "./MobileReaderBottomBar"; export { getSlideDirection, PageTransitionWrapper, @@ -16,6 +20,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/BehindByBadge.tsx b/web/src/components/series/BehindByBadge.tsx index ac8cff86..dac3219e 100644 --- a/web/src/components/series/BehindByBadge.tsx +++ b/web/src/components/series/BehindByBadge.tsx @@ -22,9 +22,9 @@ export interface BehindByBadgeProps { * Compact "+N ch" / "+N vol" badge near the series header. Two variants: * * - `translation` (orange, actionable): `latestKnownChapter > localMaxChapter`. - * Click navigates to the Releases tab. Phase 6 (MangaUpdates) is the writer. - * - `upstream` (grey, informational): `upstreamChapterGap > 0`. Phase 5 metadata - * gap signal — not actionable, no Releases tab to send the user to. + * Click navigates to the Releases tab. The MangaUpdates plugin is the writer. + * - `upstream` (grey, informational): `upstreamChapterGap > 0`. Metadata-derived + * gap signal, not actionable; no Releases tab to send the user to. */ export function BehindByBadge({ variant, diff --git a/web/src/components/series/GenreTagChips.test.tsx b/web/src/components/series/GenreTagChips.test.tsx index 737ac937..8c2c1106 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. + 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/series/seriesCounts.test.ts b/web/src/components/series/seriesCounts.test.ts index 997d8a44..bd7ac2ef 100644 --- a/web/src/components/series/seriesCounts.test.ts +++ b/web/src/components/series/seriesCounts.test.ts @@ -102,8 +102,8 @@ describe("formatSeriesCounts", () => { ).toBe("0/0 vol"); }); - // Phase 13: localMax* fields override the file-count numerator when the - // scanner has populated structured book_metadata.volume / chapter values. + // localMax* fields override the file-count numerator when the scanner + // has populated structured book_metadata.volume / chapter values. it("uses localMaxVolume as the volume numerator when present", () => { expect( formatSeriesCounts({ diff --git a/web/src/components/series/seriesCounts.ts b/web/src/components/series/seriesCounts.ts index 6d2776b2..91f2f218 100644 --- a/web/src/components/series/seriesCounts.ts +++ b/web/src/components/series/seriesCounts.ts @@ -5,8 +5,8 @@ * (`totalVolumeCount`, `totalChapterCount`). Either total may be null/undefined * when the metadata provider didn't expose it. * - * `localMaxVolume` and `localMaxChapter` are per-series aggregates (Phase 13) - * derived from `book_metadata.volume` / `book_metadata.chapter`. When present, + * `localMaxVolume` and `localMaxChapter` are per-series aggregates derived + * from `book_metadata.volume` / `book_metadata.chapter`. When present, * the numerator switches from "files on disk" to "highest known unit number" * so a series with `v01..v14 + v15-c126` correctly displays `14/17 vol` rather * than `15/17 vol`. 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..089ac377 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,75 +1,130 @@ -: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; - min-height: 100vh; + /* `100vh` on iOS Chrome reports the *large* viewport (the screen as it + would look with the URL bar and bottom nav hidden), so a body sized + to 100vh extends past the visible area while those chrome bars are on + screen. Fullscreen surfaces like the reader use `100dvh` (the dynamic + viewport that actually matches the visible area), so the body needs + to track that too — otherwise the body is taller than the reader and + a black band shows below it. */ + min-height: 100dvh; } #root { - min-height: 100vh; + min-height: 100dvh; } -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, so they remain + immersive. */ +@media (display-mode: standalone) { + /* Grow the header to include the status-bar inset visually, AND push + Mantine's main + navbar offset variable so the page content (Home + title, Series/Book breadcrumbs, etc.) slides below the now-taller + header instead of being clipped underneath it. Mantine reads + --app-shell-header-offset to size .mantine-AppShell-main's padding-top + and .mantine-AppShell-navbar's top. */ + .mantine-AppShell-root { + --app-shell-header-offset: calc( + var(--app-shell-header-height, 64px) + + env(safe-area-inset-top, 0px) + ); + } + .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( + var(--app-shell-header-height, 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) + ); + } + + /* Mantine Drawer renders as a fixed overlay covering the full viewport + (used for the EPUB Table of Contents and the mobile search sheet). Its + internal header sits at top: 0, which puts the title under the iOS + status bar in PWA mode. Pad the inner content area down by the + status-bar inset so titles like "Table of Contents" stay readable. */ + .mantine-Drawer-content { + padding-top: env(safe-area-inset-top, 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..41553add --- /dev/null +++ b/web/src/lib/offline/db.ts @@ -0,0 +1,316 @@ +/** + * IndexedDB access layer for the offline-reading feature. + * + * 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..c1d07002 --- /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 --------------------------------------------- + +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 ------------------------------------------------- + +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..27767dff --- /dev/null +++ b/web/src/lib/offline/downloadManager.ts @@ -0,0 +1,444 @@ +/** + * Page-side download manager for the offline-reading feature. + * + * Two entry points cover every book format Codex supports: + * + * - `downloadSingleFileBook` 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` 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 Downloads page + * to surface, and removes the partial cache so the reader never sees a + * half-downloaded book. + * + * The series batch download 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 ------------------------------------------------- + +/** + * 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 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 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 }); + // 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 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 }); + // 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..573efca8 --- /dev/null +++ b/web/src/lib/offline/installNudge.ts @@ -0,0 +1,83 @@ +/** + * iOS Safari install nudge. + * + * 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..7330957f --- /dev/null +++ b/web/src/lib/offline/outbox.ts @@ -0,0 +1,216 @@ +/** + * Page-side outbox helpers for offline write operations. + * + * 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..0fce4163 --- /dev/null +++ b/web/src/lib/offline/prefetchWindow.ts @@ -0,0 +1,52 @@ +/** + * Reader prefetch-window helper. + * + * 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. Widens the prefetch + * window to 5-10 pages so the in-session experience stays responsive 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..24317b2f --- /dev/null +++ b/web/src/lib/offline/seriesDownloadQueue.ts @@ -0,0 +1,505 @@ +/** + * Series batch download queue. + * + * 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..0f782e2c 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -30,7 +30,6 @@ import { IconChevronRight, IconChevronUp, IconDotsVertical, - IconDownload, IconEdit, IconEyeOff, IconInfoCircle, @@ -60,6 +59,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, @@ -682,15 +682,12 @@ export function BookDetail() { Incognito - + - + + FILE - + {book.filePath.split("/").pop() || book.filePath} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 82f97fd3..2df85f50 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -8,8 +8,8 @@ export function Home() { useDocumentTitle("Home"); return ( - - + + Home {/* Bulk Selection Toolbar - shows when items are selected */} diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx index 1dca86c7..48bc0648 100644 --- a/web/src/pages/Reader.tsx +++ b/web/src/pages/Reader.tsx @@ -50,7 +50,7 @@ export function Reader() { if (bookLoading) { return (
@@ -61,7 +61,7 @@ export function Reader() { if (bookError || !bookDetail) { return (
{bookError instanceof Error 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..fe7f010d 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, @@ -559,7 +560,7 @@ export function SeriesDetail() { }); return ( - + {/* Breadcrumbs */} }> @@ -800,9 +801,9 @@ export function SeriesDetail() { ) : null; })()} - {/* Behind-by-N badges: translation gap (Phase 6 release sources) - and upstream gap (Phase 5 metadata signal). Each badge is a - no-op when the gap is zero/missing, the series isn't tracked, + {/* Behind-by-N badges: translation gap (release sources) + and upstream gap (metadata signal). Each badge is a no-op + when the gap is zero/missing, the series isn't tracked, or the corresponding axis is disabled. */} {tracking?.tracked && ( @@ -884,15 +885,28 @@ export function SeriesDetail() { {nextBook.readProgress ? "Continue" : "Read"} )} - + {seriesBooks && seriesBooks.length > 0 ? ( + ({ + id: b.id, + fileFormat: b.fileFormat, + pageCount: b.pageCount, + fileSize: b.fileSize, + }))} + archiveDownloadUrl={`/api/v1/series/${series.id}/download`} + /> + ) : ( + + )} ; + 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..a4738b10 --- /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"; + +/** + * 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..2382a7f4 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, @@ -33,7 +33,7 @@ import { IconSettings, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { type SettingDto, type SettingHistoryDto, @@ -41,9 +41,32 @@ 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"; +// Dotted/underscored identifiers like `auth.registration_enabled` have no +// CSS-recognised break opportunities, so narrow containers (mobile cards) +// either overflow or break mid-word ("enab\nled"). Insert after every +// `.` and `_` so the browser prefers to wrap at segment boundaries. +function formatSettingKey(key: string): React.ReactNode { + const segments = key.split(/[._]/); + const separators = key.match(/[._]/g) ?? []; + return segments.flatMap((segment, i) => { + const nodes: React.ReactNode[] = [segment]; + if (i < separators.length) { + const sep = separators[i]; + nodes.push( + + {sep} + + , + ); + } + return nodes; + }); +} + // Group settings by category function groupSettingsByCategory(settings: SettingDto[]) { const groups: Record = {}; @@ -159,7 +182,7 @@ function SettingRow({ - {setting.key} + {formatSettingKey(setting.key)} {setting.description} @@ -197,6 +220,142 @@ 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 ( + + + + + {formatSettingKey(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 +471,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 +491,10 @@ function SettingsCategorySection({ )} - - - - Setting - Value - Type - Actions - - - + {isMobile ? ( + {settings.map((setting) => ( - ))} - -
+
+ ) : ( + + + + Setting + Value + Type + Actions + + + + {settings.map((setting) => ( + + ))} + +
+ )}
); @@ -535,90 +712,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..efd6bf43 --- /dev/null +++ b/web/src/sw.ts @@ -0,0 +1,162 @@ +/// + +/** + * Codex service worker (`injectManifest` mode). + * + * Mirrors the runtime caching the prior `generateSW` config provided, + * plus a 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/types/api.generated.ts b/web/src/types/api.generated.ts index bd011cc5..0f91625e 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -11647,7 +11647,7 @@ export interface components { /** * @description Type-discriminated job config exposed over the wire. * - * Phase 9 only ships the `metadata_refresh` variant; future job types + * Currently only ships the `metadata_refresh` variant; future job types * extend the enum. */ LibraryJobConfigDto: components["schemas"]["MetadataRefreshJobConfigDto"] & { @@ -12145,7 +12145,7 @@ export interface components { maxConcurrency?: number; /** @description Plugin reference, e.g. `"plugin:mangabaka"`. */ provider: string; - /** @description Refresh scope. Phase 9 only honours `series_only` at runtime. */ + /** @description Refresh scope. Currently only `series_only` is honoured at runtime. */ scope?: components["schemas"]["RefreshScope"]; /** * Format: int32 @@ -13165,7 +13165,7 @@ export interface components { * Always `None` unless the series is tracked AND `track_chapters` is * enabled AND the provider count is populated AND the rounded-to-1- * decimal gap is positive. **This is an informational signal, not a - * release announcement** — Phase 6's MangaUpdates plugin owns the + * release announcement**; the MangaUpdates plugin owns the * translation-release feed. * @example 3 */ @@ -13174,7 +13174,7 @@ export interface components { * @description Display name of the metadata provider that supplied the upstream * counts (e.g., "MangaBaka", "AniList"). Set whenever at least one of * `upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by - * the Phase 7 badge tooltip. + * the gap badge tooltip. * @example MangaBaka */ upstreamGapProvider?: string | null; @@ -14853,7 +14853,7 @@ export interface components { /** * @description Scope of a metadata refresh job. * - * Phase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The + * Currently only [`RefreshScope::SeriesOnly`] is honoured at runtime. The * other variants are schema-accepted but rejected by the validator. * @enum {string} */ @@ -16051,7 +16051,7 @@ export interface components { * Always `None` unless the series is tracked AND `track_chapters` is * enabled AND the provider count is populated AND the rounded-to-1- * decimal gap is positive. **This is an informational signal, not a - * release announcement** — Phase 6's MangaUpdates plugin owns the + * release announcement**; the MangaUpdates plugin owns the * translation-release feed. * @example 3 */ @@ -16060,7 +16060,7 @@ export interface components { * @description Display name of the metadata provider that supplied the upstream * counts (e.g., "MangaBaka", "AniList"). Set whenever at least one of * `upstream_chapter_gap` / `upstream_volume_gap` is populated. Used by - * the Phase 7 badge tooltip. + * the gap badge tooltip. * @example MangaBaka */ upstreamGapProvider?: string | null; diff --git a/web/src/types/book-metadata.ts b/web/src/types/book-metadata.ts index 57fd796c..a6161f9a 100644 --- a/web/src/types/book-metadata.ts +++ b/web/src/types/book-metadata.ts @@ -1,9 +1,9 @@ /** - * Extended book metadata types for Phase 5 frontend improvements. + * Extended book metadata types for the frontend. * - * These types represent the expanded book metadata fields added in Phase 1. - * Once the backend DTOs are updated (Phase 6), these can be replaced with - * the generated types from the OpenAPI spec. + * These types represent the expanded book metadata fields. Once the backend + * DTOs are updated, these can be replaced with the generated types from the + * OpenAPI spec. */ /** @@ -122,14 +122,14 @@ export interface BookCover { } /** - * Extended book metadata with new Phase 1 fields. + * Extended book metadata with additional fields. * This extends the existing BookMetadataDto with additional fields. */ export interface ExtendedBookMetadata { // Fields not included in BookMetadataDto but available in BookFullMetadata isbns?: string | null; - // New Phase 1 fields + // Extended metadata fields bookType?: BookType | null; subtitle?: string | null; authorsJson?: string | null; 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..38254325 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,11 +1,39 @@ 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, + // Use injectManifest (not generateSW) so the SW can own a custom route + // for per-book offline caches and the downloads broadcast bus. App-shell + // precache and the runtime caching rules live inside src/sw.ts. + 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"),