From d9b0c59747fc1b54768ee31af3504c97af5137da Mon Sep 17 00:00:00 2001 From: Hunter Read <21973361+hunter-read@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:33:58 -0700 Subject: [PATCH 1/8] Enhancement: Expand metadata for books and systems, and add filters/sorting --- README.md | 39 +- backend/config.py | 2 + backend/indexer/__init__.py | 3 + backend/indexer/categories.py | 32 + backend/indexer/constants.py | 11 + backend/indexer/scan.py | 38 +- backend/main.py | 4 + .../versions/0004_expand_metadata.py | 256 ++++++ .../migrations/versions/0005_saved_filters.py | 52 ++ .../versions/0006_parent_system_licenses.py | 137 ++++ .../versions/0007_system_folder_cover.py | 41 + backend/models/__init__.py | 19 +- backend/models/library.py | 126 +++ backend/models/lookup_defaults.py | 117 +++ backend/models/users.py | 24 + backend/routers/books/_schemas.py | 49 +- backend/routers/books/core.py | 10 + backend/routers/library/core.py | 1 + backend/routers/lookups/__init__.py | 113 +++ backend/routers/lookups/_helpers.py | 127 +++ backend/routers/lookups/_schemas.py | 66 ++ backend/routers/lookups/core.py | 308 +++++++ backend/routers/saved_filters/__init__.py | 29 + backend/routers/saved_filters/_schemas.py | 45 ++ backend/routers/saved_filters/core.py | 127 +++ backend/routers/systems/_schemas.py | 34 + backend/routers/systems/_serializers.py | 77 ++ backend/routers/systems/core.py | 186 +++-- backend/tests/test_db_migrations.py | 77 ++ backend/tests/test_indexer_category.py | 89 ++- backend/tests/test_indexer_sort_prefix.py | 99 +++ backend/tests/test_lookups.py | 251 ++++++ backend/tests/test_saved_filters.py | 146 ++++ backend/tests/test_systems_metadata.py | 253 ++++++ docs/api.md | 58 +- docs/data-model.md | 22 +- frontend/src/components/BulkEditModal.jsx | 84 +- .../src/components/BulkEditModal.test.jsx | 30 +- frontend/src/components/BulkToggleButton.jsx | 22 + .../src/components/BulkToggleButton.test.jsx | 31 + .../src/components/CollapseExpandButtons.jsx | 29 + .../components/CollapseExpandButtons.test.jsx | 35 + frontend/src/components/IconBtn.test.jsx | 30 + frontend/src/components/TagSection.test.jsx | 39 + frontend/src/components/ToggleSwitch.jsx | 74 ++ frontend/src/components/ToggleSwitch.test.jsx | 48 ++ frontend/src/components/ToolbarButton.jsx | 51 ++ .../src/components/ToolbarButton.test.jsx | 29 + .../campaigns/CampaignRoleBadge.test.jsx | 10 + .../src/components/library/AgnosticChip.jsx | 5 +- .../components/library/AgnosticChip.test.jsx | 52 ++ .../src/components/library/FavToggle.test.jsx | 24 + .../src/components/library/FilterModal.jsx | 314 ++++++++ .../components/library/FilterModal.test.jsx | 107 +++ .../src/components/library/SearchInput.jsx | 61 ++ .../components/library/SearchInput.test.jsx | 38 + .../src/components/library/SortFilterBar.jsx | 295 +++++++ .../components/library/SortFilterBar.test.jsx | 160 ++++ .../src/components/library/SystemCard.jsx | 11 + .../components/library/applyBookSortFilter.js | 43 + .../library/applyBookSortFilter.test.js | 79 ++ .../library/applySystemSortFilter.js | 56 ++ .../library/applySystemSortFilter.test.js | 92 +++ .../components/maps/InlineTagEditor.test.jsx | 106 +++ .../src/components/media/GalleryLayout.jsx | 158 ++-- .../components/media/GalleryLayout.test.jsx | 76 +- .../src/components/media/GalleryToolbar.jsx | 161 +--- .../components/media/GalleryToolbar.test.jsx | 82 ++ .../components/media/TagFilterBar.test.jsx | 60 ++ frontend/src/components/media/mediaConfig.js | 3 + .../components/metadata/CategoryPicker.jsx | 143 ++++ .../metadata/CategoryPicker.test.jsx | 100 +++ .../metadata/DiceMaterialsPicker.jsx | 263 ++++++ .../metadata/DiceMaterialsPicker.test.jsx | 130 +++ .../src/components/metadata/GenrePicker.jsx | 228 ++++++ .../components/metadata/GenrePicker.test.jsx | 117 +++ .../components/metadata/LinkListEditor.jsx | 81 ++ .../metadata/LinkListEditor.test.jsx | 49 ++ .../components/metadata/LookupCombobox.jsx | 25 + .../metadata/LookupCombobox.test.jsx | 32 + .../metadata/MultiSelectDropdown.jsx | 186 +++++ .../metadata/MultiSelectDropdown.test.jsx | 96 +++ .../metadata/SingleSelectCombobox.jsx | 202 +++++ .../metadata/SingleSelectCombobox.test.jsx | 126 +++ .../src/components/metadata/TagChipInput.jsx | 106 +++ .../components/metadata/TagChipInput.test.jsx | 51 ++ .../src/components/metadata/diceMaterials.js | 91 +++ .../components/metadata/diceMaterials.test.js | 81 ++ .../src/components/metadata/metadataUtils.js | 37 + .../components/metadata/metadataUtils.test.js | 58 ++ .../src/components/metadata/useLookups.js | 45 ++ .../components/metadata/useLookups.test.js | 51 ++ .../components/reader/BookmarkDialog.test.jsx | 77 ++ .../components/reader/SelectionPopup.test.jsx | 21 + .../settings/CollapsibleSection.jsx | 71 ++ .../settings/CollapsibleSection.test.jsx | 60 ++ .../settings/DeleteAccountSection.test.jsx | 48 ++ .../settings/DiceMaterialManagerSection.jsx | 246 ++++++ .../DiceMaterialManagerSection.test.jsx | 53 ++ .../settings/DisplayNameSection.test.jsx | 46 ++ .../components/settings/EmailSection.test.jsx | 48 ++ .../settings/ExplicitContentSection.test.jsx | 42 + .../settings/GenreManagerSection.jsx | 217 +++++ .../settings/GenreManagerSection.test.jsx | 67 ++ .../components/settings/LevelBadge.test.jsx | 22 + .../settings/LicenseManagerSection.jsx | 17 + .../settings/LicenseManagerSection.test.jsx | 20 + .../src/components/settings/LogRow.test.jsx | 31 + .../src/components/settings/MetadataTab.jsx | 55 ++ .../components/settings/MetadataTab.test.jsx | 32 + .../components/settings/OPDSSection.test.jsx | 84 ++ .../settings/ParentSystemManagerSection.jsx | 17 + .../ParentSystemManagerSection.test.jsx | 21 + .../settings/ReaderSection.test.jsx | 52 ++ .../settings/SectionDivider.test.jsx | 10 + .../settings/SimpleLookupManager.jsx | 211 +++++ .../settings/SimpleLookupManager.test.jsx | 69 ++ .../settings/SystemFamilyManagerSection.jsx | 18 + .../SystemFamilyManagerSection.test.jsx | 55 ++ .../settings/ToolbarButton.test.jsx | 26 + .../components/system/BookBulkEditFields.jsx | 275 +++++++ .../system/BookBulkEditFields.test.jsx | 126 +++ frontend/src/components/system/BookEditor.jsx | 248 +++--- .../src/components/system/BookEditor.test.jsx | 92 +-- .../src/components/system/BookFolderGroup.jsx | 6 + frontend/src/components/system/BookRow.jsx | 20 + .../components/system/CategoryBookItem.jsx | 2 + .../components/system/CategoryGroupToggle.jsx | 26 + .../system/CategoryGroupToggle.test.jsx | 30 + .../system/SystemBulkEditFields.jsx | 150 +++- .../system/SystemBulkEditFields.test.jsx | 42 +- .../system/SystemCategorySection.jsx | 35 +- .../system/SystemCategorySection.test.jsx | 39 + .../src/components/system/SystemEditor.jsx | 185 ++++- .../components/system/SystemEditor.test.jsx | 20 +- frontend/src/hooks/useMediaGallery.js | 109 ++- frontend/src/hooks/useMediaGallery.test.js | 176 ++++ frontend/src/hooks/useSavedFilters.js | 70 ++ frontend/src/hooks/useSavedFilters.test.js | 82 ++ frontend/src/locales/de-DE.json | 161 +++- frontend/src/locales/en-CA.json | 161 +++- frontend/src/locales/en-US.json | 161 +++- frontend/src/locales/es-ES.json | 161 +++- frontend/src/locales/es-MX.json | 161 +++- frontend/src/locales/fr-CA.json | 161 +++- frontend/src/locales/fr-FR.json | 161 +++- frontend/src/locales/nl-NL.json | 161 +++- frontend/src/locales/pt-BR.json | 161 +++- frontend/src/locales/pt-PT.json | 161 +++- frontend/src/utils.js | 10 +- frontend/src/utils.test.js | 20 +- frontend/src/utils/acronyms.js | 42 + frontend/src/utils/acronyms.test.js | 36 + frontend/src/utils/parentSystemLabel.js | 10 + frontend/src/utils/parentSystemLabel.test.js | 29 + frontend/src/utils/systemDisplayName.js | 23 + frontend/src/utils/systemDisplayName.test.js | 42 + frontend/src/views/AudioView.test.jsx | 16 +- frontend/src/views/LibraryView.jsx | 755 +++++++++++------- frontend/src/views/LibraryView.test.jsx | 127 ++- frontend/src/views/MapsView.test.jsx | 33 +- frontend/src/views/SettingsView.jsx | 3 + frontend/src/views/SettingsView.test.jsx | 46 ++ frontend/src/views/SystemDetailView.jsx | 613 +++++++------- frontend/src/views/SystemDetailView.test.jsx | 203 ++++- frontend/src/views/TokensView.test.jsx | 33 +- 166 files changed, 13696 insertions(+), 1285 deletions(-) create mode 100644 backend/migrations/versions/0004_expand_metadata.py create mode 100644 backend/migrations/versions/0005_saved_filters.py create mode 100644 backend/migrations/versions/0006_parent_system_licenses.py create mode 100644 backend/migrations/versions/0007_system_folder_cover.py create mode 100644 backend/models/lookup_defaults.py create mode 100644 backend/routers/lookups/__init__.py create mode 100644 backend/routers/lookups/_helpers.py create mode 100644 backend/routers/lookups/_schemas.py create mode 100644 backend/routers/lookups/core.py create mode 100644 backend/routers/saved_filters/__init__.py create mode 100644 backend/routers/saved_filters/_schemas.py create mode 100644 backend/routers/saved_filters/core.py create mode 100644 backend/routers/systems/_serializers.py create mode 100644 backend/tests/test_indexer_sort_prefix.py create mode 100644 backend/tests/test_lookups.py create mode 100644 backend/tests/test_saved_filters.py create mode 100644 backend/tests/test_systems_metadata.py create mode 100644 frontend/src/components/BulkToggleButton.jsx create mode 100644 frontend/src/components/BulkToggleButton.test.jsx create mode 100644 frontend/src/components/CollapseExpandButtons.jsx create mode 100644 frontend/src/components/CollapseExpandButtons.test.jsx create mode 100644 frontend/src/components/IconBtn.test.jsx create mode 100644 frontend/src/components/TagSection.test.jsx create mode 100644 frontend/src/components/ToggleSwitch.jsx create mode 100644 frontend/src/components/ToggleSwitch.test.jsx create mode 100644 frontend/src/components/ToolbarButton.jsx create mode 100644 frontend/src/components/ToolbarButton.test.jsx create mode 100644 frontend/src/components/campaigns/CampaignRoleBadge.test.jsx create mode 100644 frontend/src/components/library/AgnosticChip.test.jsx create mode 100644 frontend/src/components/library/FavToggle.test.jsx create mode 100644 frontend/src/components/library/FilterModal.jsx create mode 100644 frontend/src/components/library/FilterModal.test.jsx create mode 100644 frontend/src/components/library/SearchInput.jsx create mode 100644 frontend/src/components/library/SearchInput.test.jsx create mode 100644 frontend/src/components/library/SortFilterBar.jsx create mode 100644 frontend/src/components/library/SortFilterBar.test.jsx create mode 100644 frontend/src/components/library/applyBookSortFilter.js create mode 100644 frontend/src/components/library/applyBookSortFilter.test.js create mode 100644 frontend/src/components/library/applySystemSortFilter.js create mode 100644 frontend/src/components/library/applySystemSortFilter.test.js create mode 100644 frontend/src/components/maps/InlineTagEditor.test.jsx create mode 100644 frontend/src/components/media/GalleryToolbar.test.jsx create mode 100644 frontend/src/components/media/TagFilterBar.test.jsx create mode 100644 frontend/src/components/metadata/CategoryPicker.jsx create mode 100644 frontend/src/components/metadata/CategoryPicker.test.jsx create mode 100644 frontend/src/components/metadata/DiceMaterialsPicker.jsx create mode 100644 frontend/src/components/metadata/DiceMaterialsPicker.test.jsx create mode 100644 frontend/src/components/metadata/GenrePicker.jsx create mode 100644 frontend/src/components/metadata/GenrePicker.test.jsx create mode 100644 frontend/src/components/metadata/LinkListEditor.jsx create mode 100644 frontend/src/components/metadata/LinkListEditor.test.jsx create mode 100644 frontend/src/components/metadata/LookupCombobox.jsx create mode 100644 frontend/src/components/metadata/LookupCombobox.test.jsx create mode 100644 frontend/src/components/metadata/MultiSelectDropdown.jsx create mode 100644 frontend/src/components/metadata/MultiSelectDropdown.test.jsx create mode 100644 frontend/src/components/metadata/SingleSelectCombobox.jsx create mode 100644 frontend/src/components/metadata/SingleSelectCombobox.test.jsx create mode 100644 frontend/src/components/metadata/TagChipInput.jsx create mode 100644 frontend/src/components/metadata/TagChipInput.test.jsx create mode 100644 frontend/src/components/metadata/diceMaterials.js create mode 100644 frontend/src/components/metadata/diceMaterials.test.js create mode 100644 frontend/src/components/metadata/metadataUtils.js create mode 100644 frontend/src/components/metadata/metadataUtils.test.js create mode 100644 frontend/src/components/metadata/useLookups.js create mode 100644 frontend/src/components/metadata/useLookups.test.js create mode 100644 frontend/src/components/reader/BookmarkDialog.test.jsx create mode 100644 frontend/src/components/reader/SelectionPopup.test.jsx create mode 100644 frontend/src/components/settings/CollapsibleSection.jsx create mode 100644 frontend/src/components/settings/CollapsibleSection.test.jsx create mode 100644 frontend/src/components/settings/DeleteAccountSection.test.jsx create mode 100644 frontend/src/components/settings/DiceMaterialManagerSection.jsx create mode 100644 frontend/src/components/settings/DiceMaterialManagerSection.test.jsx create mode 100644 frontend/src/components/settings/DisplayNameSection.test.jsx create mode 100644 frontend/src/components/settings/EmailSection.test.jsx create mode 100644 frontend/src/components/settings/ExplicitContentSection.test.jsx create mode 100644 frontend/src/components/settings/GenreManagerSection.jsx create mode 100644 frontend/src/components/settings/GenreManagerSection.test.jsx create mode 100644 frontend/src/components/settings/LevelBadge.test.jsx create mode 100644 frontend/src/components/settings/LicenseManagerSection.jsx create mode 100644 frontend/src/components/settings/LicenseManagerSection.test.jsx create mode 100644 frontend/src/components/settings/LogRow.test.jsx create mode 100644 frontend/src/components/settings/MetadataTab.jsx create mode 100644 frontend/src/components/settings/MetadataTab.test.jsx create mode 100644 frontend/src/components/settings/OPDSSection.test.jsx create mode 100644 frontend/src/components/settings/ParentSystemManagerSection.jsx create mode 100644 frontend/src/components/settings/ParentSystemManagerSection.test.jsx create mode 100644 frontend/src/components/settings/ReaderSection.test.jsx create mode 100644 frontend/src/components/settings/SectionDivider.test.jsx create mode 100644 frontend/src/components/settings/SimpleLookupManager.jsx create mode 100644 frontend/src/components/settings/SimpleLookupManager.test.jsx create mode 100644 frontend/src/components/settings/SystemFamilyManagerSection.jsx create mode 100644 frontend/src/components/settings/SystemFamilyManagerSection.test.jsx create mode 100644 frontend/src/components/settings/ToolbarButton.test.jsx create mode 100644 frontend/src/components/system/BookBulkEditFields.jsx create mode 100644 frontend/src/components/system/BookBulkEditFields.test.jsx create mode 100644 frontend/src/components/system/CategoryGroupToggle.jsx create mode 100644 frontend/src/components/system/CategoryGroupToggle.test.jsx create mode 100644 frontend/src/hooks/useMediaGallery.test.js create mode 100644 frontend/src/hooks/useSavedFilters.js create mode 100644 frontend/src/hooks/useSavedFilters.test.js create mode 100644 frontend/src/utils/acronyms.js create mode 100644 frontend/src/utils/acronyms.test.js create mode 100644 frontend/src/utils/parentSystemLabel.js create mode 100644 frontend/src/utils/parentSystemLabel.test.js create mode 100644 frontend/src/utils/systemDisplayName.js create mode 100644 frontend/src/utils/systemDisplayName.test.js create mode 100644 frontend/src/views/SettingsView.test.jsx diff --git a/README.md b/README.md index f187330..636351d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ A Docker-based web application for managing your tabletop RPG PDF collection. Br - **Bookmarks** - Per-user page and text-selection bookmarks with inline highlights - **Favorites** - Save systems, books, maps, tokens, and audio for quick access - **View Modes** - Toggle the systems, books, maps, tokens, and audio grids between card, compact, and list layouts; each content type remembers its own default (configurable in Account Settings) while the in-page toggle is a per-tab override. Cards and list rows include quick download and favorite buttons. -- **Metadata Editor** - Add descriptions, tags, genre, publisher links, and character builder URLs +- **Metadata Editor** - Rich metadata for systems (multiple genres, dice/materials, system family, parent system + edition, license, year, and multiple generic + character-builder links) and books (authors, artists, genres, ISBN, version, language, a per-book license override, a variable-precision publication date, and multiple links). Genres, system families, parent systems, licenses, and dice/materials are drawn from curated lists you manage in **Settings → Metadata** (each section collapsible; defaults plus your own custom values). A *parent system* groups related systems (e.g. D&D 5e and AD&D under "Dungeons & Dragons"), and an *edition* string combines with it for display ("Cyberpunk" + "Red" → "Cyberpunk Red") +- **Sort & Filter** - Sort systems by name, book count, total page count, or year, and books by title, page count, or year. A shared filter modal covers genre, system family, parent system, edition, dice/materials, tags, favourites, and explicit content. Named filter presets are saved to your account (server-side, so they follow you across devices), and one preset per view can be set as the default you land on - **Bulk Actions** - Multi-select books, maps, tokens, and audio (click, shift-click for a range, ⌘/Ctrl-click to toggle) then bulk tag, add to a campaign, or edit metadata via a carousel - **Campaigns** - Track GM-run and personal campaigns; a markdown notes wiki with deep linking, Markdown/JSON/LegendKeeper import & export, character art and sheets, linked resources, and scheduling - **OPDS Catalog** - Each user can generate a personal OPDS feed URL to connect e-reader apps directly to their library @@ -235,19 +236,22 @@ Archive files placed anywhere under `books/` are shown alongside your books in t Archives are treated as opaque downloads - Grimoire does not extract or read their contents, so clicking one downloads the file rather than opening the reader. They're also included when you download a whole system, category, or subfolder as an archive. Comic-book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) additionally get a cover thumbnail generated from the first image inside them. -#### System-agnostic collections +#### Special collections (system-agnostic & one-page) -Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. Create a folder whose name is one of the recognized system-agnostic names and Grimoire will display its contents in a separate **System-Agnostic** section on the library page, outside the normal game-system grid. +Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. And some "systems" are really a bucket of many tiny games: one-page and small RPGs. Create a folder whose name is one of the recognized names below and Grimoire will display its contents in a separate **Special Collections** section on the library page, outside the normal game-system grid. **Recognized folder names** (case-insensitive): -| Folder name | Example | -|---|---| -| `System Agnostic` | `books/System Agnostic/` | -| `Generic` | `books/Generic/` | -| `Any` | `books/Any/` | +| Folder name | Collection | Example | +|---|---|---| +| `System Agnostic` | System-agnostic | `books/System Agnostic/` | +| `Generic` | System-agnostic | `books/Generic/` | +| `Any` | System-agnostic | `books/Any/` | +| `One-Page RPGs` | One-page / small RPGs | `books/One-Page RPGs/` | +| `Single-Page RPGs` | One-page / small RPGs | `books/Single-Page RPGs/` | +| `One-Shot RPGs` | One-page / small RPGs | `books/One-Shot RPGs/` | -Subfolders directly under the agnostic root become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified). +Subfolders directly under one of these roots become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified). ``` books/ @@ -276,6 +280,23 @@ books/ Users with explicit content disabled will not see this system or its books. +#### Sort-order prefixes + +To pull a system to the top of an alphabetically-sorted file browser, you can +prefix its folder name with `!`, `$`, or `%`. Grimoire strips a leading run of +those characters when deriving the system name (only the leading run — internal +occurrences are kept): + +``` +books/ +├── !!Dungeons & Dragons/ → "Dungeons & Dragons" +├── !system-agnostic/ → still the System-Agnostic collection +└── $%Pathfinder 2e/ → "Pathfinder 2e" +``` + +The prefix stacks with `(nsfw)`, so `!!Forbidden Lore (NSFW)` becomes the +explicit system "Forbidden Lore". + ### Book metadata from OPF files Grimoire reads [OPF](https://idpf.org/epub/20/spec/OPF_2.0.1_draft.htm) sidecar files to populate book metadata automatically on first scan. OPF files are the format used by [Calibre](https://calibre-ebook.com/) and many other library managers. diff --git a/backend/config.py b/backend/config.py index d3127a5..0514092 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,6 +24,7 @@ THUMB_DIR = os.path.join(DATA_PATH, "thumbnails") PAGE_CACHE_DIR = os.path.join(DATA_PATH, "page_cache") CAMPAIGN_UPLOAD_DIR = os.path.join(DATA_PATH, "campaign_uploads") +SYSTEM_COVER_DIR = os.path.join(DATA_PATH, "system_covers") VALKEY_URL = os.environ.get("VALKEY_URL", "") # OCR: image-only PDFs (scanned pages with no embedded text layer) can be run @@ -307,6 +308,7 @@ def clear(self) -> None: os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "art"), exist_ok=True) os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "sheets"), exist_ok=True) os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "files"), exist_ok=True) +os.makedirs(SYSTEM_COVER_DIR, exist_ok=True) engine, SessionLocal = init_db(DB_PATH) diff --git a/backend/indexer/__init__.py b/backend/indexer/__init__.py index 4edee9b..9bdb5db 100644 --- a/backend/indexer/__init__.py +++ b/backend/indexer/__init__.py @@ -46,8 +46,11 @@ agnostic_category, folder_category_inference_disabled, guess_category, + is_one_page_folder, + is_special_collection_folder, is_system_agnostic_folder, slugify, + strip_sort_prefix, ) # --- Archive + thumbnail helpers ----------------------------------------------- diff --git a/backend/indexer/categories.py b/backend/indexer/categories.py index 12ec74b..5938701 100644 --- a/backend/indexer/categories.py +++ b/backend/indexer/categories.py @@ -10,12 +10,30 @@ CATEGORY_MAP, NO_AUTO_CATEGORY_MARKER, # noqa: F401 (re-exported for callers) UNCATEGORIZED, + _ONE_PAGE_SLUGS, _SYSTEM_AGNOSTIC_SLUGS, ) logger = logging.getLogger("grimoire.indexer") +# Leading characters people prepend to system folders purely to steer the +# alphabetical sort order of their file browser (e.g. "!!Dungeons & Dragons"). +# Only these three are recognized, and only as a contiguous leading run — once a +# non-special character is read, the rest is the real name. +_SORT_PREFIX_CHARS = "!$%" + + +def strip_sort_prefix(name: str) -> str: + """Strip leading sort-order prefix characters (``!$%``) from a folder name. + + Only the contiguous run of these characters at the very start is removed; + everything from the first non-prefix character onward is kept verbatim + (including internal ``!``/``$``/``%``). Surrounding whitespace is trimmed. + """ + return name.lstrip(_SORT_PREFIX_CHARS).strip() + + def slugify(name: str) -> str: """Create a URL-safe slug from a name.""" slug = name.lower().strip() @@ -30,6 +48,20 @@ def is_system_agnostic_folder(folder_name: str) -> bool: return slugify(folder_name) in _SYSTEM_AGNOSTIC_SLUGS +def is_one_page_folder(folder_name: str) -> bool: + """Return True if this top-level books folder is the one-page / small-RPG collection.""" + return slugify(folder_name) in _ONE_PAGE_SLUGS + + +def is_special_collection_folder(folder_name: str) -> bool: + """Return True for any special collection folder (agnostic or one-page). + + Both use their immediate subfolder name as the category label rather than + the normal CATEGORY_MAP inference. + """ + return is_system_agnostic_folder(folder_name) or is_one_page_folder(folder_name) + + def _normalize_folder(name: str) -> str: """Collapse hyphens, underscores, and whitespace to a single space for category matching.""" return re.sub(r"[-_\s]+", " ", name.lower()).strip() diff --git a/backend/indexer/constants.py b/backend/indexer/constants.py index e90a6e4..4aad2c7 100644 --- a/backend/indexer/constants.py +++ b/backend/indexer/constants.py @@ -67,6 +67,17 @@ } ) +# Normalized folder names treated as the "one-page / small RPG" collection — a +# special sibling of the system-agnostic collection (issue #202). Books here use +# their immediate subfolder name as the category label, exactly like agnostic. +_ONE_PAGE_SLUGS = frozenset( + { + "one-page-rpgs", + "single-page-rpgs", + "one-shot-rpgs", + } +) + IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"} PDF_EXTS = {".pdf"} DOC_EXTS = {".pdf", ".epub", ".djvu"} diff --git a/backend/indexer/scan.py b/backend/indexer/scan.py index 68ac82d..edb978d 100644 --- a/backend/indexer/scan.py +++ b/backend/indexer/scan.py @@ -35,8 +35,11 @@ agnostic_category, folder_category_inference_disabled, guess_category, + is_one_page_folder, + is_special_collection_folder, is_system_agnostic_folder, slugify, + strip_sort_prefix, ) from .constants import ( ARCHIVE_EXTS, @@ -177,6 +180,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: raw_name = system_dir.name is_nsfw = bool(re.search(r"\(nsfw\)", raw_name, re.IGNORECASE)) system_name = re.sub(r"\s*\(nsfw\)\s*", "", raw_name, flags=re.IGNORECASE).strip() + # Strip any leading sort-order prefix (!$%) people use to steer their file + # browser's alphabetical ordering — "!!Dungeons & Dragons" → "Dungeons & Dragons". + # This must happen before slug/name/special-collection derivation. + system_name = strip_sort_prefix(system_name) system_slug = slugify(system_name) logger.debug(f"DB: querying system '{system_slug}'") @@ -191,6 +198,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: stats["errors"] += 1 continue is_agnostic = is_system_agnostic_folder(system_name) + is_one_page = is_one_page_folder(system_name) + # Both special collections (agnostic + one-page) use immediate-subfolder + # names as category labels rather than CATEGORY_MAP inference. + is_special = is_special_collection_folder(system_name) # Per-system opt-out: a marker file at the system root disables # folder-name category inference for just this system. system_category_off = category_inference_off or ( @@ -202,6 +213,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: slug=system_slug, is_explicit=is_nsfw, is_system_agnostic=is_agnostic, + is_one_page=is_one_page, ) session.add(system) logger.debug(f"DB: flushing new system '{system_name}'") @@ -220,6 +232,16 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: system.is_explicit = True if is_agnostic and not system.is_system_agnostic: system.is_system_agnostic = True + if is_one_page and not system.is_one_page: + system.is_one_page = True + + # Folder cover convention: a cover.*/folder.* image at the system root + # becomes the system's cover (precedence: folder > uploaded > book cover). + # Stored library-relative so it survives moves of the whole library dir. + artwork = _find_folder_artwork(str(system_dir)) + new_folder_cover = os.path.relpath(artwork, ctx.library_path) if artwork else "" + if (system.folder_cover_path or "") != new_folder_cover: + system.folder_cover_path = new_folder_cover # When scoped to a path deeper than the system dir, walk only that # subtree; otherwise walk the whole system. @@ -229,7 +251,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: else system_dir ) stop = _scan_books_in_system( - ctx, system, system_name, system_category_off, is_agnostic, walk_root + ctx, system, system_name, system_category_off, is_special, walk_root ) if stop: return @@ -240,10 +262,14 @@ def _scan_books_in_system( system: GameSystem, system_name: str, system_category_off: bool, - is_agnostic: bool, + is_special_collection: bool, walk_root: Path, ) -> bool: - """Walk one system's tree and register its books. Returns True if stop requested.""" + """Walk one system's tree and register its books. Returns True if stop requested. + + ``is_special_collection`` is True for the system-agnostic and one-page + collections, which label categories by immediate subfolder name. + """ session = ctx.session ignore = ctx.ignore stats = ctx.stats @@ -309,7 +335,7 @@ def _scan_books_in_system( system, system_name, system_category_off, - is_agnostic, + is_special_collection, root, filename, filepath, @@ -334,7 +360,7 @@ def _register_book( system: GameSystem, system_name: str, system_category_off: bool, - is_agnostic: bool, + is_special_collection: bool, root: str, filename: str, filepath: str, @@ -384,7 +410,7 @@ def _register_book( if system_category_off: category = UNCATEGORIZED - elif is_agnostic: + elif is_special_collection: category = agnostic_category(relative_path) else: category = guess_category(relative_path) diff --git a/backend/main.py b/backend/main.py index 0f97bbc..65e8a12 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,10 +34,12 @@ favorites as favorites_router, library as library_router, logs as logs_router, + lookups as lookups_router, maintenance as maintenance_router, maps as maps_router, oidc as oidc_router, opds as opds_router, + saved_filters as saved_filters_router, search as search_router, settings as settings_router, systems as systems_router, @@ -228,6 +230,7 @@ def health(): api.include_router(users_router.router) api.include_router(systems_router.router) api.include_router(books_router.router) +api.include_router(lookups_router.router) api.include_router(maps_router.router) api.include_router(tokens_router.router) api.include_router(audio_router.router) @@ -235,6 +238,7 @@ def health(): api.include_router(search_router.router) api.include_router(campaigns_router.router) api.include_router(favorites_router.router) +api.include_router(saved_filters_router.router) api.include_router(bookmarks_router.router) api.include_router(downloads_router.router) api.include_router(export_router.router) diff --git a/backend/migrations/versions/0004_expand_metadata.py b/backend/migrations/versions/0004_expand_metadata.py new file mode 100644 index 0000000..7401324 --- /dev/null +++ b/backend/migrations/versions/0004_expand_metadata.py @@ -0,0 +1,256 @@ +"""expand system & book metadata; genre/system-family lookups (issue #202) + +Additive schema changes only (SQLite-safe — no column drops): + +game_systems: genres, dice_materials, system_family, license, year, urls, + character_builder_urls, is_one_page +books: artists, genres, isbn, version, language, month, day, urls + +New lookup tables ``genres`` (tiered via parent_id) and ``system_families``, +seeded with defaults. Backfills the new multi-value columns from the legacy +single-value ones (game_systems.genre / .character_builder_url, +books.publisher_url). + +Revision ID: 6be3e9a796c4 +Revises: b2e5d3f0c8a1 +Create Date: 2026-07-25 00:00:00.000000+00:00 + +""" +import json +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +from backend.models.lookup_defaults import DEFAULT_GENRES, DEFAULT_SYSTEM_FAMILIES + + +# revision identifiers, used by Alembic. +revision: str = "6be3e9a796c4" +down_revision: Union[str, None] = "b2e5d3f0c8a1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def _add(table: str, column: sa.Column) -> None: + """Add a column only if it isn't already present (idempotent retries).""" + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS _alembic_tmp_books") + op.execute("DROP TABLE IF EXISTS _alembic_tmp_game_systems") + + # --- game_systems columns --- + _add("game_systems", sa.Column("genres", sa.JSON(), nullable=True)) + _add("game_systems", sa.Column("dice_materials", sa.JSON(), nullable=True)) + _add( + "game_systems", + sa.Column("system_family", sa.String(length=150), nullable=True, server_default=""), + ) + _add( + "game_systems", + sa.Column("license", sa.String(length=100), nullable=True, server_default=""), + ) + _add("game_systems", sa.Column("year", sa.Integer(), nullable=True)) + _add("game_systems", sa.Column("urls", sa.JSON(), nullable=True)) + _add("game_systems", sa.Column("character_builder_urls", sa.JSON(), nullable=True)) + _add( + "game_systems", + sa.Column("is_one_page", sa.Boolean(), nullable=True, server_default=sa.text("0")), + ) + + # --- books columns --- + _add("books", sa.Column("artists", sa.JSON(), nullable=True)) + _add("books", sa.Column("genres", sa.JSON(), nullable=True)) + _add("books", sa.Column("isbn", sa.String(length=20), nullable=True, server_default="")) + _add("books", sa.Column("version", sa.String(length=50), nullable=True, server_default="")) + _add("books", sa.Column("language", sa.String(length=20), nullable=True, server_default="")) + _add("books", sa.Column("month", sa.Integer(), nullable=True)) + _add("books", sa.Column("day", sa.Integer(), nullable=True)) + _add("books", sa.Column("urls", sa.JSON(), nullable=True)) + + # --- lookup tables --- + tables = _tables() + if "genres" not in tables: + op.create_table( + "genres", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("parent_id", sa.String(length=36), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["parent_id"], ["genres.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_genres_parent_id", "genres", ["parent_id"]) + if "system_families" not in tables: + op.create_table( + "system_families", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=150), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + _seed_lookups() + _backfill() + + +def _seed_lookups() -> None: + """Insert default genres/families. Skips names that already exist.""" + bind = op.get_bind() + + existing_genres = { + row[0] for row in bind.execute(sa.text("SELECT name FROM genres")).fetchall() + } + order = 0 + for name, children in DEFAULT_GENRES: + order += 1 + parent_id = _ensure_genre(bind, existing_genres, name, None, order) + child_order = 0 + for child_name, _grandchildren in children: + child_order += 1 + _ensure_genre(bind, existing_genres, child_name, parent_id, child_order) + + existing_families = { + row[0] + for row in bind.execute(sa.text("SELECT name FROM system_families")).fetchall() + } + for idx, fam in enumerate(DEFAULT_SYSTEM_FAMILIES): + if fam in existing_families: + continue + bind.execute( + sa.text( + "INSERT INTO system_families (id, name, is_default, sort_order) " + "VALUES (:id, :name, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": fam, "sort_order": idx}, + ) + + +def _ensure_genre(bind, existing: set, name: str, parent_id, sort_order: int) -> str: + """Insert a genre if absent; return its id either way.""" + if name in existing: + row = bind.execute( + sa.text("SELECT id FROM genres WHERE name = :name"), {"name": name} + ).fetchone() + return row[0] + new_id = str(uuid.uuid4()) + bind.execute( + sa.text( + "INSERT INTO genres (id, name, parent_id, is_default, sort_order) " + "VALUES (:id, :name, :parent_id, 1, :sort_order)" + ), + {"id": new_id, "name": name, "parent_id": parent_id, "sort_order": sort_order}, + ) + existing.add(name) + return new_id + + +def _backfill() -> None: + """Populate new multi-value columns from the legacy single-value ones.""" + bind = op.get_bind() + + # game_systems.genre -> genres; character_builder_url -> character_builder_urls + rows = bind.execute( + sa.text( + "SELECT id, genre, character_builder_url, genres, " + "character_builder_urls, urls FROM game_systems" + ) + ).fetchall() + for gid, genre, cb_url, genres, cb_urls, urls in rows: + updates = {} + if _empty(genres) and genre: + updates["genres"] = json.dumps([genre]) + if _empty(cb_urls) and cb_url: + updates["character_builder_urls"] = json.dumps( + [{"label": "", "url": cb_url}] + ) + if _empty(genres) and _empty(urls): + updates.setdefault("urls", json.dumps([])) + # Ensure JSON list columns are never left NULL. + _default_json(updates, "genres", genres) + _default_json(updates, "dice_materials", None) + _default_json(updates, "urls", urls) + _default_json(updates, "character_builder_urls", cb_urls) + if updates: + _apply_update(bind, "game_systems", gid, updates) + + # books.publisher_url -> urls + rows = bind.execute( + sa.text("SELECT id, publisher_url, urls, genres, artists FROM books") + ).fetchall() + for bid, pub_url, urls, genres, artists in rows: + updates = {} + if _empty(urls) and pub_url: + updates["urls"] = json.dumps([{"label": "Publisher", "url": pub_url}]) + _default_json(updates, "urls", urls) + _default_json(updates, "genres", genres) + _default_json(updates, "artists", artists) + if updates: + _apply_update(bind, "books", bid, updates) + + +def _empty(raw) -> bool: + if raw is None: + return True + try: + val = json.loads(raw) if isinstance(raw, str) else raw + except (ValueError, TypeError): + return True + return not val + + +def _default_json(updates: dict, key: str, current) -> None: + """Ensure a JSON list column gets an empty-list default when NULL.""" + if key not in updates and current is None: + updates[key] = json.dumps([]) + + +def _apply_update(bind, table: str, row_id: str, updates: dict) -> None: + set_clause = ", ".join(f"{k} = :{k}" for k in updates) + params = dict(updates) + params["row_id"] = row_id + bind.execute( + sa.text(f"UPDATE {table} SET {set_clause} WHERE id = :row_id"), params + ) + + +def downgrade() -> None: + tables = _tables() + if "system_families" in tables: + op.drop_table("system_families") + if "genres" in tables: + op.drop_index("ix_genres_parent_id", table_name="genres") + op.drop_table("genres") + + for col in ("artists", "genres", "isbn", "version", "language", "month", "day", "urls"): + if col in _columns("books"): + op.drop_column("books", col) + for col in ( + "genres", + "dice_materials", + "system_family", + "license", + "year", + "urls", + "character_builder_urls", + "is_one_page", + ): + if col in _columns("game_systems"): + op.drop_column("game_systems", col) diff --git a/backend/migrations/versions/0005_saved_filters.py b/backend/migrations/versions/0005_saved_filters.py new file mode 100644 index 0000000..019a220 --- /dev/null +++ b/backend/migrations/versions/0005_saved_filters.py @@ -0,0 +1,52 @@ +"""saved_filters: per-user named sort/filter presets with a per-scope default + +Adds the ``saved_filters`` table backing server-side saved filters for the +library scopes (systems/books/maps/tokens/audio). One preset per (user, scope) +may be the default the user lands on. + +Revision ID: 96927e7cb35e +Revises: 6be3e9a796c4 +Create Date: 2026-07-25 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = "96927e7cb35e" +down_revision: Union[str, None] = "6be3e9a796c4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + if "saved_filters" not in _tables(): + op.create_table( + "saved_filters", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("scope", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("state", sa.JSON(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "scope", "name"), + ) + op.create_index("ix_saved_filters_user_id", "saved_filters", ["user_id"]) + + +def downgrade() -> None: + if "saved_filters" in _tables(): + op.drop_index("ix_saved_filters_user_id", table_name="saved_filters") + op.drop_table("saved_filters") diff --git a/backend/migrations/versions/0006_parent_system_licenses.py b/backend/migrations/versions/0006_parent_system_licenses.py new file mode 100644 index 0000000..8c62a67 --- /dev/null +++ b/backend/migrations/versions/0006_parent_system_licenses.py @@ -0,0 +1,137 @@ +"""parent_system/edition + license & dice/material lookups + +Adds: + * game_systems.parent_system, game_systems.edition + * books.license (per-book override of the system license) + * parent_systems, licenses, dice_materials lookup tables (seeded) + +All operations are idempotent so partial/retried runs are safe. + +Revision ID: 873d3303ba93 +Revises: 96927e7cb35e +Create Date: 2026-07-26 00:00:00.000000+00:00 + +""" +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +from backend.models.lookup_defaults import ( + DEFAULT_DICE_MATERIALS, + DEFAULT_LICENSES, + DEFAULT_PARENT_SYSTEMS, +) + + +# revision identifiers, used by Alembic. +revision: str = "873d3303ba93" +down_revision: Union[str, None] = "96927e7cb35e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def _add(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + # --- new columns --- + _add( + "game_systems", + sa.Column("parent_system", sa.String(length=150), nullable=True, server_default=""), + ) + _add( + "game_systems", + sa.Column("edition", sa.String(length=80), nullable=True, server_default=""), + ) + _add( + "books", + sa.Column("license", sa.String(length=100), nullable=True, server_default=""), + ) + + tables = _tables() + for name in ("parent_systems", "licenses"): + if name not in tables: + op.create_table( + name, + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=150), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + if "dice_materials" not in tables: + op.create_table( + "dice_materials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("group", sa.String(length=60), nullable=True, server_default="Custom"), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + _seed_lookups() + + +def _seed_named(bind, table: str, names: Sequence[str]) -> None: + """Seed a simple (id, name, is_default, sort_order) lookup, skipping dupes.""" + existing = { + row[0] for row in bind.execute(sa.text(f"SELECT name FROM {table}")).fetchall() + } + for idx, name in enumerate(names): + if name in existing: + continue + bind.execute( + sa.text( + f"INSERT INTO {table} (id, name, is_default, sort_order) " + "VALUES (:id, :name, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": name, "sort_order": idx}, + ) + + +def _seed_lookups() -> None: + bind = op.get_bind() + _seed_named(bind, "parent_systems", DEFAULT_PARENT_SYSTEMS) + _seed_named(bind, "licenses", DEFAULT_LICENSES) + + existing_dice = { + row[0] for row in bind.execute(sa.text("SELECT name FROM dice_materials")).fetchall() + } + for idx, (group, name) in enumerate(DEFAULT_DICE_MATERIALS): + if name in existing_dice: + continue + bind.execute( + sa.text( + 'INSERT INTO dice_materials (id, name, "group", is_default, sort_order) ' + "VALUES (:id, :name, :group, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": name, "group": group, "sort_order": idx}, + ) + + +def downgrade() -> None: + tables = _tables() + for name in ("dice_materials", "licenses", "parent_systems"): + if name in tables: + op.drop_table(name) + if "license" in _columns("books"): + op.drop_column("books", "license") + for col in ("parent_system", "edition"): + if col in _columns("game_systems"): + op.drop_column("game_systems", col) diff --git a/backend/migrations/versions/0007_system_folder_cover.py b/backend/migrations/versions/0007_system_folder_cover.py new file mode 100644 index 0000000..b4b50ff --- /dev/null +++ b/backend/migrations/versions/0007_system_folder_cover.py @@ -0,0 +1,41 @@ +"""system folder-cover path + +Adds game_systems.folder_cover_path — the library-relative path to a +cover.*/folder.* image found at a system's folder root by the scanner. It takes +precedence over the admin-uploaded cover_image, which beats the cover_book_id +fallback. Idempotent. + +Revision ID: 1537716d5347 +Revises: 873d3303ba93 +Create Date: 2026-07-26 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = "1537716d5347" +down_revision: Union[str, None] = "873d3303ba93" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def upgrade() -> None: + if "folder_cover_path" not in _columns("game_systems"): + op.add_column( + "game_systems", + sa.Column("folder_cover_path", sa.String(length=1000), nullable=True, server_default=""), + ) + + +def downgrade() -> None: + if "folder_cover_path" in _columns("game_systems"): + op.drop_column("game_systems", "folder_cover_path") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index c3eec7d..a7a1282 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -22,10 +22,19 @@ WikiPageShare, ) from .db import init_db -from .library import Book, BookFolder, GameSystem +from .library import ( + Book, + BookFolder, + DiceMaterial, + GameSystem, + Genre, + License, + ParentSystem, + SystemFamily, +) from .media import Audio, AudioFolder, GenericMap, MapFolder, Token, TokenFolder from .settings import AppSetting -from .users import Bookmark, Favorite, User +from .users import Bookmark, Favorite, SavedFilter, User __all__ = [ "Base", @@ -34,6 +43,11 @@ "GameSystem", "Book", "BookFolder", + "Genre", + "SystemFamily", + "ParentSystem", + "License", + "DiceMaterial", # Media "GenericMap", "MapFolder", @@ -45,6 +59,7 @@ "User", "Bookmark", "Favorite", + "SavedFilter", # Campaigns "Campaign", "CampaignMember", diff --git a/backend/models/library.py b/backend/models/library.py index b296e4d..d25e5e9 100644 --- a/backend/models/library.py +++ b/backend/models/library.py @@ -25,13 +25,40 @@ class GameSystem(Base): slug = Column(String(255), unique=True, nullable=False) description = Column(Text, default="") publishers = Column(JSON, default=list) + # Legacy single-value URL column. Kept for backward compatibility; new code + # reads/writes the multi-value ``character_builder_urls`` list instead. character_builder_url = Column(String(512), default="") + # Admin-uploaded cover: bare filename stored under DATA_PATH/system_covers/. cover_image = Column(String(512), default="") + # Library-relative path to a cover.*/folder.* image found at the system's + # folder root by the scanner. Takes precedence over the uploaded cover_image, + # which in turn beats the cover_book_id fallback. + folder_cover_path = Column(String(1000), default="") cover_book_id = Column(String(36), nullable=True) tags = Column(JSON, default=list) + # Legacy single-value genre column. Superseded by the ``genres`` JSON list; + # kept so old databases keep working and the backfill has a source. genre = Column(String(100), default="") + # Multi-value metadata (issue #202). + genres = Column(JSON, default=list) + dice_materials = Column(JSON, default=list) + system_family = Column(String(150), default="") + # Parent-system / edition hierarchy: a system_family (e.g. "d20 System") may + # contain several parent_systems (e.g. "Dungeons & Dragons"), each of which + # has editions (e.g. "5e"). ``parent_system`` + ``edition`` combine for + # display ("Cyberpunk" + "Red" → "Cyberpunk Red"). + parent_system = Column(String(150), default="") + edition = Column(String(80), default="") + license = Column(String(100), default="") + year = Column(Integer, nullable=True) + # Labeled link lists: ``[{"label": str, "url": str}, ...]``. + urls = Column(JSON, default=list) + character_builder_urls = Column(JSON, default=list) is_explicit = Column(Boolean, default=False) is_system_agnostic = Column(Boolean, default=False) + # Special "one-page / small RPG" collection, grouped with system-agnostic + # in the library view. Set by the indexer from the folder name. + is_one_page = Column(Boolean, default=False) created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) @@ -54,9 +81,27 @@ class Book(Base): category = Column(String(100), default="core", index=True) description = Column(Text, default="") authors = Column(JSON, default=list) + artists = Column(JSON, default=list) publisher = Column(String(255), default="") + # Legacy single-value URL column, superseded by the ``urls`` list. Kept for + # backward compatibility and as the backfill source. publisher_url = Column(String(512), default="") + # Labeled link list: ``[{"label": str, "url": str}, ...]``. + urls = Column(JSON, default=list) + # Genres (issue #202). Independent of the system's genres; a book may carry + # its own (e.g. a grimdark D&D book tagged Horror as well as Fantasy). + genres = Column(JSON, default=list) + isbn = Column(String(20), default="") + version = Column(String(50), default="") + language = Column(String(20), default="") + # Per-book license override. Empty means "inherit the system's license" — an + # OGL SRD can sit inside an otherwise-proprietary system (issue: metadata). + license = Column(String(100), default="") + # Publication date with variable precision. ``year`` may stand alone; + # ``month`` and/or ``day`` refine it. All nullable. year = Column(Integer, nullable=True) + month = Column(Integer, nullable=True) + day = Column(Integer, nullable=True) file_size = Column(Integer, default=0) page_count = Column(Integer, default=0) mime_type = Column(String(100), default="application/pdf") @@ -99,3 +144,84 @@ class BookFolder(Base): id = Column(String(36), primary_key=True, default=_uuid) path = Column(String(1000), nullable=False, unique=True) tags = Column(JSON, default=list) + + +class Genre(Base): + """A curated genre value, optionally nested under a parent (tiered). + + Powers the tiered genre picker (e.g. Science Fiction → Cyberpunk). Defaults + are seeded on migration; users may add their own via settings. ``is_default`` + marks a seeded row so the UI can distinguish it, but defaults are removable. + """ + + __tablename__ = "genres" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(120), unique=True, nullable=False) + parent_id = Column(String(36), ForeignKey("genres.id"), nullable=True, index=True) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + parent = relationship("Genre", remote_side=[id], back_populates="children") + children = relationship( + "Genre", back_populates="parent", cascade="all, delete-orphan" + ) + + +class SystemFamily(Base): + """A curated system-family / engine value (e.g. Powered by the Apocalypse).""" + + __tablename__ = "system_families" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(150), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class ParentSystem(Base): + """A curated parent-system value (e.g. "Dungeons & Dragons"). + + The mid tier between a broad system_family ("d20 System") and a concrete + GameSystem ("D&D 5e"). Users manage the list in settings; systems reference + it by name via ``GameSystem.parent_system``. + """ + + __tablename__ = "parent_systems" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(150), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class License(Base): + """A curated license value (e.g. OGL 1.0a, ORC, CC-BY 4.0, Proprietary). + + Applied at the system level as a default and optionally overridden per book. + Seeded with common TTRPG licenses; users may add their own. + """ + + __tablename__ = "licenses" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(100), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class DiceMaterial(Base): + """A curated dice / materials value (e.g. D20, Playing Cards, Tarot Cards). + + Backs the dice/materials picker on systems. Seeded from the built-in default + groups; users may add their own via settings. + """ + + __tablename__ = "dice_materials" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(120), unique=True, nullable=False) + # Grouping label for the picker ("Dice", "Cards", "Other", "Custom"). + group = Column(String(60), default="Custom") + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) diff --git a/backend/models/lookup_defaults.py b/backend/models/lookup_defaults.py new file mode 100644 index 0000000..2fc632d --- /dev/null +++ b/backend/models/lookup_defaults.py @@ -0,0 +1,117 @@ +"""Default seed data for the genre and system-family lookup tables (issue #202). + +Kept as plain Python data so both the Alembic migration and any future reseed +routine share one source of truth. The genre tree is loosely modeled on +DriveThruRPG's genre/subgenre taxonomy; families cover the most common design +lineages. All values are user-removable after seeding. +""" +from typing import Sequence + +# Tiered genre defaults. Each entry is ``(name, [children])`` where a child may +# itself be an ``(name, [grandchildren])`` tuple, allowing arbitrary nesting. +GenreNode = tuple[str, Sequence["GenreNode"]] + +DEFAULT_GENRES: Sequence[GenreNode] = ( + ( + "Fantasy", + ( + ("High Fantasy", ()), + ("Dark Fantasy", ()), + ("Grimdark", ()), + ("Sword & Sorcery", ()), + ("Fairy Tale", ()), + ), + ), + ( + "Science Fiction", + ( + ("Cyberpunk", ()), + ("Space Opera", ()), + ("Post-Apocalyptic", ()), + ("Hard SF", ()), + ("Mecha", ()), + ), + ), + ( + "Horror", + ( + ("Cosmic Horror", ()), + ("Survival Horror", ()), + ("Gothic", ()), + ), + ), + ( + "Historical", + ( + ("Ancient", ()), + ("Medieval", ()), + ("Renaissance", ()), + ("Modern", ()), + ), + ), + ("Mystery", ()), + ("Western", ()), + ("Superhero", ()), + ("Steampunk", ()), + ("Modern / Contemporary", ()), + ("Comedy", ()), + ("Adventure", ()), + ("Slice of Life", ()), +) + +# System-family / engine defaults. +DEFAULT_SYSTEM_FAMILIES: Sequence[str] = ( + "Powered by the Apocalypse", + "Forged in the Dark", + "d20 System", + "OSR", + "Year Zero Engine", + "Fate", + "Cypher System", + "GUMSHOE", + "Savage Worlds", + "Basic Roleplaying (BRP)", + "Storyteller / Storytelling", + "GURPS", +) + +# Parent-system defaults are intentionally empty — these are highly library +# specific (users curate their own "Dungeons & Dragons", "Cyberpunk", etc.). +DEFAULT_PARENT_SYSTEMS: Sequence[str] = () + +# Common TTRPG license defaults. User-removable / extendable. +DEFAULT_LICENSES: Sequence[str] = ( + "Proprietary / All Rights Reserved", + "OGL 1.0a", + "ORC License", + "Creative Commons BY 4.0", + "Creative Commons BY-SA 4.0", + "Creative Commons BY-NC 4.0", + "Creative Commons CC0", + "GPL", + "Public Domain", + "Custom / Other", +) + +# Dice / materials defaults, grouped for the picker. Mirrors the front-end +# DICE_MATERIAL_GROUPS so a fresh DB seeds the same starting options. +DiceMaterialDefault = tuple[str, str] # (group, name) + +DEFAULT_DICE_MATERIALS: Sequence[DiceMaterialDefault] = ( + ("Dice", "D4"), + ("Dice", "D6"), + ("Dice", "D8"), + ("Dice", "D10"), + ("Dice", "D12"), + ("Dice", "D20"), + ("Dice", "D100"), + ("Dice", "Custom (System specific)"), + ("Cards", "Playing Cards"), + ("Cards", "Tarot Cards"), + ("Cards", "Custom Deck"), + ("Other", "Tumbling Tower (Jenga Tower)"), + ("Other", "Candles"), + ("Other", "Poker Chips"), + ("Other", "Timers"), + ("Other", "Phone"), +) diff --git a/backend/models/users.py b/backend/models/users.py index 1e9dc12..a0afeee 100644 --- a/backend/models/users.py +++ b/backend/models/users.py @@ -6,6 +6,7 @@ ForeignKey, Index, Integer, + JSON, String, Text, UniqueConstraint, @@ -68,3 +69,26 @@ class Favorite(Base): created_at = Column(DateTime, default=_utcnow) __table_args__ = (UniqueConstraint("user_id", "item_type", "item_id"),) + + +class SavedFilter(Base): + """A named, per-user saved sort/filter preset for a library scope. + + ``scope`` is one of the browsable content areas (systems/books/maps/tokens/ + audio). ``state`` holds the serialized sort/filter object the UI applies. + At most one filter per (user, scope) may have ``is_default`` set — it is the + view the user lands on. The (user, scope, name) uniqueness prevents dupes. + """ + + __tablename__ = "saved_filters" + + id = Column(String(36), primary_key=True, default=_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + scope = Column(String(20), nullable=False) + name = Column(String(120), nullable=False) + state = Column(JSON, default=dict) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + __table_args__ = (UniqueConstraint("user_id", "scope", "name"),) diff --git a/backend/routers/books/_schemas.py b/backend/routers/books/_schemas.py index 16f67e4..88de15a 100644 --- a/backend/routers/books/_schemas.py +++ b/backend/routers/books/_schemas.py @@ -1,6 +1,14 @@ """Pydantic schemas for the books API.""" from typing import Optional -from pydantic import BaseModel + +from pydantic import BaseModel, field_validator + + +class LinkEntry(BaseModel): + """A labeled link on a book (publisher / DriveThruRPG page, etc.).""" + + label: str = "" + url: str = "" class BookUpdate(BaseModel): @@ -8,8 +16,47 @@ class BookUpdate(BaseModel): category: Optional[str] = None description: Optional[str] = None authors: Optional[list[str]] = None + artists: Optional[list[str]] = None + genres: Optional[list[str]] = None publisher: Optional[str] = None + # Legacy single-value URL; still accepted. New clients send ``urls``. publisher_url: Optional[str] = None + urls: Optional[list[LinkEntry]] = None + isbn: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = None + license: Optional[str] = None year: Optional[int] = None + month: Optional[int] = None + day: Optional[int] = None tags: Optional[list[str]] = None is_explicit: Optional[bool] = None + + @field_validator("genres", mode="before") + @classmethod + def strip_genres(cls, v): + if v is None: + return v + seen: set[str] = set() + out: list[str] = [] + for item in v: + s = str(item).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out + + @field_validator("month") + @classmethod + def check_month(cls, v): + if v is not None and not (1 <= v <= 12): + raise ValueError("month must be between 1 and 12") + return v + + @field_validator("day") + @classmethod + def check_day(cls, v): + if v is not None and not (1 <= v <= 31): + raise ValueError("day must be between 1 and 31") + return v diff --git a/backend/routers/books/core.py b/backend/routers/books/core.py index aa985e1..3a61d24 100644 --- a/backend/routers/books/core.py +++ b/backend/routers/books/core.py @@ -89,9 +89,19 @@ def get_book( "page_count": book.page_count, "file_size": book.file_size, "authors": book.authors or [], + "artists": book.artists or [], + "genres": book.genres or [], "publisher": book.publisher, "publisher_url": book.publisher_url, + "urls": book.urls or [], + "isbn": book.isbn or "", + "version": book.version or "", + "language": book.language or "", + "license": book.license or "", "year": book.year, + "month": book.month, + "day": book.day, + "tags": book.tags or [], "indexed": book.indexed, "index_failed": book.index_failed, "ocr_indexed": book.index_error == "ocr", diff --git a/backend/routers/library/core.py b/backend/routers/library/core.py index a586919..fcb15f8 100644 --- a/backend/routers/library/core.py +++ b/backend/routers/library/core.py @@ -89,6 +89,7 @@ def get_stats( return { "game_systems": db.query(GameSystem) .filter(GameSystem.is_system_agnostic != True) # noqa: E712 + .filter(GameSystem.is_one_page != True) # noqa: E712 .count(), "books": db.query(Book).count(), "maps": db.query(GenericMap).count(), diff --git a/backend/routers/lookups/__init__.py b/backend/routers/lookups/__init__.py new file mode 100644 index 0000000..0dec5d2 --- /dev/null +++ b/backend/routers/lookups/__init__.py @@ -0,0 +1,113 @@ +"""Lookups package — genre and system-family reference values (issue #202). + +Registers CRUD routes for the curated genre tree and system-family list that +feed the editor dropdowns and the settings management screens. +""" +from fastapi import APIRouter + +from .core import ( + create_dice_material, + create_genre, + create_license, + create_parent_system, + create_system_family, + delete_dice_material, + delete_genre, + delete_license, + delete_parent_system, + delete_system_family, + list_dice_materials, + list_genres, + list_licenses, + list_parent_systems, + list_system_families, +) + +router = APIRouter(tags=["lookups"]) + +__all__ = ["router"] + +router.add_api_route( + "/genres", list_genres, methods=["GET"], summary="List all genres (tiered)" +) +router.add_api_route( + "/genres", create_genre, methods=["POST"], summary="Create a custom genre (admin)" +) +router.add_api_route( + "/genres/{genre_id}", + delete_genre, + methods=["DELETE"], + summary="Delete a genre (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/system-families", + list_system_families, + methods=["GET"], + summary="List all system families", +) +router.add_api_route( + "/system-families", + create_system_family, + methods=["POST"], + summary="Create a custom system family (admin)", +) +router.add_api_route( + "/system-families/{family_id}", + delete_system_family, + methods=["DELETE"], + summary="Delete a system family (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/parent-systems", + list_parent_systems, + methods=["GET"], + summary="List all parent systems", +) +router.add_api_route( + "/parent-systems", + create_parent_system, + methods=["POST"], + summary="Create a custom parent system (admin)", +) +router.add_api_route( + "/parent-systems/{parent_id}", + delete_parent_system, + methods=["DELETE"], + summary="Delete a parent system (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/licenses", + list_licenses, + methods=["GET"], + summary="List all licenses", +) +router.add_api_route( + "/licenses", + create_license, + methods=["POST"], + summary="Create a custom license (admin)", +) +router.add_api_route( + "/licenses/{license_id}", + delete_license, + methods=["DELETE"], + summary="Delete a license (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/dice-materials", + list_dice_materials, + methods=["GET"], + summary="List all dice/materials", +) +router.add_api_route( + "/dice-materials", + create_dice_material, + methods=["POST"], + summary="Create a custom dice/material (admin)", +) +router.add_api_route( + "/dice-materials/{material_id}", + delete_dice_material, + methods=["DELETE"], + summary="Delete a dice/material (admin; blocked if in use unless force=true)", +) diff --git a/backend/routers/lookups/_helpers.py b/backend/routers/lookups/_helpers.py new file mode 100644 index 0000000..0a47ccb --- /dev/null +++ b/backend/routers/lookups/_helpers.py @@ -0,0 +1,127 @@ +"""Shared helpers for the lookups router.""" +from typing import Any + +from sqlalchemy.orm import Session + +from ...models import ( + Book, + DiceMaterial, + GameSystem, + Genre, + License, + ParentSystem, + SystemFamily, +) + + +def serialize_genre(g: Genre) -> dict[str, Any]: + return { + "id": g.id, + "name": g.name, + "parent_id": g.parent_id, + "is_default": bool(g.is_default), + "sort_order": g.sort_order or 0, + } + + +def serialize_family(f: SystemFamily) -> dict[str, Any]: + return { + "id": f.id, + "name": f.name, + "is_default": bool(f.is_default), + "sort_order": f.sort_order or 0, + } + + +def serialize_parent_system(p: ParentSystem) -> dict[str, Any]: + return { + "id": p.id, + "name": p.name, + "is_default": bool(p.is_default), + "sort_order": p.sort_order or 0, + } + + +def serialize_license(lic: License) -> dict[str, Any]: + return { + "id": lic.id, + "name": lic.name, + "is_default": bool(lic.is_default), + "sort_order": lic.sort_order or 0, + } + + +def serialize_dice_material(d: DiceMaterial) -> dict[str, Any]: + return { + "id": d.id, + "name": d.name, + "group": d.group or "Custom", + "is_default": bool(d.is_default), + "sort_order": d.sort_order or 0, + } + + +def _matches(field: Any, name: str) -> bool: + """Case-insensitive test whether a JSON list / scalar column holds ``name``.""" + if field is None: + return False + wanted = name.strip().lower() + if isinstance(field, list): + return any(str(v).strip().lower() == wanted for v in field) + return str(field).strip().lower() == wanted + + +def count_genre_usage(db: Session, name: str) -> int: + """Count systems + books whose ``genres`` list contains ``name``. + + JSON membership isn't portable in SQLite without json1 filtering, so this + loads the (small) candidate columns and checks in Python. Genre lists are + tiny and the lookup-management screens are admin-only, so the cost is fine. + """ + count = 0 + for (genres,) in db.query(GameSystem.genres).all(): + if _matches(genres, name): + count += 1 + for (genres,) in db.query(Book.genres).all(): + if _matches(genres, name): + count += 1 + return count + + +def count_family_usage(db: Session, name: str) -> int: + """Count systems whose ``system_family`` equals ``name`` (case-insensitive).""" + count = 0 + for (fam,) in db.query(GameSystem.system_family).all(): + if _matches(fam, name): + count += 1 + return count + + +def count_parent_system_usage(db: Session, name: str) -> int: + """Count systems whose ``parent_system`` equals ``name`` (case-insensitive).""" + count = 0 + for (parent,) in db.query(GameSystem.parent_system).all(): + if _matches(parent, name): + count += 1 + return count + + +def count_license_usage(db: Session, name: str) -> int: + """Count systems + books whose ``license`` equals ``name`` (case-insensitive).""" + count = 0 + for (lic,) in db.query(GameSystem.license).all(): + if _matches(lic, name): + count += 1 + for (lic,) in db.query(Book.license).all(): + if _matches(lic, name): + count += 1 + return count + + +def count_dice_material_usage(db: Session, name: str) -> int: + """Count systems whose ``dice_materials`` list contains ``name``.""" + count = 0 + for (materials,) in db.query(GameSystem.dice_materials).all(): + if _matches(materials, name): + count += 1 + return count diff --git a/backend/routers/lookups/_schemas.py b/backend/routers/lookups/_schemas.py new file mode 100644 index 0000000..aabf17b --- /dev/null +++ b/backend/routers/lookups/_schemas.py @@ -0,0 +1,66 @@ +"""Pydantic schemas for the genre / system-family lookup API (issue #202).""" +from typing import Optional + +from pydantic import BaseModel, field_validator + + +class GenreCreate(BaseModel): + name: str + parent_id: Optional[str] = None + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class SystemFamilyCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class ParentSystemCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class LicenseCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class DiceMaterialCreate(BaseModel): + name: str + group: Optional[str] = "Custom" + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v diff --git a/backend/routers/lookups/core.py b/backend/routers/lookups/core.py new file mode 100644 index 0000000..f2c43c3 --- /dev/null +++ b/backend/routers/lookups/core.py @@ -0,0 +1,308 @@ +"""Genre and system-family lookup endpoint handlers (issue #202). + +Reads are available to any authenticated user (they power editor dropdowns); +mutations are admin-only. Deleting a value that is still attached to a system or +book is blocked with 409 unless ``?force=true`` is passed, so the UI can warn +and confirm first. +""" +from fastapi import Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ...auth import CurrentUser, get_current_user, require_admin +from ...config import get_db +from ...models import DiceMaterial, Genre, License, ParentSystem, SystemFamily +from ._helpers import ( + count_dice_material_usage, + count_family_usage, + count_genre_usage, + count_license_usage, + count_parent_system_usage, + serialize_dice_material, + serialize_family, + serialize_genre, + serialize_license, + serialize_parent_system, +) +from ._schemas import ( + DiceMaterialCreate, + GenreCreate, + LicenseCreate, + ParentSystemCreate, + SystemFamilyCreate, +) + + +# --- Genres ------------------------------------------------------------------- + + +def list_genres( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return all genres, ordered for a tiered picker (parents then children).""" + genres = ( + db.query(Genre).order_by(Genre.sort_order, Genre.name).all() + ) + return {"genres": [serialize_genre(g) for g in genres]} + + +def create_genre( + data: GenreCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(Genre).filter(Genre.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A genre with that name already exists") + if data.parent_id: + parent = db.query(Genre).filter_by(id=data.parent_id).first() + if not parent: + raise HTTPException(404, "Parent genre not found") + max_order = db.query(Genre).count() + genre = Genre( + name=name, parent_id=data.parent_id, is_default=False, sort_order=max_order + ) + db.add(genre) + db.commit() + return serialize_genre(genre) + + +def delete_genre( + genre_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + genre = db.query(Genre).filter_by(id=genre_id).first() + if not genre: + raise HTTPException(404, "Genre not found") + usage = count_genre_usage(db, genre.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Genre is in use", + "usage_count": usage, + "name": genre.name, + }, + ) + # Children are removed by the cascade on the self-referential relationship. + db.delete(genre) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- System families ---------------------------------------------------------- + + +def list_system_families( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + families = ( + db.query(SystemFamily).order_by(SystemFamily.sort_order, SystemFamily.name).all() + ) + return {"families": [serialize_family(f) for f in families]} + + +def create_system_family( + data: SystemFamilyCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(SystemFamily).filter(SystemFamily.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A system family with that name already exists") + max_order = db.query(SystemFamily).count() + family = SystemFamily(name=name, is_default=False, sort_order=max_order) + db.add(family) + db.commit() + return serialize_family(family) + + +def delete_system_family( + family_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + family = db.query(SystemFamily).filter_by(id=family_id).first() + if not family: + raise HTTPException(404, "System family not found") + usage = count_family_usage(db, family.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "System family is in use", + "usage_count": usage, + "name": family.name, + }, + ) + db.delete(family) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Parent systems ----------------------------------------------------------- + + +def list_parent_systems( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + parents = ( + db.query(ParentSystem).order_by(ParentSystem.sort_order, ParentSystem.name).all() + ) + return {"parent_systems": [serialize_parent_system(p) for p in parents]} + + +def create_parent_system( + data: ParentSystemCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(ParentSystem).filter(ParentSystem.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A parent system with that name already exists") + max_order = db.query(ParentSystem).count() + parent = ParentSystem(name=name, is_default=False, sort_order=max_order) + db.add(parent) + db.commit() + return serialize_parent_system(parent) + + +def delete_parent_system( + parent_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + parent = db.query(ParentSystem).filter_by(id=parent_id).first() + if not parent: + raise HTTPException(404, "Parent system not found") + usage = count_parent_system_usage(db, parent.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Parent system is in use", + "usage_count": usage, + "name": parent.name, + }, + ) + db.delete(parent) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Licenses ----------------------------------------------------------------- + + +def list_licenses( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + licenses = db.query(License).order_by(License.sort_order, License.name).all() + return {"licenses": [serialize_license(lic) for lic in licenses]} + + +def create_license( + data: LicenseCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(License).filter(License.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A license with that name already exists") + max_order = db.query(License).count() + lic = License(name=name, is_default=False, sort_order=max_order) + db.add(lic) + db.commit() + return serialize_license(lic) + + +def delete_license( + license_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + lic = db.query(License).filter_by(id=license_id).first() + if not lic: + raise HTTPException(404, "License not found") + usage = count_license_usage(db, lic.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "License is in use", + "usage_count": usage, + "name": lic.name, + }, + ) + db.delete(lic) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Dice / materials --------------------------------------------------------- + + +def list_dice_materials( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + materials = ( + db.query(DiceMaterial).order_by(DiceMaterial.sort_order, DiceMaterial.name).all() + ) + return {"dice_materials": [serialize_dice_material(d) for d in materials]} + + +def create_dice_material( + data: DiceMaterialCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(DiceMaterial).filter(DiceMaterial.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A dice/material with that name already exists") + max_order = db.query(DiceMaterial).count() + material = DiceMaterial( + name=name, + group=(data.group or "Custom").strip() or "Custom", + is_default=False, + sort_order=max_order, + ) + db.add(material) + db.commit() + return serialize_dice_material(material) + + +def delete_dice_material( + material_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + material = db.query(DiceMaterial).filter_by(id=material_id).first() + if not material: + raise HTTPException(404, "Dice/material not found") + usage = count_dice_material_usage(db, material.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Dice/material is in use", + "usage_count": usage, + "name": material.name, + }, + ) + db.delete(material) + db.commit() + return {"status": "ok", "removed_usage": usage} diff --git a/backend/routers/saved_filters/__init__.py b/backend/routers/saved_filters/__init__.py new file mode 100644 index 0000000..443cc36 --- /dev/null +++ b/backend/routers/saved_filters/__init__.py @@ -0,0 +1,29 @@ +"""Saved-filters package — per-user named sort/filter presets.""" +from fastapi import APIRouter + +from .core import ( + create_saved_filter, + delete_saved_filter, + list_saved_filters, + update_saved_filter, +) + +router = APIRouter(prefix="/saved-filters", tags=["saved-filters"]) + +__all__ = ["router"] + +router.add_api_route( + "", list_saved_filters, methods=["GET"], summary="List the user's saved filters" +) +router.add_api_route( + "", create_saved_filter, methods=["POST"], summary="Create/overwrite a saved filter" +) +router.add_api_route( + "/{filter_id}", + update_saved_filter, + methods=["PATCH"], + summary="Rename, re-save state, or set default", +) +router.add_api_route( + "/{filter_id}", delete_saved_filter, methods=["DELETE"], summary="Delete a saved filter" +) diff --git a/backend/routers/saved_filters/_schemas.py b/backend/routers/saved_filters/_schemas.py new file mode 100644 index 0000000..12d9fb7 --- /dev/null +++ b/backend/routers/saved_filters/_schemas.py @@ -0,0 +1,45 @@ +"""Pydantic schemas for the saved-filters API.""" +from typing import Any, Optional + +from pydantic import BaseModel, field_validator + +# Browsable content areas a saved filter can belong to. +VALID_SCOPES = {"systems", "books", "maps", "tokens", "audio"} + + +class SavedFilterCreate(BaseModel): + scope: str + name: str + state: dict[str, Any] = {} + is_default: bool = False + + @field_validator("scope") + @classmethod + def valid_scope(cls, v: str) -> str: + if v not in VALID_SCOPES: + raise ValueError(f"scope must be one of: {', '.join(sorted(VALID_SCOPES))}") + return v + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class SavedFilterUpdate(BaseModel): + name: Optional[str] = None + state: Optional[dict[str, Any]] = None + is_default: Optional[bool] = None + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v diff --git a/backend/routers/saved_filters/core.py b/backend/routers/saved_filters/core.py new file mode 100644 index 0000000..63001dd --- /dev/null +++ b/backend/routers/saved_filters/core.py @@ -0,0 +1,127 @@ +"""Saved-filter CRUD endpoints. + +Per-user named sort/filter presets, scoped to a library area +(systems/books/maps/tokens/audio). At most one preset per (user, scope) is the +default — the view the user lands on. Setting a preset default clears the flag +on any sibling in the same scope so the "one default per scope" invariant holds. +""" +from typing import Any, Optional + +from fastapi import Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ...auth import CurrentUser, get_current_user +from ...config import get_db +from ...models import SavedFilter +from ._schemas import VALID_SCOPES, SavedFilterCreate, SavedFilterUpdate + + +def _serialize(f: SavedFilter) -> dict[str, Any]: + return { + "id": f.id, + "scope": f.scope, + "name": f.name, + "state": f.state or {}, + "is_default": bool(f.is_default), + } + + +def _clear_other_defaults(db: Session, user_id: str, scope: str, keep_id: str) -> None: + """Unset is_default on every other preset in this (user, scope).""" + others = ( + db.query(SavedFilter) + .filter( + SavedFilter.user_id == user_id, + SavedFilter.scope == scope, + SavedFilter.is_default == True, # noqa: E712 + SavedFilter.id != keep_id, + ) + .all() + ) + for o in others: + o.is_default = False + + +def list_saved_filters( + scope: Optional[str] = Query(None), + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List the current user's saved filters, optionally limited to one scope.""" + q = db.query(SavedFilter).filter_by(user_id=user.id) + if scope is not None: + if scope not in VALID_SCOPES: + raise HTTPException(400, "Invalid scope") + q = q.filter_by(scope=scope) + rows = q.order_by(SavedFilter.scope, SavedFilter.name).all() + return {"filters": [_serialize(f) for f in rows]} + + +def create_saved_filter( + body: SavedFilterCreate, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Create a preset. Re-saving an existing (scope, name) overwrites its state.""" + existing = ( + db.query(SavedFilter) + .filter_by(user_id=user.id, scope=body.scope, name=body.name.strip()) + .first() + ) + if existing: + existing.state = body.state + if body.is_default: + existing.is_default = True + _clear_other_defaults(db, user.id, body.scope, existing.id) + db.commit() + return _serialize(existing) + + f = SavedFilter( + user_id=user.id, + scope=body.scope, + name=body.name.strip(), + state=body.state, + is_default=body.is_default, + ) + db.add(f) + db.flush() + if body.is_default: + _clear_other_defaults(db, user.id, body.scope, f.id) + db.commit() + return _serialize(f) + + +def update_saved_filter( + filter_id: str, + body: SavedFilterUpdate, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Rename a preset, replace its state, and/or set it as the scope default.""" + f = db.query(SavedFilter).filter_by(id=filter_id, user_id=user.id).first() + if not f: + raise HTTPException(404, "Saved filter not found") + if body.name is not None: + f.name = body.name.strip() + if body.state is not None: + f.state = body.state + if body.is_default is not None: + f.is_default = body.is_default + if body.is_default: + _clear_other_defaults(db, user.id, f.scope, f.id) + db.commit() + return _serialize(f) + + +def delete_saved_filter( + filter_id: str, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete one of the current user's saved filters.""" + f = db.query(SavedFilter).filter_by(id=filter_id, user_id=user.id).first() + if not f: + raise HTTPException(404, "Saved filter not found") + db.delete(f) + db.commit() + return {"status": "ok"} diff --git a/backend/routers/systems/_schemas.py b/backend/routers/systems/_schemas.py index 1ecd970..422b1be 100644 --- a/backend/routers/systems/_schemas.py +++ b/backend/routers/systems/_schemas.py @@ -11,6 +11,13 @@ class PublisherEntry(BaseModel): url: str = "" +class LinkEntry(BaseModel): + """A labeled link (generic URL or character-builder URL).""" + + label: str = "" + url: str = "" + + class BookFolderUpdate(BaseModel): path: str tags: list[str] @@ -25,9 +32,20 @@ class GameSystemUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None publishers: Optional[list[PublisherEntry]] = None + # Legacy single-value URL; still accepted for backward compatibility. character_builder_url: Optional[str] = None + character_builder_urls: Optional[list[LinkEntry]] = None + urls: Optional[list[LinkEntry]] = None tags: Optional[list[str]] = None + # Legacy single-value genre; still accepted. New clients send ``genres``. genre: Optional[str] = None + genres: Optional[list[str]] = None + dice_materials: Optional[list[str]] = None + system_family: Optional[str] = None + parent_system: Optional[str] = None + edition: Optional[str] = None + license: Optional[str] = None + year: Optional[int] = None cover_book_id: Optional[str] = None is_explicit: Optional[bool] = None @@ -35,3 +53,19 @@ class GameSystemUpdate(BaseModel): @classmethod def lowercase_tags(cls, v): return _normalize_tags(v) if v is not None else v + + @field_validator("genres", "dice_materials", mode="before") + @classmethod + def strip_list(cls, v): + """Trim and drop empties, preserving case (genres are display values).""" + if v is None: + return v + seen: set[str] = set() + out: list[str] = [] + for item in v: + s = str(item).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out diff --git a/backend/routers/systems/_serializers.py b/backend/routers/systems/_serializers.py new file mode 100644 index 0000000..a73086d --- /dev/null +++ b/backend/routers/systems/_serializers.py @@ -0,0 +1,77 @@ +"""Serialization helpers for game systems and their books (issue #202). + +Centralizes the field lists so ``list_systems``, ``get_system``, and any future +endpoint emit the same shape. +""" +from typing import Any + +from ...models import Book, GameSystem + + +def serialize_book(book: Book) -> dict[str, Any]: + """Serialize a Book to the API shape used by the system detail view.""" + return { + "id": book.id, + "title": book.title, + "filename": book.filename, + "category": book.category, + "description": book.description, + "page_count": book.page_count, + "file_size": book.file_size, + "mime_type": book.mime_type, + "authors": book.authors or [], + "artists": book.artists or [], + "genres": book.genres or [], + "publisher": book.publisher, + "publisher_url": book.publisher_url, + "urls": book.urls or [], + "isbn": book.isbn or "", + "version": book.version or "", + "language": book.language or "", + "license": book.license or "", + "year": book.year, + "month": book.month, + "day": book.day, + "indexed": book.indexed, + "index_failed": book.index_failed, + "index_error": book.index_error, + "ocr_indexed": book.index_error == "ocr", + "ocr_dpi": book.ocr_dpi, + "has_thumbnail": book.has_thumbnail, + "tags": book.tags or [], + "is_explicit": bool(book.is_explicit), + "is_missing": bool(book.is_missing), + "relative_path": book.relative_path, + } + + +def serialize_system_summary( + system: GameSystem, book_count: int, total_page_count: int, cover_book_id: str | None +) -> dict[str, Any]: + """Serialize a GameSystem for the systems list (no book payload).""" + return { + "id": system.id, + "name": system.name, + "slug": system.slug, + "description": system.description, + "publishers": system.publishers or [], + "character_builder_url": system.character_builder_url, + "character_builder_urls": system.character_builder_urls or [], + "urls": system.urls or [], + "tags": system.tags or [], + "genre": system.genre, + "genres": system.genres or [], + "dice_materials": system.dice_materials or [], + "system_family": system.system_family or "", + "parent_system": system.parent_system or "", + "edition": system.edition or "", + "license": system.license or "", + "year": system.year, + "book_count": book_count, + "total_page_count": total_page_count, + "cover_image": system.cover_image, + "cover_book_id": cover_book_id, + "is_explicit": bool(system.is_explicit), + "is_system_agnostic": bool(system.is_system_agnostic), + "is_one_page": bool(system.is_one_page), + } diff --git a/backend/routers/systems/core.py b/backend/routers/systems/core.py index 914a43f..37a3882 100644 --- a/backend/routers/systems/core.py +++ b/backend/routers/systems/core.py @@ -1,5 +1,8 @@ """Game system endpoint handlers.""" -from fastapi import Depends, HTTPException +from typing import Optional + +from fastapi import Depends, HTTPException, Query +from sqlalchemy import func from sqlalchemy.orm import Session from ...auth import CurrentUser, get_current_user, require_gm_or_admin @@ -7,9 +10,33 @@ from ...models import Book, BookFolder, GameSystem, User from ._helpers import resolve_cover_book_id from ._schemas import BookFolderUpdate, GameSystemUpdate +from ._serializers import serialize_book, serialize_system_summary + +# Sort keys accepted by list_systems. Value is the summary dict key to sort on. +_SYSTEM_SORT_KEYS = {"name", "book_count", "page_count", "year"} +# Sort keys accepted for a system's books (get_system). +_BOOK_SORT_KEYS = {"title", "page_count", "year"} + + +def _has_value(field, wanted: str) -> bool: + """Case-insensitive membership test against a stringy/list JSON field.""" + if field is None: + return False + wanted = wanted.strip().lower() + if isinstance(field, list): + return any(str(v).strip().lower() == wanted for v in field) + return str(field).strip().lower() == wanted def list_systems( + sort: str = Query("name"), + order: str = Query("asc"), + genre: Optional[str] = Query(None), + family: Optional[str] = Query(None), + parent_system: Optional[str] = Query(None), + edition: Optional[str] = Query(None), + license: Optional[str] = Query(None), + explicit: Optional[bool] = Query(None), current_user: CurrentUser = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -18,38 +45,71 @@ def list_systems( bool(user.allow_explicit) if user and user.allow_explicit is not None else True ) - systems = db.query(GameSystem).order_by(GameSystem.name).all() + # Per-system book count + total page count in one grouped query. + agg_q = db.query( + Book.game_system_id, + func.count(Book.id), + func.coalesce(func.sum(Book.page_count), 0), + ) + if not can_see_explicit: + agg_q = agg_q.filter(Book.is_explicit != True) # noqa: E712 + agg = { + gsid: (count, pages) + for gsid, count, pages in agg_q.group_by(Book.game_system_id).all() + } + + systems = db.query(GameSystem).all() result = [] for s in systems: if s.is_explicit and not can_see_explicit: continue - book_q = db.query(Book).filter_by(game_system_id=s.id) - if not can_see_explicit: - book_q = book_q.filter(Book.is_explicit != True) - book_count = book_q.count() + if explicit is not None and bool(s.is_explicit) != explicit: + continue + if genre and not _has_value(s.genres, genre): + continue + if family and not _has_value(s.system_family, family): + continue + if parent_system and not _has_value(s.parent_system, parent_system): + continue + if edition and not _has_value(s.edition, edition): + continue + if license and not _has_value(s.license, license): + continue + book_count, total_pages = agg.get(s.id, (0, 0)) cover_book_id = resolve_cover_book_id(db, s) result.append( - { - "id": s.id, - "name": s.name, - "slug": s.slug, - "description": s.description, - "publishers": s.publishers or [], - "character_builder_url": s.character_builder_url, - "tags": s.tags or [], - "genre": s.genre, - "book_count": book_count, - "cover_image": s.cover_image, - "cover_book_id": cover_book_id, - "is_explicit": bool(s.is_explicit), - "is_system_agnostic": bool(s.is_system_agnostic), - } + serialize_system_summary(s, book_count, int(total_pages or 0), cover_book_id) ) + + result = _sort_systems(result, sort, order) return result +def _sort_systems(rows: list[dict], sort: str, order: str) -> list[dict]: + """Sort serialized system rows by the requested key (name default).""" + key = sort if sort in _SYSTEM_SORT_KEYS else "name" + reverse = order == "desc" + if key == "name": + return sorted(rows, key=lambda r: r["name"].lower(), reverse=reverse) + if key == "page_count": + return sorted(rows, key=lambda r: r["total_page_count"], reverse=reverse) + if key == "year": + # Systems with no year sort last regardless of direction. + return sorted( + rows, + key=lambda r: (r["year"] is None, r["year"] or 0), + reverse=reverse, + ) + return sorted(rows, key=lambda r: r[key], reverse=reverse) + + def get_system( system_id: str, + book_sort: str = Query("category"), + book_order: str = Query("asc"), + explicit: Optional[bool] = Query(None), + genre: Optional[str] = Query(None), + category: Optional[str] = Query(None), current_user: CurrentUser = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -68,8 +128,9 @@ def get_system( book_q = db.query(Book).filter_by(game_system_id=system.id) if not can_see_explicit: book_q = book_q.filter(Book.is_explicit != True) - books = book_q.order_by(Book.category, Book.title).all() + books = book_q.all() + # Cover resolution ignores the sort/filter args (must be stable). cover_book_id = system.cover_book_id if not cover_book_id: auto = next((b for b in books if b.category == "core" and b.has_thumbnail), None) @@ -77,47 +138,42 @@ def get_system( auto = next((b for b in books if b.has_thumbnail), None) if auto: cover_book_id = auto.id - return { - "id": system.id, - "name": system.name, - "slug": system.slug, - "description": system.description, - "publishers": system.publishers or [], - "character_builder_url": system.character_builder_url, - "tags": system.tags or [], - "genre": system.genre, - "cover_image": system.cover_image, - "cover_book_id": cover_book_id, - "is_explicit": bool(system.is_explicit), - "is_system_agnostic": bool(system.is_system_agnostic), - "books": [ - { - "id": b.id, - "title": b.title, - "filename": b.filename, - "category": b.category, - "description": b.description, - "page_count": b.page_count, - "file_size": b.file_size, - "mime_type": b.mime_type, - "authors": b.authors or [], - "publisher": b.publisher, - "publisher_url": b.publisher_url, - "year": b.year, - "indexed": b.indexed, - "index_failed": b.index_failed, - "index_error": b.index_error, - "ocr_indexed": b.index_error == "ocr", - "ocr_dpi": b.ocr_dpi, - "has_thumbnail": b.has_thumbnail, - "tags": b.tags or [], - "is_explicit": bool(b.is_explicit), - "is_missing": bool(b.is_missing), - "relative_path": b.relative_path, - } - for b in books - ], - } + + # Filter then sort the returned book list. + if explicit is not None: + books = [b for b in books if bool(b.is_explicit) == explicit] + if category: + books = [b for b in books if b.category == category] + if genre: + books = [b for b in books if _has_value(b.genres, genre)] + books = _sort_books(books, book_sort, book_order) + + summary = serialize_system_summary( + system, + book_count=len(books), + total_page_count=sum(b.page_count or 0 for b in books), + cover_book_id=cover_book_id, + ) + summary["books"] = [serialize_book(b) for b in books] + return summary + + +def _sort_books(books: list[Book], sort: str, order: str) -> list[Book]: + """Sort ORM Book rows by the requested key (category+title default).""" + reverse = order == "desc" + key = sort if sort in _BOOK_SORT_KEYS else "category" + if key == "title": + return sorted(books, key=lambda b: b.title.lower(), reverse=reverse) + if key == "page_count": + return sorted(books, key=lambda b: b.page_count or 0, reverse=reverse) + if key == "year": + return sorted( + books, + key=lambda b: (b.year is None, b.year or 0, b.title.lower()), + reverse=reverse, + ) + # Default: group by category, then title (both ascending, ignoring order). + return sorted(books, key=lambda b: (b.category, b.title.lower())) def list_book_folders( @@ -156,9 +212,9 @@ def update_system( system = db.query(GameSystem).filter_by(id=system_id).first() if not system: raise HTTPException(404, "System not found") + # model_dump serializes nested Pydantic models (publishers, urls, + # character_builder_urls) to plain dicts, which SQLAlchemy stores as JSON. payload = data.model_dump(exclude_none=True) - if "publishers" in payload: - payload["publishers"] = [p if isinstance(p, dict) else p for p in payload["publishers"]] for field, value in payload.items(): setattr(system, field, value) db.commit() diff --git a/backend/tests/test_db_migrations.py b/backend/tests/test_db_migrations.py index 6a47ab1..1fd81f1 100644 --- a/backend/tests/test_db_migrations.py +++ b/backend/tests/test_db_migrations.py @@ -432,3 +432,80 @@ def test_upgrade_is_reentrant_after_partial_apply(self): init_db(path) # must be a clean no-op, not "duplicate column" error assert _stamped_revision(path) == _alembic_head(path) + + +class TestExpandMetadataMigration: + """Migration 0004: new columns, lookup seeds, and legacy backfill (#202).""" + + def test_new_columns_present(self): + path = _fresh_db() + engine = create_engine(f"sqlite:///{path}") + insp = inspect(engine) + sys_cols = {c["name"] for c in insp.get_columns("game_systems")} + book_cols = {c["name"] for c in insp.get_columns("books")} + assert { + "genres", + "dice_materials", + "system_family", + "license", + "year", + "urls", + "character_builder_urls", + "is_one_page", + } <= sys_cols + assert {"artists", "genres", "isbn", "version", "language", "month", "day", "urls"} <= book_cols + + def test_lookup_tables_seeded(self): + path = _fresh_db() + engine = create_engine(f"sqlite:///{path}") + with engine.connect() as conn: + genre_count = conn.execute(text("SELECT count(*) FROM genres")).scalar() + fam_count = conn.execute(text("SELECT count(*) FROM system_families")).scalar() + cyber = conn.execute( + text( + "SELECT p.name FROM genres g JOIN genres p ON g.parent_id=p.id " + "WHERE g.name='Cyberpunk'" + ) + ).scalar() + assert genre_count > 0 + assert fam_count > 0 + assert cyber == "Science Fiction" + + def test_legacy_backfill(self): + """genre → genres, character_builder_url → list, publisher_url → book urls.""" + path = os.path.join(tempfile.mkdtemp(), "legacy.db") + engine = create_engine(f"sqlite:///{path}") + # Migrate up to the pre-0004 revision, insert legacy rows, then finish. + with engine.connect() as conn: + from alembic import command + + cfg = _alembic_config(conn) + command.upgrade(cfg, "b2e5d3f0c8a1") + conn.commit() + with engine.connect() as conn: + conn.execute( + text( + "INSERT INTO game_systems (id, name, slug, genre, character_builder_url) " + "VALUES ('s1','S','s','Fantasy','http://b')" + ) + ) + conn.execute( + text( + "INSERT INTO books (id, title, filename, filepath, relative_path, publisher_url) " + "VALUES ('b1','B','b.pdf','/b.pdf','b.pdf','http://p')" + ) + ) + conn.commit() + engine.dispose() + + init_db(path) # runs 0004 including backfill + + engine = create_engine(f"sqlite:///{path}") + with engine.connect() as conn: + g = conn.execute( + text("SELECT genres, character_builder_urls FROM game_systems WHERE id='s1'") + ).fetchone() + b = conn.execute(text("SELECT urls FROM books WHERE id='b1'")).fetchone() + assert json.loads(g[0]) == ["Fantasy"] + assert json.loads(g[1])[0]["url"] == "http://b" + assert json.loads(b[0])[0]["url"] == "http://p" diff --git a/backend/tests/test_indexer_category.py b/backend/tests/test_indexer_category.py index 92135ab..3f91532 100644 --- a/backend/tests/test_indexer_category.py +++ b/backend/tests/test_indexer_category.py @@ -1,5 +1,13 @@ """Tests for guess_category(), agnostic_category(), and is_system_agnostic_folder() in the library indexer.""" -from backend.indexer import guess_category, agnostic_category, is_system_agnostic_folder +from backend.indexer import ( + agnostic_category, + guess_category, + is_one_page_folder, + is_special_collection_folder, + is_system_agnostic_folder, + slugify, + strip_sort_prefix, +) class TestKnownCategories: @@ -214,6 +222,85 @@ def test_empty_string_not_agnostic(self): assert is_system_agnostic_folder("") is False +class TestOnePageFolder: + """Tests for is_one_page_folder() and is_special_collection_folder() (#202).""" + + def test_one_page_rpgs(self): + assert is_one_page_folder("One-Page RPGs") is True + + def test_single_page_rpgs_alias(self): + assert is_one_page_folder("Single-Page RPGs") is True + + def test_one_shot_rpgs_alias(self): + assert is_one_page_folder("One-Shot RPGs") is True + + def test_case_insensitive(self): + assert is_one_page_folder("one-page-rpgs") is True + + def test_normal_folder_not_one_page(self): + assert is_one_page_folder("Dungeons and Dragons 5e") is False + + def test_agnostic_not_one_page(self): + assert is_one_page_folder("System Agnostic") is False + + def test_special_includes_agnostic(self): + assert is_special_collection_folder("System Agnostic") is True + + def test_special_includes_one_page(self): + assert is_special_collection_folder("One-Page RPGs") is True + + def test_special_excludes_normal(self): + assert is_special_collection_folder("Pathfinder 2e") is False + + def test_one_page_uses_subfolder_category(self): + # One-page collections share the agnostic category resolver. + assert agnostic_category("books/One-Page RPGs/Honey Heist/hh.pdf") == "honey-heist" + + +class TestStripSortPrefix: + """Leading !$% sort-order prefixes are stripped from system folder names.""" + + def test_strips_single_bang(self): + assert strip_sort_prefix("!system-agnostic") == "system-agnostic" + + def test_strips_double_bang(self): + assert strip_sort_prefix("!!Dungeons & Dragons") == "Dungeons & Dragons" + + def test_strips_mixed_prefix_chars(self): + assert strip_sort_prefix("!$%Pathfinder 2e") == "Pathfinder 2e" + + def test_stops_at_first_non_prefix_char(self): + # Only the leading run is removed; internal specials are preserved. + assert strip_sort_prefix("!!D&D $ Extras") == "D&D $ Extras" + + def test_no_prefix_is_unchanged(self): + assert strip_sort_prefix("Call of Cthulhu") == "Call of Cthulhu" + + def test_trims_surrounding_whitespace(self): + assert strip_sort_prefix("!! Dungeons & Dragons ") == "Dungeons & Dragons" + + def test_empty_string(self): + assert strip_sort_prefix("") == "" + + def test_all_prefix_chars_collapse_to_empty(self): + assert strip_sort_prefix("!!!") == "" + + def test_does_not_strip_other_specials(self): + # A hash isn't in the recognised set, so nothing is stripped. + assert strip_sort_prefix("#Homebrew") == "#Homebrew" + + def test_prefixed_agnostic_folder_still_detected(self): + # Detection goes through slugify, which already drops "!", so prefixed + # special-collection folders are recognised regardless. + assert is_system_agnostic_folder("!system-agnostic") is True + + def test_prefixed_one_page_folder_still_detected(self): + assert is_one_page_folder("!!one-page-rpgs") is True + + def test_stripped_name_slugs_cleanly(self): + assert slugify(strip_sort_prefix("!!Dungeons & Dragons")) == "dungeons-dragons" + + class TestAgnosticCategory: """Tests for agnostic_category() — the category resolver for system-agnostic books.""" diff --git a/backend/tests/test_indexer_sort_prefix.py b/backend/tests/test_indexer_sort_prefix.py new file mode 100644 index 0000000..376581b --- /dev/null +++ b/backend/tests/test_indexer_sort_prefix.py @@ -0,0 +1,99 @@ +"""Tests for leading sort-order prefix (!$%) stripping in the library scanner. + +People prepend characters like "!", "$", or "%" to system folders so their file +browser sorts them first. The scanner strips that leading run when deriving the +system name/slug, while keeping the rest of the name (including internal specials) +verbatim. +""" +import tempfile +from pathlib import Path + +from backend.config import SessionLocal +from backend.models import GameSystem +from backend.indexer import scan_library + + +def _mk_lib(): + tmp = tempfile.mkdtemp() + lib = Path(tmp) / "library" + lib.mkdir() + return tmp, lib + + +def _books_dir(lib: Path, system_folder: str) -> Path: + d = lib / "books" / system_folder + d.mkdir(parents=True, exist_ok=True) + return d + + +def _touch_pdf(folder: Path, name: str = "book.pdf") -> Path: + p = folder / name + p.write_bytes(b"%PDF-1.4") + return p + + +def _get_system_by_slug(slug: str): + db = SessionLocal() + try: + return db.query(GameSystem).filter_by(slug=slug).first() + finally: + db.close() + + +def _scan(lib: Path, tmp: str): + db = SessionLocal() + try: + scan_library(str(lib), tmp, db) + finally: + db.close() + + +class TestSortPrefixStripping: + def test_double_bang_prefix_stripped_from_name(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Dungeons & Dragons")) + _scan(lib, tmp) + + system = _get_system_by_slug("dungeons-dragons") + assert system is not None + assert system.name == "Dungeons & Dragons" + + def test_single_bang_agnostic_folder_still_agnostic(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!system-agnostic")) + _scan(lib, tmp) + + system = _get_system_by_slug("system-agnostic") + assert system is not None + assert system.name == "system-agnostic" + assert system.is_system_agnostic is True + + def test_mixed_prefix_chars_stripped(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!$%Pathfinder 2e")) + _scan(lib, tmp) + + system = _get_system_by_slug("pathfinder-2e") + assert system is not None + assert system.name == "Pathfinder 2e" + assert system.is_system_agnostic is False + assert system.is_one_page is False + + def test_internal_special_chars_preserved(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Vampire: The Masquerade")) + _scan(lib, tmp) + + system = _get_system_by_slug("vampire-the-masquerade") + assert system is not None + assert system.name == "Vampire: The Masquerade" + + def test_prefix_and_nsfw_combined(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Forbidden Lore (NSFW)")) + _scan(lib, tmp) + + system = _get_system_by_slug("forbidden-lore") + assert system is not None + assert system.name == "Forbidden Lore" + assert system.is_explicit is True diff --git a/backend/tests/test_lookups.py b/backend/tests/test_lookups.py new file mode 100644 index 0000000..5f6f4b9 --- /dev/null +++ b/backend/tests/test_lookups.py @@ -0,0 +1,251 @@ +"""Tests for the genre / system-family lookup API (issue #202).""" +from backend.tests.conftest import make_game_system + + +class TestGenres: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/genres", headers=admin_headers) + assert resp.status_code == 200 + names = [g["name"] for g in resp.json()["genres"]] + assert "Science Fiction" in names + assert "Cyberpunk" in names + + def test_cyberpunk_nested_under_science_fiction(self, client, admin_headers): + genres = client.get("/api/genres", headers=admin_headers).json()["genres"] + by_name = {g["name"]: g for g in genres} + sci = by_name["Science Fiction"] + cyber = by_name["Cyberpunk"] + assert cyber["parent_id"] == sci["id"] + + def test_player_can_read_genres(self, client, player_headers): + assert client.get("/api/genres", headers=player_headers).status_code == 200 + + def test_create_custom_genre_admin(self, client, admin_headers): + resp = client.post("/api/genres", json={"name": "Solarpunk"}, headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["name"] == "Solarpunk" + assert resp.json()["is_default"] is False + + def test_create_child_genre(self, client, admin_headers): + genres = client.get("/api/genres", headers=admin_headers).json()["genres"] + parent = next(g for g in genres if g["name"] == "Fantasy") + resp = client.post( + "/api/genres", + json={"name": "Grimbright", "parent_id": parent["id"]}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["parent_id"] == parent["id"] + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/genres", json={"name": "Noir"}, headers=admin_headers) + resp = client.post("/api/genres", json={"name": "noir"}, headers=admin_headers) + assert resp.status_code == 409 + + def test_create_blank_rejected(self, client, admin_headers): + resp = client.post("/api/genres", json={"name": " "}, headers=admin_headers) + assert resp.status_code == 422 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/genres", json={"name": "Weird"}, headers=gm_headers) + assert resp.status_code == 403 + + def test_delete_unused_genre(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "Deletable"}, headers=admin_headers + ).json() + resp = client.delete(f"/api/genres/{created['id']}", headers=admin_headers) + assert resp.status_code == 200 + + def test_delete_in_use_blocked_without_force(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "AttachedGenre"}, headers=admin_headers + ).json() + make_game_system(genres=["AttachedGenre"]) + resp = client.delete(f"/api/genres/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + assert resp.json()["detail"]["usage_count"] >= 1 + + def test_delete_in_use_with_force(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "ForceGenre"}, headers=admin_headers + ).json() + make_game_system(genres=["ForceGenre"]) + resp = client.delete( + f"/api/genres/{created['id']}?force=true", headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["removed_usage"] >= 1 + + def test_delete_missing_genre(self, client, admin_headers): + resp = client.delete("/api/genres/does-not-exist", headers=admin_headers) + assert resp.status_code == 404 + + +class TestSystemFamilies: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/system-families", headers=admin_headers) + assert resp.status_code == 200 + names = [f["name"] for f in resp.json()["families"]] + assert "Powered by the Apocalypse" in names + + def test_create_custom_family(self, client, admin_headers): + resp = client.post( + "/api/system-families", json={"name": "Havoc System"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Havoc System" + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/system-families", json={"name": "Ubiquity"}, headers=admin_headers) + resp = client.post( + "/api/system-families", json={"name": "ubiquity"}, headers=admin_headers + ) + assert resp.status_code == 409 + + def test_delete_in_use_blocked(self, client, admin_headers): + created = client.post( + "/api/system-families", json={"name": "AttachedFamily"}, headers=admin_headers + ).json() + make_game_system(system_family="AttachedFamily") + resp = client.delete(f"/api/system-families/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_delete_in_use_with_force(self, client, admin_headers): + created = client.post( + "/api/system-families", json={"name": "ForceFamily"}, headers=admin_headers + ).json() + make_game_system(system_family="ForceFamily") + resp = client.delete( + f"/api/system-families/{created['id']}?force=true", headers=admin_headers + ) + assert resp.status_code == 200 + + def test_create_requires_admin(self, client, player_headers): + resp = client.post( + "/api/system-families", json={"name": "Nope"}, headers=player_headers + ) + assert resp.status_code == 403 + + +class TestParentSystems: + def test_list_empty_by_default(self, client, admin_headers): + resp = client.get("/api/parent-systems", headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["parent_systems"] == [] + + def test_create_and_list(self, client, admin_headers): + created = client.post( + "/api/parent-systems", + json={"name": "Dungeons & Dragons"}, + headers=admin_headers, + ) + assert created.status_code == 200 + assert created.json()["name"] == "Dungeons & Dragons" + names = [ + p["name"] + for p in client.get("/api/parent-systems", headers=admin_headers).json()[ + "parent_systems" + ] + ] + assert "Dungeons & Dragons" in names + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/parent-systems", json={"name": "Cyberpunk"}, headers=admin_headers) + resp = client.post( + "/api/parent-systems", json={"name": "cyberpunk"}, headers=admin_headers + ) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/parent-systems", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/parent-systems", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 + + def test_delete_in_use_blocked_then_forced(self, client, admin_headers): + created = client.post( + "/api/parent-systems", json={"name": "AttachedParent"}, headers=admin_headers + ).json() + make_game_system(parent_system="AttachedParent") + blocked = client.delete( + f"/api/parent-systems/{created['id']}", headers=admin_headers + ) + assert blocked.status_code == 409 + forced = client.delete( + f"/api/parent-systems/{created['id']}?force=true", headers=admin_headers + ) + assert forced.status_code == 200 + + +class TestLicenses: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/licenses", headers=admin_headers) + assert resp.status_code == 200 + names = [lic["name"] for lic in resp.json()["licenses"]] + assert "OGL 1.0a" in names + + def test_create_custom(self, client, admin_headers): + resp = client.post( + "/api/licenses", json={"name": "My Homebrew License"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["is_default"] is False + + def test_delete_in_use_by_system_blocked(self, client, admin_headers): + created = client.post( + "/api/licenses", json={"name": "SystemLicense"}, headers=admin_headers + ).json() + make_game_system(license="SystemLicense") + resp = client.delete(f"/api/licenses/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/licenses", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/licenses", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 + + +class TestDiceMaterials: + def test_defaults_seeded_with_groups(self, client, admin_headers): + resp = client.get("/api/dice-materials", headers=admin_headers) + assert resp.status_code == 200 + rows = resp.json()["dice_materials"] + by_name = {d["name"]: d for d in rows} + assert "D20" in by_name + assert by_name["D20"]["group"] == "Dice" + assert by_name["Tarot Cards"]["group"] == "Cards" + + def test_create_custom_defaults_to_custom_group(self, client, admin_headers): + resp = client.post( + "/api/dice-materials", json={"name": "Spinner"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["group"] == "Custom" + + def test_create_with_group(self, client, admin_headers): + resp = client.post( + "/api/dice-materials", + json={"name": "Fudge Dice", "group": "Dice"}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["group"] == "Dice" + + def test_delete_in_use_blocked(self, client, admin_headers): + created = client.post( + "/api/dice-materials", json={"name": "AttachedDie"}, headers=admin_headers + ).json() + make_game_system(dice_materials=["AttachedDie"]) + resp = client.delete(f"/api/dice-materials/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/dice-materials", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/dice-materials", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 diff --git a/backend/tests/test_saved_filters.py b/backend/tests/test_saved_filters.py new file mode 100644 index 0000000..bae02cc --- /dev/null +++ b/backend/tests/test_saved_filters.py @@ -0,0 +1,146 @@ +"""Tests for the saved-filters API (server-side sort/filter presets).""" + + +class TestSavedFilters: + def test_create_and_list(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "By pages", "state": {"sort": "page_count"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["name"] == "By pages" + assert r.json()["state"] == {"sort": "page_count"} + + rows = client.get("/api/saved-filters?scope=systems", headers=admin_headers).json()[ + "filters" + ] + assert any(f["name"] == "By pages" for f in rows) + + def test_blank_name_rejected(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": " ", "state": {}}, + headers=admin_headers, + ) + assert r.status_code == 422 + + def test_invalid_scope_rejected(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "nope", "name": "x", "state": {}}, + headers=admin_headers, + ) + assert r.status_code == 422 + + def test_list_invalid_scope_query(self, client, admin_headers): + r = client.get("/api/saved-filters?scope=nope", headers=admin_headers) + assert r.status_code == 400 + + def test_resave_overwrites_state(self, client, admin_headers): + client.post( + "/api/saved-filters", + json={"scope": "books", "name": "Dupe", "state": {"sort": "title"}}, + headers=admin_headers, + ) + r = client.post( + "/api/saved-filters", + json={"scope": "books", "name": "Dupe", "state": {"sort": "year"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["state"] == {"sort": "year"} + # No duplicate row created. + rows = client.get("/api/saved-filters?scope=books", headers=admin_headers).json()[ + "filters" + ] + assert len([f for f in rows if f["name"] == "Dupe"]) == 1 + + def test_only_one_default_per_scope(self, client, admin_headers): + a = client.post( + "/api/saved-filters", + json={"scope": "maps", "name": "A", "state": {}, "is_default": True}, + headers=admin_headers, + ).json() + b = client.post( + "/api/saved-filters", + json={"scope": "maps", "name": "B", "state": {}}, + headers=admin_headers, + ).json() + # Promote B to default → A must lose it. + client.patch( + f"/api/saved-filters/{b['id']}", json={"is_default": True}, headers=admin_headers + ) + rows = client.get("/api/saved-filters?scope=maps", headers=admin_headers).json()["filters"] + defaults = {f["name"]: f["is_default"] for f in rows} + assert defaults["A"] is False + assert defaults["B"] is True + assert a["is_default"] is True # was default at creation + + def test_default_isolated_per_scope(self, client, admin_headers): + client.post( + "/api/saved-filters", + json={"scope": "tokens", "name": "TokDefault", "state": {}, "is_default": True}, + headers=admin_headers, + ) + client.post( + "/api/saved-filters", + json={"scope": "audio", "name": "AudDefault", "state": {}, "is_default": True}, + headers=admin_headers, + ) + tok = client.get("/api/saved-filters?scope=tokens", headers=admin_headers).json()[ + "filters" + ] + aud = client.get("/api/saved-filters?scope=audio", headers=admin_headers).json()["filters"] + assert tok[0]["is_default"] is True + assert aud[0]["is_default"] is True + + def test_update_rename_and_state(self, client, admin_headers): + f = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "Old", "state": {"sort": "name"}}, + headers=admin_headers, + ).json() + r = client.patch( + f"/api/saved-filters/{f['id']}", + json={"name": "New", "state": {"sort": "year"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["name"] == "New" + assert r.json()["state"] == {"sort": "year"} + + def test_delete(self, client, admin_headers): + f = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "ToDelete", "state": {}}, + headers=admin_headers, + ).json() + assert ( + client.delete(f"/api/saved-filters/{f['id']}", headers=admin_headers).status_code + == 200 + ) + assert ( + client.delete(f"/api/saved-filters/{f['id']}", headers=admin_headers).status_code + == 404 + ) + + def test_update_missing_404(self, client, admin_headers): + assert ( + client.patch( + "/api/saved-filters/nope", json={"name": "x"}, headers=admin_headers + ).status_code + == 404 + ) + + def test_filters_are_per_user(self, client, admin_headers, gm_headers): + client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "AdminOnly", "state": {}}, + headers=admin_headers, + ) + gm_rows = client.get("/api/saved-filters", headers=gm_headers).json()["filters"] + assert all(f["name"] != "AdminOnly" for f in gm_rows) + + def test_requires_auth(self, client): + assert client.get("/api/saved-filters").status_code == 401 diff --git a/backend/tests/test_systems_metadata.py b/backend/tests/test_systems_metadata.py new file mode 100644 index 0000000..edf185a --- /dev/null +++ b/backend/tests/test_systems_metadata.py @@ -0,0 +1,253 @@ +"""Tests for expanded system metadata, sort/filter, and serialization (#202).""" +from backend.tests.conftest import make_book, make_game_system + + +class TestSystemMetadataFields: + def test_new_fields_in_list(self, client, admin_headers): + make_game_system( + name="Blades Test", + slug="blades-test", + genres=["Fantasy", "Heist"], + dice_materials=["D6 pool"], + system_family="Forged in the Dark", + license="CC-BY", + year=2017, + urls=[{"label": "DriveThruRPG", "url": "http://example.com"}], + ) + resp = client.get("/api/systems", headers=admin_headers) + s = next(s for s in resp.json() if s["slug"] == "blades-test") + assert s["genres"] == ["Fantasy", "Heist"] + assert s["dice_materials"] == ["D6 pool"] + assert s["system_family"] == "Forged in the Dark" + assert s["license"] == "CC-BY" + assert s["year"] == 2017 + assert s["urls"][0]["label"] == "DriveThruRPG" + assert "total_page_count" in s + assert "is_one_page" in s + + def test_update_metadata(self, client, admin_headers): + sysobj = make_game_system(name="Patch Meta", slug="patch-meta") + resp = client.patch( + f"/api/systems/{sysobj.id}", + json={ + "genres": ["Horror", "Horror", " gothic "], + "system_family": "GUMSHOE", + "year": 2011, + "character_builder_urls": [{"label": "Sheet", "url": "http://s"}], + }, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + # De-duplicated case-insensitively, trimmed, case preserved. + assert got["genres"] == ["Horror", "gothic"] + assert got["system_family"] == "GUMSHOE" + assert got["character_builder_urls"][0]["url"] == "http://s" + + def test_update_parent_system_and_edition(self, client, admin_headers): + sysobj = make_game_system(name="Cyberpunk Red", slug="cp-red") + resp = client.patch( + f"/api/systems/{sysobj.id}", + json={"parent_system": "Cyberpunk", "edition": "Red", "license": "Custom"}, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + assert got["parent_system"] == "Cyberpunk" + assert got["edition"] == "Red" + assert got["license"] == "Custom" + + def test_new_fields_default_empty(self, client, admin_headers): + sysobj = make_game_system(name="Bare Sys", slug="bare-sys") + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + assert got["parent_system"] == "" + assert got["edition"] == "" + + def test_total_page_count_aggregates(self, client, admin_headers): + sysobj = make_game_system(name="Pages Sys", slug="pages-sys") + make_book(system_id=sysobj.id, page_count=10) + make_book(system_id=sysobj.id, page_count=25) + resp = client.get("/api/systems", headers=admin_headers) + s = next(s for s in resp.json() if s["slug"] == "pages-sys") + assert s["total_page_count"] == 35 + assert s["book_count"] == 2 + + +class TestSystemSort: + def _slugs(self, rows, prefix): + return [r["slug"] for r in rows if r["slug"].startswith(prefix)] + + def test_sort_by_page_count(self, client, admin_headers): + a = make_game_system(name="Zsort A", slug="zsort-a") + b = make_game_system(name="Zsort B", slug="zsort-b") + make_book(system_id=a.id, page_count=5) + make_book(system_id=b.id, page_count=50) + rows = client.get( + "/api/systems?sort=page_count&order=desc", headers=admin_headers + ).json() + ordered = self._slugs(rows, "zsort-") + assert ordered.index("zsort-b") < ordered.index("zsort-a") + + def test_sort_by_name_desc(self, client, admin_headers): + make_game_system(name="Alpha Name", slug="namesort-alpha") + make_game_system(name="Beta Name", slug="namesort-beta") + rows = client.get("/api/systems?sort=name&order=desc", headers=admin_headers).json() + ordered = self._slugs(rows, "namesort-") + assert ordered.index("namesort-beta") < ordered.index("namesort-alpha") + + +class TestSystemFilter: + def test_filter_by_genre(self, client, admin_headers): + make_game_system(name="GenreFilt", slug="genrefilt", genres=["Steampunk"]) + rows = client.get("/api/systems?genre=Steampunk", headers=admin_headers).json() + assert any(s["slug"] == "genrefilt" for s in rows) + assert all("Steampunk" in (s.get("genres") or []) for s in rows) + + def test_filter_by_family(self, client, admin_headers): + make_game_system(name="FamFilt", slug="famfilt", system_family="Cypher System") + rows = client.get( + "/api/systems?family=Cypher System", headers=admin_headers + ).json() + assert any(s["slug"] == "famfilt" for s in rows) + + def test_filter_by_parent_system(self, client, admin_headers): + make_game_system( + name="ParentFilt", slug="parentfilt", parent_system="Dungeons & Dragons" + ) + make_game_system(name="OtherPar", slug="otherpar", parent_system="Cyberpunk") + rows = client.get( + "/api/systems?parent_system=Dungeons %26 Dragons", headers=admin_headers + ).json() + slugs = [s["slug"] for s in rows] + assert "parentfilt" in slugs + assert "otherpar" not in slugs + + def test_filter_by_edition(self, client, admin_headers): + make_game_system( + name="EdFilt", slug="edfilt", parent_system="Cyberpunk", edition="Red" + ) + rows = client.get("/api/systems?edition=Red", headers=admin_headers).json() + assert any(s["slug"] == "edfilt" for s in rows) + + def test_filter_by_license(self, client, admin_headers): + make_game_system(name="LicFilt", slug="licfilt", license="OGL 1.0a") + rows = client.get( + "/api/systems?license=OGL 1.0a", headers=admin_headers + ).json() + assert any(s["slug"] == "licfilt" for s in rows) + + def test_filter_explicit(self, client, admin_headers): + make_game_system(name="ExplFilt", slug="explfilt", is_explicit=True) + rows = client.get("/api/systems?explicit=true", headers=admin_headers).json() + assert all(s["is_explicit"] for s in rows) + assert any(s["slug"] == "explfilt" for s in rows) + + +class TestBookMetadata: + def test_book_new_fields(self, client, admin_headers): + sysobj = make_game_system(name="BookMeta Sys", slug="bookmeta-sys") + book = make_book( + system_id=sysobj.id, + artists=["Jane Artist"], + genres=["Grimdark"], + isbn="978-3-16-148410-0", + version="1.2", + language="en", + year=2019, + month=3, + day=14, + ) + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + b = next(x for x in got["books"] if x["id"] == book.id) + assert b["artists"] == ["Jane Artist"] + assert b["genres"] == ["Grimdark"] + assert b["isbn"] == "978-3-16-148410-0" + assert b["version"] == "1.2" + assert b["month"] == 3 + assert b["day"] == 14 + + def test_book_license_override(self, client, admin_headers): + # A book can carry its own license (e.g. an OGL SRD in a proprietary system). + sysobj = make_game_system( + name="LicSys", slug="licsys", license="Proprietary / All Rights Reserved" + ) + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"license": "OGL 1.0a"}, headers=admin_headers + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + b = next(x for x in got["books"] if x["id"] == book.id) + assert b["license"] == "OGL 1.0a" + + def test_book_update_month_validation(self, client, admin_headers): + sysobj = make_game_system(name="BadDate Sys", slug="baddate-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"month": 13}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_month_zero_rejected(self, client, admin_headers): + sysobj = make_game_system(name="ZeroMonth Sys", slug="zeromonth-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"month": 0}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_day_out_of_range(self, client, admin_headers): + sysobj = make_game_system(name="BadDay Sys", slug="badday-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"day": 32}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_valid_full_date(self, client, admin_headers): + sysobj = make_game_system(name="GoodDate Sys", slug="gooddate-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", + json={"year": 2020, "month": 6, "day": 15, "genres": [" Fantasy ", "fantasy"]}, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/books/{book.id}", headers=admin_headers).json() + assert (got["year"], got["month"], got["day"]) == (2020, 6, 15) + # Genres trimmed and de-duplicated case-insensitively. + assert got["genres"] == ["Fantasy"] + + def test_book_url_backfill_field(self, client, admin_headers): + sysobj = make_game_system(name="BookUrl Sys", slug="bookurl-sys") + book = make_book(system_id=sysobj.id) + client.patch( + f"/api/books/{book.id}", + json={"urls": [{"label": "DTRPG", "url": "http://x"}]}, + headers=admin_headers, + ) + got = client.get(f"/api/books/{book.id}", headers=admin_headers).json() + assert got["urls"][0]["label"] == "DTRPG" + + +class TestBookSortFilter: + def test_book_sort_by_page_count(self, client, admin_headers): + sysobj = make_game_system(name="BookSort Sys", slug="booksort-sys") + make_book(system_id=sysobj.id, title="Small", page_count=3) + make_book(system_id=sysobj.id, title="Large", page_count=300) + got = client.get( + f"/api/systems/{sysobj.id}?book_sort=page_count&book_order=desc", + headers=admin_headers, + ).json() + titles = [b["title"] for b in got["books"]] + assert titles.index("Large") < titles.index("Small") + + def test_book_filter_explicit(self, client, admin_headers): + sysobj = make_game_system(name="BookExpl Sys", slug="bookexpl-sys") + make_book(system_id=sysobj.id, title="Clean", is_explicit=False) + make_book(system_id=sysobj.id, title="Spicy", is_explicit=True) + got = client.get( + f"/api/systems/{sysobj.id}?explicit=true", headers=admin_headers + ).json() + titles = [b["title"] for b in got["books"]] + assert "Spicy" in titles and "Clean" not in titles diff --git a/docs/api.md b/docs/api.md index f8bf0d8..a9c21d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -209,21 +209,27 @@ Returns `{"status": "not_running"}` if no scan is in progress. Cancellation is c | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| -| `/api/systems` | GET | any | List all systems with book counts | -| `/api/systems/:id` | GET | any | System detail + full book list | +| `/api/systems` | GET | any | List all systems with book counts, `total_page_count`, and metadata. Query: `sort` (`name`\|`book_count`\|`page_count`\|`year`), `order` (`asc`\|`desc`), `genre`, `family`, `parent_system`, `edition`, `license`, `explicit` (bool) | +| `/api/systems/:id` | GET | any | System detail + full book list. Query: `book_sort` (`category`\|`title`\|`page_count`\|`year`), `book_order`, `explicit` (bool), `genre`, `category` filter the returned books | | `/api/systems/:id` | PATCH | gm/admin | Update metadata (see fields below) | -**PATCH fields:** `name`, `slug`, `description`, `publishers`, `character_builder_url`, `cover_image`, `cover_book_id`, `tags`, `genre`, `is_explicit` +**PATCH fields:** `name`, `slug`, `description`, `publishers`, `character_builder_url` (legacy), `character_builder_urls`, `urls`, `cover_image`, `cover_book_id`, `tags`, `genre` (legacy), `genres`, `dice_materials`, `system_family`, `parent_system`, `edition`, `license`, `year`, `is_explicit` **Publishers format:** `[{"name": "Publisher Name", "url": "https://..."}]` +**Link-list format** (`urls`, `character_builder_urls`): `[{"label": "DriveThruRPG", "url": "https://..."}]` + +**Multi-value metadata** (issue #202): `genres` and `dice_materials` are string arrays; `genres` supersedes the legacy single `genre`, and `urls`/`character_builder_urls` supersede the legacy single-URL fields (the legacy fields remain accepted for backward compatibility). Systems in the special one-page collection carry `is_one_page: true` (grouped with `is_system_agnostic` in the library UI). + +**Parent system / edition:** `parent_system` (e.g. `"Dungeons & Dragons"`) is the mid-tier grouping between the broad `system_family` (`"d20 System"`) and a concrete system; `edition` (`"5e"`, `"Red"`, `"2020"`) combines with it for display (`"Cyberpunk Red"`). Both are free-text; `parent_system` values are curated via the `/api/parent-systems` lookup. Both are filterable on `/api/systems`. + ### Books | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/books` | GET | any | Paginated book list. Query: `system_id`, `category`, `limit` (max 500, default 100), `offset` | | `/api/books/:id` | GET | any | Book detail with game system | -| `/api/books/:id` | PATCH | gm/admin | Update: `title`, `category`, `description`, `authors`, `publisher`, `publisher_url`, `year`, `is_explicit` | +| `/api/books/:id` | PATCH | gm/admin | Update: `title`, `category`, `description`, `authors`, `artists`, `genres`, `publisher`, `publisher_url` (legacy), `urls`, `isbn`, `version`, `language`, `license`, `year`, `month` (1–12), `day` (1–31), `tags`, `is_explicit`. `license` overrides the system license for this book (blank inherits it). `file_size`/`page_count`/`mime_type` are read-only. | | `/api/books/:id/reindex` | POST | gm/admin | Re-run OCR on a scanned book. Optional query `ocr_dpi` (72–600) re-reads this book at a higher resolution than the global `OCR_DPI`; omit for the default. Clears the book's search index and re-queues it (OCR runs in the background — poll `/api/scan-status`). 400 if the book has an embedded text layer (nothing to OCR). Returns `{status: "reindex_queued", ocr_dpi}`. | | `/api/books/:id/rescan` | POST | gm/admin | Re-read a single book from disk and rebuild its search index, for a file edited externally. Unlike `/reindex` this works for any PDF: a text-layer book is re-extracted and its FTS rows rebuilt; an image-only book is re-queued for OCR. Refreshes page count and cover thumbnail if the file changed. Runs in the background (poll `/api/scan-status`); no-ops if a library scan is already running. 400 for non-PDFs, 404 if the file is missing on disk. Returns `{status: "rescan_queued"}`. | | `/api/books/:id/file` | GET | any | Download/stream the file | @@ -239,6 +245,34 @@ Returns `{"status": "not_running"}` if no scan is in progress. Cancellation is c **Categories:** `core`, `supplement`, `adventure`, `character-sheet`, `map`, `handout`, `homebrew`, `starter-set` +### Metadata lookups (genres, families, parent systems, licenses, dice/materials) + +Curated reference values that power the editor pickers/comboboxes and the +"Metadata" settings tab (issue #202). Reads are open to any authenticated user; +mutations require admin. Every list is managed in **Settings → Metadata**, where +each section is collapsible. + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/genres` | GET | any | `{"genres": [{id, name, parent_id, is_default, sort_order}]}`. Tiered via `parent_id` (e.g. Cyberpunk → Science Fiction). | +| `/api/genres` | POST | admin | Create a genre. Body `{name, parent_id?}`. 409 if the name exists. | +| `/api/genres/:id` | DELETE | admin | Delete a genre (and its children). 409 with `{detail: {message, name, usage_count}}` if attached to a system/book, unless `?force=true`. | +| `/api/system-families` | GET | any | `{"families": [{id, name, is_default, sort_order}]}` | +| `/api/system-families` | POST | admin | Create a family. Body `{name}`. 409 if the name exists. | +| `/api/system-families/:id` | DELETE | admin | Delete a family. 409 if in use unless `?force=true`. | +| `/api/parent-systems` | GET | any | `{"parent_systems": [{id, name, is_default, sort_order}]}`. Empty by default (library-specific). | +| `/api/parent-systems` | POST | admin | Create a parent system. Body `{name}`. 409 if the name exists. | +| `/api/parent-systems/:id` | DELETE | admin | Delete a parent system. 409 if in use unless `?force=true`. | +| `/api/licenses` | GET | any | `{"licenses": [{id, name, is_default, sort_order}]}`. Seeded with common TTRPG licenses (OGL, ORC, CC-BY, Proprietary, …). | +| `/api/licenses` | POST | admin | Create a license. Body `{name}`. 409 if the name exists. | +| `/api/licenses/:id` | DELETE | admin | Delete a license. 409 if used by a system or book unless `?force=true`. | +| `/api/dice-materials` | GET | any | `{"dice_materials": [{id, name, group, is_default, sort_order}]}`. `group` is one of `Dice`\|`Cards`\|`Other`\|`Custom`. Sources the editor's dice/materials picker options. | +| `/api/dice-materials` | POST | admin | Create a dice/material. Body `{name, group?}` (defaults to `Custom`). 409 if the name exists. The editor picker best-effort POSTs here (as group `Custom`) when an admin types a new value, so it becomes reusable. | +| `/api/dice-materials/:id` | DELETE | admin | Delete a dice/material. 409 if in use unless `?force=true`. | + +Defaults for both tables are seeded on migration and are removable. A genre or +family removed while attached to systems/books is detached from them (`?force=true`). + ### Maps | Endpoint | Method | Auth | Description | @@ -290,6 +324,22 @@ Audio tracks behave like maps/tokens, with embedded metadata. Supported formats: Item types: `book`, `map`, `token`, `audio`, `system` +### Saved filters + +Per-user named sort/filter presets for a library scope. At most one preset per +(user, scope) may be the **default** — the view the user lands on. Setting a +preset default clears the flag on any sibling in the same scope. + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/saved-filters` | GET | any | List the user's saved filters. Optional query `scope` limits to one scope. Returns `{filters: [{id, scope, name, state, is_default}]}` | +| `/api/saved-filters` | POST | any | Create a preset. Body `{scope, name, state, is_default?}`. Re-saving an existing `(scope, name)` overwrites its `state`. | +| `/api/saved-filters/:id` | PATCH | any | Rename, replace `state`, and/or set as the scope default. Body `{name?, state?, is_default?}` | +| `/api/saved-filters/:id` | DELETE | any | Delete one of the user's saved filters | + +Scopes: `systems`, `books`, `maps`, `tokens`, `audio`. `state` is an opaque +sort/filter object the client interprets (e.g. `{sort, order, filters}`). + ### Bookmarks Bookmarks are per-user - users cannot see or modify each other's bookmarks. diff --git a/docs/data-model.md b/docs/data-model.md index e72682e..3ae1b9c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -23,6 +23,7 @@ erDiagram users ||--o{ campaign_members : "member of" users ||--o{ bookmarks : has users ||--o{ favorites : has + users ||--o{ saved_filters : has users ||--o{ session_availability : declares users ||--o{ player_session_notes : writes users ||--o{ wiki_pages : "created by" @@ -33,6 +34,8 @@ erDiagram game_systems ||--o{ books : contains game_systems ||--o{ campaigns : "system for" + genres ||--o{ genres : "parent of" + books ||--o{ bookmarks : "bookmarked in" campaigns ||--o{ campaign_members : has @@ -61,16 +64,19 @@ erDiagram ## Foreign keys -There are 32 `ForeignKey` declarations across the models, plus the two self-referential -keys (`campaigns.parent_campaign_id`, `wiki_pages.parent_id`) and the polymorphic soft -links from `campaign_resources`/`favorites`, which are *not* declared foreign keys. +There are 34 `ForeignKey` declarations across the models, plus the three self-referential +keys (`campaigns.parent_campaign_id`, `wiki_pages.parent_id`, `genres.parent_id`) and the +polymorphic soft links from `campaign_resources`/`favorites`, which are *not* declared +foreign keys. | From (table.column) | To (table.column) | Notes | | --- | --- | --- | | `books.game_system_id` | `game_systems.id` | nullable; a book may be unassigned | +| `genres.parent_id` | `genres.id` | self-referential; nullable (tiered genres) | | `bookmarks.user_id` | `users.id` | | | `bookmarks.book_id` | `books.id` | | | `favorites.user_id` | `users.id` | `item_id` is a soft link (not a FK) | +| `saved_filters.user_id` | `users.id` | per-user sort/filter presets | | `campaigns.owner_id` | `users.id` | the GM / creator | | `campaigns.parent_campaign_id` | `campaigns.id` | self-referential; nullable | | `campaigns.system_id` | `game_systems.id` | nullable; falls back to `system_name` | @@ -106,9 +112,14 @@ links from `campaign_resources`/`favorites`, which are *not* declared foreign ke | Table | Purpose | Key columns / constraints | | --- | --- | --- | -| `game_systems` | A TTRPG system (D&D 5e, PbtA, …). | `name`, `slug` unique. `is_system_agnostic` flags cross-system content. | -| `books` | One PDF/document in the library. | `filepath` unique. `game_system_id` FK. Index `ix_books_indexer_queue` on `(indexed, mime_type)` drives the indexer. `indexed`/`index_failed`/`is_missing` track scan state. `index_error` holds the failure message, or the sentinel `image-only` (no text layer, OCR unavailable) / `ocr` (indexed via OCR). `ocr_pending` (indexed `ix_books_ocr_pending`) flags a scanned PDF queued for deferred OCR; `ocr_pages_done` is the per-page OCR checkpoint so a long book resumes rather than restarts after an interruption. `ocr_dpi` is an optional per-book OCR resolution override (NULL = global `OCR_DPI`), set when a book is re-OCR'd at a higher DPI via `POST /api/books/{id}/reindex`. | +| `game_systems` | A TTRPG system (D&D 5e, PbtA, …). | `name`, `slug` unique. `is_system_agnostic` flags cross-system content; `is_one_page` flags the special one-page/small-RPG collection (both grouped together in the library UI). Metadata (issue #202): `genres` (JSON list; supersedes the legacy scalar `genre`), `dice_materials` (JSON list), `system_family`, `parent_system` (mid-tier grouping, e.g. "Dungeons & Dragons"), `edition` (e.g. "5e"/"Red"; combines with `parent_system` for display), `license`, `year`, `urls` and `character_builder_urls` (JSON lists of `{label, url}`; supersede the legacy scalar `character_builder_url`). | +| `books` | One PDF/document in the library. | `filepath` unique. `game_system_id` FK. Index `ix_books_indexer_queue` on `(indexed, mime_type)` drives the indexer. `indexed`/`index_failed`/`is_missing` track scan state. `index_error` holds the failure message, or the sentinel `image-only` (no text layer, OCR unavailable) / `ocr` (indexed via OCR). `ocr_pending` (indexed `ix_books_ocr_pending`) flags a scanned PDF queued for deferred OCR; `ocr_pages_done` is the per-page OCR checkpoint so a long book resumes rather than restarts after an interruption. `ocr_dpi` is an optional per-book OCR resolution override (NULL = global `OCR_DPI`), set when a book is re-OCR'd at a higher DPI via `POST /api/books/{id}/reindex`. Metadata (issue #202): `artists` and `genres` (JSON lists), `isbn`, `version`, `language`, `license` (per-book override of the system license — an OGL SRD inside a proprietary system), `urls` (JSON list of `{label, url}`; supersedes the legacy scalar `publisher_url`), and a variable-precision publication date `year`/`month`/`day` (all nullable — `year` may stand alone). | | `book_folders` | Tags auto-applied to a book subcategory folder path. | `path` unique. | +| `genres` | Curated genre lookup, tiered via a self-referential `parent_id` (e.g. Cyberpunk → Science Fiction). | `name` unique. `is_default` marks seeded rows; `sort_order` orders siblings. Children cascade-delete. | +| `system_families` | Curated system-family / engine lookup (PbtA, d20, Year Zero, …). | `name` unique. `is_default`, `sort_order`. | +| `parent_systems` | Curated parent-system lookup — the mid tier between a `system_family` and a concrete system (e.g. "Dungeons & Dragons"). | `name` unique. `is_default`, `sort_order`. Seeded empty. | +| `licenses` | Curated license lookup (OGL, ORC, CC-BY, Proprietary, …), used by systems and per-book overrides. | `name` unique. `is_default`, `sort_order`. | +| `dice_materials` | Curated dice / materials lookup for the system picker. | `name` unique. `group` (`Dice`\|`Cards`\|`Other`\|`Custom`). `is_default`, `sort_order`. | ### Media - [`backend/models/media.py`](../backend/models/media.py) @@ -129,6 +140,7 @@ None of these tables carry foreign keys; they are linked to campaigns polymorphi | `users` | An authenticated account. | `username` unique; `email`, `opds_token`, `oidc_subject` unique + indexed. `role` ∈ `admin`/`gm`/`player`/`guest`. `is_guest` marks campaign-scoped guest accounts. | | `bookmarks` | Per-user page/text bookmark in a book. | FKs `user_id`, `book_id`. Index `ix_bookmarks_user_book` on `(user_id, book_id)`. | | `favorites` | Per-user favorite across books/maps/tokens. | FK `user_id`. Polymorphic `(item_type, item_id)`. **Unique** `(user_id, item_type, item_id)`. | +| `saved_filters` | Per-user named sort/filter preset for a library scope. | FK `user_id` (indexed). `scope` ∈ systems/books/maps/tokens/audio. `state` JSON holds the sort/filter object. `is_default` marks the per-scope landing view (at most one per scope, enforced in the router). **Unique** `(user_id, scope, name)`. | ### Campaigns - [`backend/models/campaigns.py`](../backend/models/campaigns.py) diff --git a/frontend/src/components/BulkEditModal.jsx b/frontend/src/components/BulkEditModal.jsx index aab23a0..32e7f4e 100644 --- a/frontend/src/components/BulkEditModal.jsx +++ b/frontend/src/components/BulkEditModal.jsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { LuX, LuChevronLeft, LuChevronRight } from 'react-icons/lu' import api from '../api' import SystemBulkEditFields from './system/SystemBulkEditFields' +import BookBulkEditFields from './system/BookBulkEditFields' +import { cleanLinks } from './metadata/metadataUtils' // Per-type editable fields and the PATCH endpoint they save to. Tags are edited // as a comma-separated string and split on save. @@ -21,19 +23,45 @@ const CONFIG = { }, book: { endpoint: (id) => `/books/${id}`, - fields: ['title', 'category', 'description', 'publisher', 'year', 'tags', 'is_explicit'], + // Books use a bespoke editor body (BookBulkEditFields) mirroring the full + // single-book editor, so genres/tags/authors/artists/links stay native + // arrays and category uses the shared combobox. + fields: [ + 'title', + 'description', + 'category', + 'genres', + 'tags', + 'urls', + 'authors', + 'artists', + 'publisher', + 'isbn', + 'version', + 'language', + 'year', + 'month', + 'day', + 'is_explicit', + ], + custom: true, }, system: { endpoint: (id) => `/systems/${id}`, - // Systems use a bespoke editor body (SystemBulkEditFields) rather than the - // generic field loop, so tags/publishers stay native arrays and the cover - // image can be picked from each system's own books. + // Systems use a bespoke editor body (SystemBulkEditFields) that mirrors the + // full single-system editor, so tags/publishers/genres/links stay native + // arrays and the cover image can be picked from each system's own books. fields: [ 'description', 'tags', + 'genres', + 'dice_materials', + 'system_family', + 'license', + 'year', 'publishers', - 'character_builder_url', - 'genre', + 'urls', + 'character_builder_urls', 'is_explicit', 'cover_book_id', ], @@ -43,7 +71,15 @@ const CONFIG = { // Fields that are stored as arrays/objects rather than strings — kept as native // values in the draft (not stringified) and compared by JSON on save. -const STRUCTURED_FIELDS = new Set(['publishers']) +const STRUCTURED_FIELDS = new Set([ + 'publishers', + 'genres', + 'dice_materials', + 'urls', + 'character_builder_urls', + 'authors', + 'artists', +]) // Pull a grid size like "22x22" out of a map's filename or folder, e.g. // "Sunken Temple (22x22)" → "22x22". Used to pre-fill an empty grid size. @@ -56,6 +92,15 @@ const inferGridSize = (item) => { return '' } +// Normalize a structured field's draft value before compare/save. +const cleanStructured = (field, value) => { + const list = value || [] + if (field === 'publishers') return list.filter((p) => p.name?.trim()) + if (field === 'urls' || field === 'character_builder_urls') return cleanLinks(list) + // genres / dice_materials are plain string arrays, already trimmed in the UI. + return list +} + const tagsToString = (tags) => (Array.isArray(tags) ? tags.join(', ') : '') const stringToTags = (s) => s @@ -69,7 +114,14 @@ const stringToTags = (s) => * via its single-item PATCH endpoint and calls `onSaved` with a map of * { id: changedFields } so the parent view can patch local state. */ -export default function BulkEditModal({ type, items, onClose, onSaved }) { +export default function BulkEditModal({ + type, + items, + onClose, + onSaved, + existingCategories = [], + systemGenres = [], +}) { const { t } = useTranslation() const cfg = CONFIG[type] const [index, setIndex] = useState(0) @@ -133,17 +185,16 @@ export default function BulkEditModal({ type, items, onClose, onSaved }) { const next = cfg.custom ? d.tags : stringToTags(d.tags) if (tagsToString(next) !== tagsToString(it.tags)) patch.tags = next } else if (STRUCTURED_FIELDS.has(f)) { - // Drop empty publisher rows before comparing/saving. - const next = (d[f] || []).filter((p) => p.name?.trim()) + const next = cleanStructured(f, d[f]) if (JSON.stringify(next) !== JSON.stringify(it[f] || [])) patch[f] = next } else if (f === 'is_explicit') { if (!!d.is_explicit !== !!it.is_explicit) patch.is_explicit = !!d.is_explicit } else if (f === 'cover_book_id') { if ((d.cover_book_id ?? null) !== (it.cover_book_id ?? null)) patch.cover_book_id = d.cover_book_id ?? null - } else if (f === 'year') { - const next = d.year === '' ? null : Number(d.year) - if (next !== (it.year ?? null)) patch.year = next + } else if (f === 'year' || f === 'month' || f === 'day') { + const next = d[f] === '' || d[f] == null ? null : Number(d[f]) + if (next !== (it[f] ?? null)) patch[f] = next } else if ((d[f] ?? '') !== (it[f] ?? '')) { patch[f] = d[f] } @@ -208,6 +259,13 @@ export default function BulkEditModal({ type, items, onClose, onSaved }) { {cfg.custom && type === 'system' ? ( + ) : cfg.custom && type === 'book' ? ( + ) : (
{cfg.fields.map((f) => { diff --git a/frontend/src/components/BulkEditModal.test.jsx b/frontend/src/components/BulkEditModal.test.jsx index f87988e..9cef90b 100644 --- a/frontend/src/components/BulkEditModal.test.jsx +++ b/frontend/src/components/BulkEditModal.test.jsx @@ -3,9 +3,19 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import BulkEditModal from './BulkEditModal' const patch = vi.fn(() => Promise.resolve({})) -const get = vi.fn(() => Promise.resolve({ books: [] })) +const post = vi.fn(() => Promise.resolve({ id: 'g1', name: 'Fantasy' })) +// useLookups fetches /genres and /system-families; return empty lookup lists. +const get = vi.fn((path) => { + if (path?.includes('genres')) return Promise.resolve({ genres: [] }) + if (path?.includes('system-families')) return Promise.resolve({ families: [] }) + return Promise.resolve({ books: [] }) +}) vi.mock('../api', () => ({ - default: { patch: (...args) => patch(...args), get: (...args) => get(...args) }, + default: { + patch: (...args) => patch(...args), + get: (...args) => get(...args), + post: (...args) => post(...args), + }, mediaUrl: (p) => p, })) @@ -53,23 +63,27 @@ describe('BulkEditModal', () => { expect(onSaved).toHaveBeenCalledWith({ m1: { tags: ['old', 'new'] } }) }) - it('edits a system genre via the /systems endpoint', async () => { + it('edits system genres via the genre combobox', async () => { const onSaved = vi.fn() // Seed `books` so the cover picker doesn't lazy-fetch. const systems = [ - { id: 's1', name: 'Alpha', tags: ['osr'], genre: '', is_explicit: false, books: [] }, + { id: 's1', name: 'Alpha', tags: ['osr'], genres: [], is_explicit: false, books: [] }, ] render() expect(screen.getByText('Alpha')).toBeInTheDocument() - // Genre is a labelled field in the rich system editor. - fireEvent.change(screen.getByLabelText('Genre'), { target: { value: 'Fantasy' } }) + // Genres use the shared GenrePicker combobox (aria-label "Add genre"): type + // then pick the create row. + const combo = screen.getByRole('combobox', { name: /add genre/i }) + fireEvent.change(combo, { target: { value: 'Fantasy' } }) + fireEvent.click(await screen.findByRole('option', { name: /Fantasy/ })) + fireEvent.click(screen.getByText('Save all')) await waitFor(() => expect(onSaved).toHaveBeenCalled()) - expect(patch).toHaveBeenCalledWith('/systems/s1', { genre: 'Fantasy' }) - expect(onSaved).toHaveBeenCalledWith({ s1: { genre: 'Fantasy' } }) + expect(patch).toHaveBeenCalledWith('/systems/s1', { genres: ['Fantasy'] }) + expect(onSaved).toHaveBeenCalledWith({ s1: { genres: ['Fantasy'] } }) }) it('edits system description, publishers, and explicit flag', async () => { diff --git a/frontend/src/components/BulkToggleButton.jsx b/frontend/src/components/BulkToggleButton.jsx new file mode 100644 index 0000000..02c4cec --- /dev/null +++ b/frontend/src/components/BulkToggleButton.jsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { LuListChecks } from 'react-icons/lu' +import ToolbarButton from './ToolbarButton' + +/** + * Shared "Select" / "Cancel" toggle used on every library-style page. + * Keeps a stable width regardless of state (the label swaps between two words) + * and standardizes the off-state label to "Cancel" across all pages. + */ +export default function BulkToggleButton({ active, onToggle, style }) { + const { t } = useTranslation() + return ( + } + label={active ? t('common.cancel') : t('common.select')} + onClick={onToggle} + active={active} + minWidth={96} + style={style} + /> + ) +} diff --git a/frontend/src/components/BulkToggleButton.test.jsx b/frontend/src/components/BulkToggleButton.test.jsx new file mode 100644 index 0000000..ed581fe --- /dev/null +++ b/frontend/src/components/BulkToggleButton.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BulkToggleButton from './BulkToggleButton' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => ({ 'common.select': 'Select', 'common.cancel': 'Cancel' })[k] || k, + }), +})) + +describe('BulkToggleButton', () => { + it('shows "Select" when inactive and "Cancel" when active', () => { + const { rerender } = render( {}} />) + expect(screen.getByText('Select')).toBeInTheDocument() + rerender( {}} />) + expect(screen.getByText('Cancel')).toBeInTheDocument() + }) + + it('keeps a stable width across states (minWidth set)', () => { + render( {}} />) + expect(screen.getByText('Select').closest('button').style.minWidth).toBe('96px') + }) + + it('fires onToggle when clicked', async () => { + const onToggle = vi.fn() + render() + await userEvent.click(screen.getByText('Select')) + expect(onToggle).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/CollapseExpandButtons.jsx b/frontend/src/components/CollapseExpandButtons.jsx new file mode 100644 index 0000000..e48db94 --- /dev/null +++ b/frontend/src/components/CollapseExpandButtons.jsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import ToolbarButton from './ToolbarButton' + +/** + * Shared "Collapse All" / "Expand All" pair used by the system detail and media + * gallery toolbars. Each button disables when the action would be a no-op. + */ +export default function CollapseExpandButtons({ + onCollapseAll, + onExpandAll, + collapseDisabled = false, + expandDisabled = false, +}) { + const { t } = useTranslation() + return ( + <> + + + + ) +} diff --git a/frontend/src/components/CollapseExpandButtons.test.jsx b/frontend/src/components/CollapseExpandButtons.test.jsx new file mode 100644 index 0000000..e79f06d --- /dev/null +++ b/frontend/src/components/CollapseExpandButtons.test.jsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CollapseExpandButtons from './CollapseExpandButtons' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => ({ 'common.collapseAll': 'Collapse All', 'common.expandAll': 'Expand All' })[k] || k, + }), +})) + +describe('CollapseExpandButtons', () => { + it('fires the collapse and expand callbacks', async () => { + const onCollapseAll = vi.fn() + const onExpandAll = vi.fn() + render() + await userEvent.click(screen.getByText('Collapse All')) + await userEvent.click(screen.getByText('Expand All')) + expect(onCollapseAll).toHaveBeenCalled() + expect(onExpandAll).toHaveBeenCalled() + }) + + it('disables each button independently', () => { + render( + {}} + onExpandAll={() => {}} + collapseDisabled + expandDisabled={false} + /> + ) + expect(screen.getByText('Collapse All').closest('button')).toBeDisabled() + expect(screen.getByText('Expand All').closest('button')).not.toBeDisabled() + }) +}) diff --git a/frontend/src/components/IconBtn.test.jsx b/frontend/src/components/IconBtn.test.jsx new file mode 100644 index 0000000..41c15e9 --- /dev/null +++ b/frontend/src/components/IconBtn.test.jsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import IconBtn from './IconBtn' + +describe('IconBtn', () => { + it('renders its children and title and fires onClick', async () => { + const onClick = vi.fn() + render( + + + + + ) + const btn = screen.getByTitle('Zoom in') + expect(btn).toHaveTextContent('+') + await userEvent.click(btn) + expect(onClick).toHaveBeenCalled() + }) + + it('applies the active styling and extra style overrides', () => { + render( + + b + + ) + const btn = screen.getByTitle('B') + expect(btn.style.color).toBe('var(--gold)') + expect(btn.style.width).toBe('50px') + }) +}) diff --git a/frontend/src/components/TagSection.test.jsx b/frontend/src/components/TagSection.test.jsx new file mode 100644 index 0000000..32e600c --- /dev/null +++ b/frontend/src/components/TagSection.test.jsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagSection from './TagSection' + +describe('TagSection', () => { + it('renders the label and tag pills', () => { + render() + expect(screen.getByText('Tags')).toBeInTheDocument() + expect(screen.getByText('forest')).toBeInTheDocument() + expect(screen.getByText('cave')).toBeInTheDocument() + }) + + it('shows the no-tags label when empty', () => { + render() + expect(screen.getByText('No tags yet')).toBeInTheDocument() + }) + + it('renders an edit button when editable and fires onEdit', async () => { + const onEdit = vi.fn() + render( + + ) + await userEvent.click(screen.getByText('Edit')) + expect(onEdit).toHaveBeenCalled() + }) + + it('hides the edit button when not editable', () => { + render() + expect(screen.queryByText('Edit')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ToggleSwitch.jsx b/frontend/src/components/ToggleSwitch.jsx new file mode 100644 index 0000000..f9df62b --- /dev/null +++ b/frontend/src/components/ToggleSwitch.jsx @@ -0,0 +1,74 @@ +/** + * A small on/off switch styled as a toggle. + * + * Props: + * - labelFirst: render the label before the switch (default: switch then label) + * - pill: wrap the whole control in a bordered, button-like pill so clicking + * anywhere on it toggles (the whole thing is a
@@ -324,6 +331,10 @@ export default function SystemCard({
+ {/* Genres are shown before tags (issue #202), in the genre colour. */} + {(system.genres || []).slice(0, 3).map((g) => ( + + ))} {(system.tags || []).slice(0, 4).map((tag) => onTagClick && !selectable ? (
+ {/* Sort + filter toolbar (right-aligned), below the header. */} {!bulkMode && ( - +
+ +
)} {bulkMode && ( @@ -97,30 +131,60 @@ export default function GalleryLayout({

)} - {gallery.folderEntries.map(([folder, subfolders]) => ( - {} : gallery.setEditingFolder} - onSaveFolderTags={isPlayer ? () => {} : gallery.saveFolderTags} - canTag={!isPlayer} - onSelectItem={onSelectItem} - bulkMode={bulkMode} - selectedIds={gallery.selectedIds} - selectedFolderPaths={gallery.selectedFolderPaths} - onToggleItem={gallery.toggleSelect} - onToggleFolder={gallery.bulk.toggleFolder} - onDownload={onDownload} - /> - ))} + {/* Grouped by folder (default) or a single flat sorted grid. */} + {gallery.grouped + ? gallery.folderEntries.map(([folder, subfolders]) => ( + {} : gallery.setEditingFolder} + onSaveFolderTags={isPlayer ? () => {} : gallery.saveFolderTags} + canTag={!isPlayer} + onSelectItem={onSelectItem} + bulkMode={bulkMode} + selectedIds={gallery.selectedIds} + selectedFolderPaths={gallery.selectedFolderPaths} + onToggleItem={gallery.toggleSelect} + onToggleFolder={gallery.bulk.toggleFolder} + onDownload={onDownload} + /> + )) + : gallery.flatItems.length > 0 && ( + +
+ {gallery.flatItems.map((item) => ( + onSelectItem(item.id)} + bulkMode={bulkMode} + selected={gallery.selectedIds?.has(item.id)} + onToggle={(mods) => gallery.toggleSelect(item.id, mods)} + list={gallery.list} + /> + ))} +
+
+ )} {gallery.noFolders && (
diff --git a/frontend/src/components/media/GalleryLayout.test.jsx b/frontend/src/components/media/GalleryLayout.test.jsx index 2c9f1ff..f371138 100644 --- a/frontend/src/components/media/GalleryLayout.test.jsx +++ b/frontend/src/components/media/GalleryLayout.test.jsx @@ -3,26 +3,31 @@ import { render, screen, fireEvent } from '@testing-library/react' import GalleryLayout from './GalleryLayout' import { MEDIA_CONFIGS } from './mediaConfig' -// Fire the inline arrow handlers GalleryLayout passes down so they're covered. +// The toolbar has its own coverage; stub it so this test focuses on layout. vi.mock('./GalleryToolbar', () => ({ - default: ({ onCollapseAll, onExpandAll, onToggleFavOnly, onToggleBulk }) => ( -
-
- ), + default: ({ showBulk }) =>
, })) -vi.mock('./TagFilterBar', () => ({ default: () =>
})) vi.mock('./MediaFolderGroup', () => ({ default: ({ folder }) =>
{folder}
, })) +vi.mock('./MediaCard', () => ({ + default: ({ item, onClick, onToggle }) => ( + + ), +})) +vi.mock('../LazyGrid', () => ({ default: ({ children }) =>
{children}
})) vi.mock('../BulkActionBar', () => ({ default: () =>
})) const makeGallery = (over = {}) => ({ filter: '', setFilter: vi.fn(), + sortFilter: { sort: 'name', order: 'asc', filters: {} }, + setSortFilter: vi.fn(), + savedFilters: { saved: [], save: vi.fn(), setDefault: vi.fn(), remove: vi.fn(), loaded: true }, + grouped: true, + setGrouped: vi.fn(), bulk: { bulkMode: false, enter: vi.fn(), exit: vi.fn(), toggleFolder: vi.fn() }, noFolders: false, allCollapsed: false, @@ -32,12 +37,12 @@ const makeGallery = (over = {}) => ({ viewMode: 'grid', cycleViewMode: vi.fn(), favOnly: false, - setFavOnly: vi.fn(), allTags: [], selectedTags: new Set(), toggleTag: vi.fn(), clearTags: vi.fn(), folderEntries: [['Dungeons', {}]], + flatItems: [], cardSize: 'comfortable', list: false, collapsed: new Set(), @@ -69,43 +74,47 @@ const baseProps = (over = {}) => ({ }) describe('GalleryLayout', () => { - it('renders title, subtitle, toolbar, tag filter, and folder groups', () => { + it('renders title, subtitle, toolbar, and folder groups when grouped', () => { render() expect(screen.getByRole('heading', { name: 'Maps' })).toBeInTheDocument() expect(screen.getByText('Battle maps')).toBeInTheDocument() expect(screen.getByTestId('toolbar')).toBeInTheDocument() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() expect(screen.getByTestId('folder-group')).toHaveTextContent('Dungeons') expect(screen.queryByTestId('bulk-bar')).not.toBeInTheDocument() }) - it('wires the toolbar collapse/expand/favorite/bulk callbacks through', () => { - const gallery = makeGallery() - render() - fireEvent.click(screen.getByTestId('tb-collapse')) - expect(gallery.setCollapsed).toHaveBeenCalledWith(gallery.allKeys) - fireEvent.click(screen.getByTestId('tb-expand')) - expect(gallery.setCollapsed).toHaveBeenCalledWith(expect.any(Set)) - fireEvent.click(screen.getByTestId('tb-fav')) - expect(gallery.setFavOnly).toHaveBeenCalled() - fireEvent.click(screen.getByTestId('tb-bulk')) - expect(gallery.bulk.enter).toHaveBeenCalled() + it('renders a flat card grid (no folder groups) when grouping is off', () => { + const gallery = makeGallery({ + grouped: false, + flatItems: [ + { id: 'a', filename: 'goblin.png' }, + { id: 'b', filename: 'dragon.png' }, + ], + }) + const onSelectItem = vi.fn() + render() + expect(screen.queryByTestId('folder-group')).not.toBeInTheDocument() + const cards = screen.getAllByTestId('flat-card') + expect(cards.map((n) => n.textContent)).toEqual(['goblin.png', 'dragon.png']) + // Exercise the flat-card onClick / onToggle wiring. + fireEvent.click(cards[0]) + expect(onSelectItem).toHaveBeenCalledWith('a') + fireEvent.doubleClick(cards[1]) + expect(gallery.toggleSelect).toHaveBeenCalledWith('b', {}) }) - it('shows the bulk hint and bulk action bar in bulk mode (hiding the tag filter)', () => { + it('shows the bulk hint and bulk action bar in bulk mode', () => { const gallery = makeGallery({ bulk: { bulkMode: true, enter: vi.fn(), exit: vi.fn(), toggleFolder: vi.fn() }, }) render() expect(screen.getByTestId('bulk-bar')).toBeInTheDocument() - expect(screen.queryByTestId('tag-filter')).not.toBeInTheDocument() }) it('renders the empty state when there are no folders', () => { const gallery = makeGallery({ noFolders: true, folderEntries: [] }) render() expect(screen.queryByTestId('folder-group')).not.toBeInTheDocument() - // Empty message text comes from the maps config i18n keys (maps.noMaps). expect(screen.getByText(/No maps found/i)).toBeInTheDocument() }) @@ -116,7 +125,18 @@ describe('GalleryLayout', () => { expect(screen.getByText(/No maps match your filter/i)).toBeInTheDocument() }) - it('renders in player mode without crashing', () => { + it('shows the no-favourites message when favOnly is on and nothing matches', () => { + const gallery = makeGallery({ noFolders: true, folderEntries: [], favOnly: true }) + render() + expect(screen.getByText(/no favorites here yet/i)).toBeInTheDocument() + }) + + it('passes showBulk=false to the toolbar in player mode', () => { + render() + expect(screen.getByTestId('toolbar')).toHaveAttribute('data-showbulk', 'false') + }) + + it('renders folder groups in player mode', () => { render() expect(screen.getByTestId('folder-group')).toBeInTheDocument() }) diff --git a/frontend/src/components/media/GalleryToolbar.jsx b/frontend/src/components/media/GalleryToolbar.jsx index d719ac5..5399256 100644 --- a/frontend/src/components/media/GalleryToolbar.jsx +++ b/frontend/src/components/media/GalleryToolbar.jsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next' -import { LuX, LuListChecks, LuSearch, LuHeart } from 'react-icons/lu' import ViewModeToggle from '../ViewModeToggle' +import BulkToggleButton from '../BulkToggleButton' +import CollapseExpandButtons from '../CollapseExpandButtons' +import ToggleSwitch from '../ToggleSwitch' const toolBtnStyle = { padding: '6px 12px', @@ -13,140 +15,51 @@ const toolBtnStyle = { } /** - * Header controls for a media gallery: filter input, collapse/expand-all, - * bulk-select toggle, view-mode toggle, and favorites-only toggle. - * Shared by the maps and tokens views via `config` (mediaConfig.js). + * Header view-controls for a media gallery (maps / tokens / audio): bulk-select, + * view-mode, a folder-grouping switch, and collapse/expand-all. The standalone + * search box and the SortFilterBar (sort + filter modal) are rendered separately + * by GalleryLayout, mirroring the system/library toolbar arrangement. */ -export default function GalleryToolbar({ - config, - filter, - onFilter, - bulkMode, - onToggleBulk, - showBulk, - collapseDisabled, - expandDisabled, - onCollapseAll, - onExpandAll, - viewMode, - onCycleViewMode, - favOnly, - onToggleFavOnly, -}) { +export default function GalleryToolbar({ config, gallery, showBulk }) { const { t } = useTranslation() const { i18n } = config + const { bulkMode } = gallery.bulk return (
- {!bulkMode && ( -
- - onFilter(e.target.value)} - aria-label={t(`${i18n}.filterAriaLabel`)} - style={{ - width: '100%', - fontSize: 13, - padding: '6px 28px 6px 30px', - borderRadius: 6, - border: '1px solid var(--border)', - background: 'var(--bg-card)', - boxSizing: 'border-box', - }} - /> - {filter && ( - - )} -
- )} -
- - - {showBulk && ( - - )} - - -
+ {showBulk && } + {bulkMode && } + + + {t('sortFilter.groupByFolder')} + + } + /> + gallery.setCollapsed(gallery.allKeys)} + onExpandAll={() => gallery.setCollapsed(new Set())} + collapseDisabled={gallery.noFolders || bulkMode || !gallery.grouped || gallery.allCollapsed} + expandDisabled={gallery.noFolders || bulkMode || !gallery.grouped || gallery.allExpanded} + />
) } diff --git a/frontend/src/components/media/GalleryToolbar.test.jsx b/frontend/src/components/media/GalleryToolbar.test.jsx new file mode 100644 index 0000000..e3cf2ff --- /dev/null +++ b/frontend/src/components/media/GalleryToolbar.test.jsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GalleryToolbar from './GalleryToolbar' +import { MEDIA_CONFIGS } from './mediaConfig' + +const makeGallery = (over = {}) => ({ + bulk: { bulkMode: false, enter: vi.fn(), exit: vi.fn() }, + viewMode: 'grid', + cycleViewMode: vi.fn(), + grouped: true, + setGrouped: vi.fn(), + setCollapsed: vi.fn(), + allKeys: new Set(['a']), + noFolders: false, + allCollapsed: false, + allExpanded: false, + ...over, +}) + +const renderToolbar = (props = {}) => + render( + + ) + +describe('GalleryToolbar', () => { + it('renders the group-by-folder switch and view-mode control', () => { + renderToolbar() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('enters bulk mode via the bulk-select button when allowed', async () => { + const enter = vi.fn() + renderToolbar({ gallery: { bulk: { bulkMode: false, enter, exit: vi.fn() } } }) + const btns = screen.getAllByRole('button') + // The first button is the bulk-select toggle. + await userEvent.click(btns[0]) + expect(enter).toHaveBeenCalled() + }) + + it('hides the bulk-select button for players (showBulk=false)', () => { + const { rerender } = renderToolbar({ showBulk: false }) + // Still renders the group switch even without bulk controls. + expect(screen.getByRole('switch')).toBeInTheDocument() + rerender() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('toggles the folder grouping switch', async () => { + const setGrouped = vi.fn() + renderToolbar({ gallery: { setGrouped } }) + await userEvent.click(screen.getByRole('switch')) + expect(setGrouped).toHaveBeenCalled() + }) + + it('collapses all folders (setCollapsed with every key)', async () => { + const setCollapsed = vi.fn() + renderToolbar({ gallery: { setCollapsed, allKeys: new Set(['a', 'b']) } }) + await userEvent.click(screen.getByRole('button', { name: /collapse all/i })) + expect(setCollapsed).toHaveBeenCalledWith(new Set(['a', 'b'])) + }) + + it('expands all folders (setCollapsed with an empty set)', async () => { + const setCollapsed = vi.fn() + renderToolbar({ gallery: { setCollapsed, allCollapsed: true } }) + await userEvent.click(screen.getByRole('button', { name: /expand all/i })) + expect(setCollapsed).toHaveBeenCalledWith(new Set()) + }) + + it('exits bulk mode via the active bulk button', async () => { + const exit = vi.fn() + renderToolbar({ gallery: { bulk: { bulkMode: true, enter: vi.fn(), exit } } }) + // In bulk mode two bulk buttons render (enter + exit); the exit one calls exit(). + const btns = screen.getAllByRole('button') + await userEvent.click(btns[1]) + expect(exit).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/media/TagFilterBar.test.jsx b/frontend/src/components/media/TagFilterBar.test.jsx new file mode 100644 index 0000000..69dc0af --- /dev/null +++ b/frontend/src/components/media/TagFilterBar.test.jsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagFilterBar from './TagFilterBar' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'common.tags': 'Tags:', + 'common.clear': 'Clear', + 'common.showLess': 'Show less', + 'common.showMore': `+${o?.count} more`, + })[k] || k, + }), +})) + +describe('TagFilterBar', () => { + it('renders nothing when there are no tags', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders capitalized tag pills and toggles one', async () => { + const onToggle = vi.fn() + render( + + ) + const pill = screen.getByText('Forest') + await userEvent.click(pill) + expect(onToggle).toHaveBeenCalledWith('forest') + }) + + it('shows a clear button only when something is selected', async () => { + const onClear = vi.fn() + render( + + ) + await userEvent.click(screen.getByText('Clear')) + expect(onClear).toHaveBeenCalled() + }) + + it('limits visible tags and reveals the rest via show-more', async () => { + const tags = Array.from({ length: 20 }, (_, i) => `tag${i}`) + render() + // 16th+ tags hidden until "+5 more" is clicked. + expect(screen.queryByText('Tag19')).not.toBeInTheDocument() + await userEvent.click(screen.getByText('+5 more')) + expect(screen.getByText('Tag19')).toBeInTheDocument() + await userEvent.click(screen.getByText('Show less')) + expect(screen.queryByText('Tag19')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/media/mediaConfig.js b/frontend/src/components/media/mediaConfig.js index a8e983f..4a2d613 100644 --- a/frontend/src/components/media/mediaConfig.js +++ b/frontend/src/components/media/mediaConfig.js @@ -34,6 +34,7 @@ export const MEDIA_CONFIGS = { downloadType: 'maps', archiveType: 'map_folder', sessionKey: 'grimoire:maps:collapsed', + sortOptions: ['name', 'size'], // Grid cell min width per card size. gridMin: { comfortable: '200px', compact: '140px' }, gridGap: 16, @@ -69,6 +70,7 @@ export const MEDIA_CONFIGS = { downloadType: 'tokens', archiveType: 'token_folder', sessionKey: 'grimoire:tokens:collapsed', + sortOptions: ['name', 'size'], gridMin: { comfortable: '130px', compact: '90px' }, gridGap: 12, thumb: { kind: 'square' }, @@ -109,6 +111,7 @@ export const MEDIA_CONFIGS = { downloadType: 'audio', archiveType: 'audio_folder', sessionKey: 'grimoire:audio:collapsed', + sortOptions: ['title', 'name', 'duration', 'size'], gridMin: { comfortable: '200px', compact: '140px' }, gridGap: 16, thumb: { kind: 'square' }, diff --git a/frontend/src/components/metadata/CategoryPicker.jsx b/frontend/src/components/metadata/CategoryPicker.jsx new file mode 100644 index 0000000..06a2e02 --- /dev/null +++ b/frontend/src/components/metadata/CategoryPicker.jsx @@ -0,0 +1,143 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { slugify } from '../../constants' + +/** + * Single-value combobox for a book category, matching the GenrePicker feel: one + * input that filters the known options as you type (shown by their friendly + * label), lets you pick one, or create a brand-new custom category. The stored + * value is always a slug; `options` is `[{ value: slug, label }]`. + */ +export default function CategoryPicker({ value, onChange, options }) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [editing, setEditing] = useState(false) + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const labelFor = (slug) => options.find((o) => o.value === slug)?.label || slug + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setOpen(false) + setEditing(false) + setQuery('') + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + const matches = options.filter( + (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) + ) + const slugQ = slugify(query) + const exists = options.some((o) => o.value === slugQ) + const canCreate = slugQ.length > 0 && !exists + + const rows = [ + ...matches.map((o) => ({ type: 'option', ...o })), + ...(canCreate ? [{ type: 'create', value: slugQ, label: query.trim() }] : []), + ] + + const commit = (row) => { + if (!row) return + onChange(row.value) + setEditing(false) + setOpen(false) + setQuery('') + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + commit(rows[activeIdx] || rows[0]) + } else if (e.key === 'Escape') { + setEditing(false) + setOpen(false) + setQuery('') + } + } + + return ( +
+ { + setEditing(true) + setQuery('') + setOpen(true) + setActiveIdx(0) + }} + onChange={(e) => { + setEditing(true) + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onKeyDown={onKeyDown} + placeholder={t('bookEditor.categoryPlaceholder')} + style={{ width: '100%', fontSize: 13 }} + /> + {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/CategoryPicker.test.jsx b/frontend/src/components/metadata/CategoryPicker.test.jsx new file mode 100644 index 0000000..871b3c1 --- /dev/null +++ b/frontend/src/components/metadata/CategoryPicker.test.jsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CategoryPicker from './CategoryPicker' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${o.name}` : k) }), +})) + +const options = [ + { value: 'core', label: 'Core Rulebooks' }, + { value: 'adventure', label: 'Adventures & Modules' }, + { value: 'my-custom', label: 'my-custom' }, +] + +function Harness({ initial = 'core' }) { + const [value, setValue] = useState(initial) + return ( +
+ + {value} +
+ ) +} + +describe('CategoryPicker', () => { + it('shows the friendly label for the current slug when not editing', () => { + render() + expect(screen.getByRole('combobox').value).toBe('Core Rulebooks') + }) + + it('lists options on focus and filters as you type', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + expect(screen.getAllByRole('option').length).toBeGreaterThanOrEqual(3) + fireEvent.change(input, { target: { value: 'advent' } }) + const labels = screen.getAllByRole('option').map((o) => o.textContent) + expect(labels).toContain('Adventures & Modules') + expect(labels).not.toContain('Core Rulebooks') + }) + + it('commits the picked slug (not the label)', async () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'advent' } }) + await userEvent.click(screen.getByRole('option', { name: 'Adventures & Modules' })) + expect(screen.getByTestId('val').textContent).toBe('adventure') + }) + + it('offers a create row for a brand-new value, slugified', async () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'My New Cat!' } }) + await userEvent.click(screen.getByRole('option', { name: /createCategory/ })) + expect(screen.getByTestId('val').textContent).toBe('my-new-cat') + }) + + it('does not offer create for an existing slug', () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'core' } }) + expect(screen.queryByRole('option', { name: /createCategory/ })).not.toBeInTheDocument() + }) + + it('commits the active option on Enter after arrow navigation', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowUp' }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Enter commits some option (a valid slug). + expect(['core', 'adventure', 'my-custom']).toContain(screen.getByTestId('val').textContent) + }) + + it('closes the list on Escape without changing the value', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'advent' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByRole('option')).not.toBeInTheDocument() + expect(screen.getByTestId('val').textContent).toBe('core') + }) + + it('closes the dropdown when clicking outside', () => { + render( +
+ + +
+ ) + fireEvent.focus(screen.getByRole('combobox')) + expect(screen.getAllByRole('option').length).toBeGreaterThan(0) + fireEvent.mouseDown(screen.getByText('outside')) + expect(screen.queryByRole('option')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/metadata/DiceMaterialsPicker.jsx b/frontend/src/components/metadata/DiceMaterialsPicker.jsx new file mode 100644 index 0000000..aeeb877 --- /dev/null +++ b/frontend/src/components/metadata/DiceMaterialsPicker.jsx @@ -0,0 +1,263 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' +import { buildDiceMaterialRows } from './diceMaterials' + +/** + * Combobox multi-select for a system's dice / materials, modeled on GenrePicker. + * Selected values show as removable chips. Typing filters a curated, grouped + * list; group headers are shown but not selectable. Text that matches no option + * offers a "Create «text»" row for a custom value. + * + * The option groups come from the managed dice/materials lookup when a `groups` + * prop is supplied (built via `groupsFromManaged`), otherwise the built-in + * defaults are used. Creating a custom value best-effort persists it to the + * managed list (admin only) and calls `onCreate` to refresh, mirroring + * GenrePicker; without `onCreate` it simply adds the free-text value. + */ +export default function DiceMaterialsPicker({ selected, onChange, groups, onCreate }) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const has = (name) => selected.some((g) => g.toLowerCase() === name.toLowerCase()) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + // Grouped rows, dropping already-selected items and (when filtering) any group + // that ends up with no matching items. `groups` (when supplied) sources the + // options from the managed dice/materials lookup; otherwise built-in defaults. + const allRows = groups + ? buildDiceMaterialRows(selected, t('metadata.diceCustomGroup'), groups) + : buildDiceMaterialRows(selected, t('metadata.diceCustomGroup')) + const grouped = [] + for (const row of allRows) { + if (row.type === 'group') { + grouped.push({ header: row, items: [] }) + } else if (!has(row.value) && (!q || row.value.toLowerCase().includes(q))) { + grouped[grouped.length - 1]?.items.push(row) + } + } + const visibleGroups = grouped.filter((g) => g.items.length > 0) + + const exact = allRows.some((r) => r.type === 'item' && r.value.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + // Flat list of selectable option rows (for keyboard nav), plus an optional + // create row. Group headers are rendered but excluded from this list. + const optionRows = [ + ...visibleGroups.flatMap((g) => g.items), + ...(canCreate ? [{ type: 'create', value: query.trim() }] : []), + ] + + const add = (value) => { + const v = value.trim() + if (v && !has(v)) onChange([...selected, v]) + } + + // Best-effort: persist a newly created value to the managed lookup so it shows + // up in the list later. Ignored if not permitted (non-admin) or already exists. + const persistCustom = (value) => { + const v = value.trim() + if (!v) return + const known = allRows.some( + (r) => r.type === 'item' && r.value.toLowerCase() === v.toLowerCase() + ) + if (known) return + api + .post('/dice-materials', { name: v, group: 'Custom' }) + .then((created) => onCreate && onCreate(created)) + .catch(() => {}) + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create' && onCreate) persistCustom(row.value) + add(row.value) + setQuery('') + setActiveIdx(0) + setOpen(true) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, optionRows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + choose(optionRows[activeIdx] || optionRows[0]) + } else if (e.key === 'Backspace' && !query && selected.length > 0) { + onChange(selected.slice(0, -1)) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + // Global index into optionRows (for highlight), tracked as we render. + let optIdx = -1 + + return ( +
+
+ {selected.length === 0 && ( + + {t('metadata.noDiceMaterials')} + + )} + {selected.map((g) => ( + + {g} + + + ))} +
+ { + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={t('metadata.diceMaterialsComboPlaceholder')} + style={{ width: '100%' }} + /> + {open && optionRows.length > 0 && ( +
+ {visibleGroups.map((g) => ( +
+
+ {g.header.label} +
+ {g.items.map((row) => { + optIdx += 1 + const idx = optIdx + return ( + + ) + })} +
+ ))} + {canCreate && + (() => { + optIdx += 1 + const idx = optIdx + return ( + + ) + })()} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx b/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx new file mode 100644 index 0000000..9e61191 --- /dev/null +++ b/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DiceMaterialsPicker from './DiceMaterialsPicker' +import api from '../../api' +import { groupsFromManaged } from './diceMaterials' + +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'x' })) } })) + +beforeEach(() => vi.clearAllMocks()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'metadata.noDiceMaterials': 'No dice/materials selected', + 'metadata.addDiceMaterial': 'Add dice/material', + 'metadata.diceMaterialsComboPlaceholder': 'Search or add dice/materials…', + 'metadata.createDiceMaterial': `Create "${o?.name}"`, + 'metadata.diceCustomGroup': 'Custom', + })[k] || k, + }), +})) + +function Harness({ initial = [], groups, onCreate }) { + const [selected, setSelected] = useState(initial) + return ( +
+ + {JSON.stringify(selected)} +
+ ) +} + +describe('DiceMaterialsPicker', () => { + it('shows the empty placeholder', () => { + render() + expect(screen.getByText('No dice/materials selected')).toBeInTheDocument() + }) + + it('shows group headers (unselectable) and default items on focus', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + // Group headers render but are not options (role=presentation, not option). + expect(screen.getByText('Dice')).toBeInTheDocument() + expect(screen.getByText('Cards')).toBeInTheDocument() + // Items are options. + expect(screen.getByRole('option', { name: 'D20' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Tarot Cards' })).toBeInTheDocument() + }) + + it('adds a default item by clicking it', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + await userEvent.click(screen.getByRole('option', { name: 'D20' })) + expect(screen.getByTestId('sel').textContent).toBe('["D20"]') + }) + + it('filters items as you type and adds on Enter', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'tarot' } }) + expect(screen.getByRole('option', { name: 'Tarot Cards' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'D20' })).not.toBeInTheDocument() + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('sel').textContent).toBe('["Tarot Cards"]') + }) + + it('offers a create row for a custom value', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Glass Beads' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Glass Beads"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Glass Beads"]') + }) + + it('removes a selected chip', async () => { + render() + await userEvent.click(screen.getByLabelText('Remove D6')) + expect(screen.getByTestId('sel').textContent).toBe('[]') + }) + + it('sources options from a managed group list when provided', async () => { + const groups = groupsFromManaged([ + { name: 'Fudge Dice', group: 'Dice' }, + { name: 'Runestones', group: 'Custom' }, + ]) + render() + await userEvent.click(screen.getByRole('combobox')) + expect(screen.getByRole('option', { name: 'Fudge Dice' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Runestones' })).toBeInTheDocument() + // Built-in defaults are NOT shown when a managed list is supplied. + expect(screen.queryByRole('option', { name: 'D20' })).not.toBeInTheDocument() + }) + + it('persists a created custom value and calls onCreate when provided', async () => { + const onCreate = vi.fn() + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Runestones' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Runestones"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Runestones"]') + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/dice-materials', { + name: 'Runestones', + group: 'Custom', + }) + ) + await waitFor(() => expect(onCreate).toHaveBeenCalled()) + }) + + it('does not POST when creating without onCreate (free-text only)', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Glass Beads' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Glass Beads"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Glass Beads"]') + expect(api.post).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/metadata/GenrePicker.jsx b/frontend/src/components/metadata/GenrePicker.jsx new file mode 100644 index 0000000..631ce7d --- /dev/null +++ b/frontend/src/components/metadata/GenrePicker.jsx @@ -0,0 +1,228 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' +import { buildGenreTree } from './metadataUtils' + +/** + * Tiered, single-input combobox multi-select for genres. Selected genres show + * as removable chips. Typing in the one input filters the curated tiered list + * (indented by depth) as a dropdown; picking an option adds it. If the typed + * text matches no existing genre, a "Create «text»" row lets you add a custom + * genre, which is also persisted to the lookup list (best-effort) so it appears + * next time. + */ +export default function GenrePicker({ + genreTree, + selected, + onChange, + onGenreCreated, + inheritGenres = null, +}) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const tree = buildGenreTree(genreTree) + const has = (name) => selected.some((g) => g.toLowerCase() === name.toLowerCase()) + + // "Inherit from system": merge the system's genres into the book's, keeping + // any extras the book already has and never duplicating (case-insensitive). + const inheritable = (inheritGenres || []).filter((g) => !has(g)) + const inheritFromSystem = () => { + if (inheritable.length) onChange([...selected, ...inheritable]) + } + + // Close the dropdown when clicking outside. + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + // Options not already selected, filtered by the query (substring match). + const matches = tree.filter((g) => !has(g.name) && g.name.toLowerCase().includes(q)) + const exact = tree.some((g) => g.name.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + // Build the concrete option rows (existing matches, then an optional create row). + const rows = [ + ...matches.map((g) => ({ type: 'genre', ...g })), + ...(canCreate ? [{ type: 'create', name: query.trim() }] : []), + ] + + const addGenre = (name) => { + const v = name.trim() + if (v && !has(v)) onChange([...selected, v]) + } + + const createGenre = (name) => { + const v = name.trim() + if (!v) return + addGenre(v) + // Best-effort: persist as a lookup value so it shows in the list later. + // Ignored if not permitted (non-admin) or already exists. + if (!tree.some((g) => g.name.toLowerCase() === v.toLowerCase())) { + api + .post('/genres', { name: v }) + .then((created) => onGenreCreated && onGenreCreated(created)) + .catch(() => {}) + } + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create') createGenre(row.name) + else addGenre(row.name) + setQuery('') + setActiveIdx(0) + setOpen(true) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + choose(rows[activeIdx] || rows[0]) + } else if (e.key === 'Backspace' && !query && selected.length > 0) { + onChange(selected.slice(0, -1)) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+
+ {selected.length === 0 && ( + {t('metadata.noGenres')} + )} + {selected.map((g) => ( + + {g} + + + ))} +
+ { + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={t('metadata.genreComboPlaceholder')} + style={{ width: '100%' }} + /> + {inheritGenres !== null && ( + + )} + {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/GenrePicker.test.jsx b/frontend/src/components/metadata/GenrePicker.test.jsx new file mode 100644 index 0000000..3f21b1c --- /dev/null +++ b/frontend/src/components/metadata/GenrePicker.test.jsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GenrePicker from './GenrePicker' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${o.name}` : k) }), +})) +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'new' })) } })) + +const tree = [ + { id: 'sci', name: 'Science Fiction', parent_id: null, sort_order: 1 }, + { id: 'cyber', name: 'Cyberpunk', parent_id: 'sci', sort_order: 1 }, +] + +function Harness({ initial = [], inheritGenres = null }) { + const [selected, setSelected] = useState(initial) + return ( + + ) +} + +beforeEach(() => vi.clearAllMocks()) + +describe('GenrePicker', () => { + it('shows the empty placeholder', () => { + render() + expect(screen.getByText('metadata.noGenres')).toBeInTheDocument() + }) + + it('opens a filtered list on focus and adds a genre by clicking', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + // Clicking an option adds it as a chip. + await userEvent.click(screen.getByRole('option', { name: /Cyberpunk/ })) + expect(screen.getByText('Cyberpunk')).toBeInTheDocument() + }) + + it('filters the list as you type', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'cyber') + // Science Fiction is filtered out; Cyberpunk matches (plus a create row). + expect(screen.getByRole('option', { name: /Cyberpunk/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Science Fiction/ })).not.toBeInTheDocument() + }) + + it('indents child options in the list', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + const cyber = screen.getByRole('option', { name: /Cyberpunk/ }) + expect(cyber.textContent).toContain('└') + }) + + it('offers a create row and persists a custom genre via the API', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'Solarpunk') + // The create row appears (label uses createGenre:{{name}}). + await userEvent.click(screen.getByRole('option', { name: /createGenre:Solarpunk/ })) + expect(screen.getByText('Solarpunk')).toBeInTheDocument() + expect(api.post).toHaveBeenCalledWith('/genres', { name: 'Solarpunk' }) + }) + + it('does not offer create for an existing genre name', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'Cyberpunk') + expect(screen.queryByRole('option', { name: /createGenre/ })).not.toBeInTheDocument() + }) + + it('adds the active option on Enter', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'science{Enter}') + expect(screen.getByText('Science Fiction')).toBeInTheDocument() + }) + + it('removes a selected genre via its chip button', async () => { + render() + await userEvent.click(screen.getByLabelText('Remove Fantasy')) + expect(screen.queryByText('Fantasy')).not.toBeInTheDocument() + }) + + it('removes the last chip on Backspace when the input is empty', async () => { + render() + const input = screen.getByRole('combobox') + input.focus() + await userEvent.keyboard('{Backspace}') + expect(screen.queryByText('Horror')).not.toBeInTheDocument() + expect(screen.getByText('Fantasy')).toBeInTheDocument() + }) + + it('does not show the inherit button when inheritGenres is null', () => { + render() + expect(screen.queryByText('metadata.inheritFromSystem')).not.toBeInTheDocument() + }) + + it('merges system genres on inherit, keeping extras and avoiding duplicates', async () => { + // Book already has "Horror" (an extra) and "Fantasy" (also on the system). + render() + await userEvent.click(screen.getByText('metadata.inheritFromSystem')) + // Fantasy not duplicated; Horror kept; Sci-Fi added. + expect(screen.getAllByText('Fantasy')).toHaveLength(1) + expect(screen.getByText('Horror')).toBeInTheDocument() + expect(screen.getByText('Sci-Fi')).toBeInTheDocument() + }) + + it('disables the inherit button when nothing new to add', () => { + render() + expect(screen.getByText('metadata.inheritFromSystem')).toBeDisabled() + }) +}) diff --git a/frontend/src/components/metadata/LinkListEditor.jsx b/frontend/src/components/metadata/LinkListEditor.jsx new file mode 100644 index 0000000..ab24234 --- /dev/null +++ b/frontend/src/components/metadata/LinkListEditor.jsx @@ -0,0 +1,81 @@ +import { LuX, LuPlus } from 'react-icons/lu' + +/** + * Repeatable list of labeled links ([{ label, url }]). Used for a system's + * generic URLs and character-builder URLs, and a book's URLs. Empty rows are + * filtered out by the caller on save (see cleanLinks). + */ +export default function LinkListEditor({ + links, + onChange, + addLabel, + labelPlaceholder, + urlPlaceholder, + idPrefix = 'link', +}) { + const setLink = (idx, key, value) => + onChange(links.map((l, i) => (i === idx ? { ...l, [key]: value } : l))) + + const addLink = () => onChange([...links, { label: '', url: '' }]) + const removeLink = (idx) => onChange(links.filter((_, i) => i !== idx)) + + return ( +
+ {links.map((l, idx) => ( +
+ setLink(idx, 'label', e.target.value)} + placeholder={labelPlaceholder} + aria-label={labelPlaceholder} + style={{ flex: '1 1 130px', minWidth: 0 }} + /> + setLink(idx, 'url', e.target.value)} + placeholder={urlPlaceholder} + aria-label={urlPlaceholder} + style={{ flex: '1 1 180px', minWidth: 0 }} + /> + +
+ ))} + +
+ ) +} diff --git a/frontend/src/components/metadata/LinkListEditor.test.jsx b/frontend/src/components/metadata/LinkListEditor.test.jsx new file mode 100644 index 0000000..851ec78 --- /dev/null +++ b/frontend/src/components/metadata/LinkListEditor.test.jsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LinkListEditor from './LinkListEditor' + +function Harness({ initial = [{ label: '', url: '' }] }) { + const [links, setLinks] = useState(initial) + return ( + + ) +} + +describe('LinkListEditor', () => { + it('edits label and url', async () => { + render() + await userEvent.type(document.getElementById('lnk-label-0'), 'DTRPG') + await userEvent.type(document.getElementById('lnk-url-0'), 'http://x') + expect(document.getElementById('lnk-label-0').value).toBe('DTRPG') + expect(document.getElementById('lnk-url-0').value).toBe('http://x') + }) + + it('adds a new row', async () => { + render() + await userEvent.click(screen.getByText('Add Link')) + expect(document.getElementById('lnk-url-1')).toBeTruthy() + }) + + it('removes a row', async () => { + render( + + ) + await userEvent.click(screen.getAllByLabelText('Remove link')[0]) + // Only one row remains. + expect(document.getElementById('lnk-url-1')).toBeFalsy() + }) +}) diff --git a/frontend/src/components/metadata/LookupCombobox.jsx b/frontend/src/components/metadata/LookupCombobox.jsx new file mode 100644 index 0000000..9f545c6 --- /dev/null +++ b/frontend/src/components/metadata/LookupCombobox.jsx @@ -0,0 +1,25 @@ +/** + * A single-value text input backed by a datalist of known options — the user + * can pick an existing value or type a new one. Used for System Family. + */ +export default function LookupCombobox({ id, value, onChange, options, placeholder = '' }) { + const listId = `${id}-options` + return ( + <> + onChange(e.target.value)} + placeholder={placeholder} + style={{ width: '100%' }} + /> + + {options.map((o) => ( + + + ) +} diff --git a/frontend/src/components/metadata/LookupCombobox.test.jsx b/frontend/src/components/metadata/LookupCombobox.test.jsx new file mode 100644 index 0000000..33e473d --- /dev/null +++ b/frontend/src/components/metadata/LookupCombobox.test.jsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LookupCombobox from './LookupCombobox' + +function Harness() { + const [v, setV] = useState('') + return ( + + ) +} + +describe('LookupCombobox', () => { + it('renders options in a datalist', () => { + render() + const list = document.getElementById('fam-options') + expect(list.querySelectorAll('option')).toHaveLength(2) + }) + + it('accepts typed custom values', async () => { + render() + await userEvent.type(screen.getByPlaceholderText('family'), 'Custom Engine') + expect(screen.getByPlaceholderText('family').value).toBe('Custom Engine') + }) +}) diff --git a/frontend/src/components/metadata/MultiSelectDropdown.jsx b/frontend/src/components/metadata/MultiSelectDropdown.jsx new file mode 100644 index 0000000..da788af --- /dev/null +++ b/frontend/src/components/metadata/MultiSelectDropdown.jsx @@ -0,0 +1,186 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuChevronDown, LuSearch, LuX } from 'react-icons/lu' + +/** + * A searchable multi-select dropdown. Shows a trigger with the count of + * selected values; opening reveals a search box and a scrollable checkbox list. + * Scales cleanly to long option lists (e.g. 50+ tags) unlike an inline pill row. + * + * Props: + * - options: [{ value, label }] + * - selected: string[] + * - onChange: (nextSelected) => void + * - label: trigger/aria label + * - emptyLabel: shown when there are no options at all + * - searchPlaceholder + */ +export default function MultiSelectDropdown({ + options, + selected = [], + onChange, + label, + emptyLabel = '—', + searchPlaceholder, +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const wrapRef = useRef(null) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setOpen(false) + setQuery('') + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const toggle = (value) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + onChange(next.length ? next : []) + } + + const q = query.trim().toLowerCase() + const filtered = q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options + + if (options.length === 0) { + return
{emptyLabel}
+ } + + return ( +
+ + + {open && ( +
+
+ + setQuery(e.target.value)} + placeholder={searchPlaceholder || t('common.search')} + aria-label={searchPlaceholder || t('common.search')} + style={{ + width: '100%', + boxSizing: 'border-box', + fontSize: 13, + padding: '6px 8px 6px 28px', + borderRadius: 6, + border: '1px solid var(--border)', + background: 'var(--bg-input)', + }} + /> +
+
+ {filtered.length === 0 ? ( +
+ {t('common.noResults')} +
+ ) : ( + filtered.map((o) => ( + + )) + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/MultiSelectDropdown.test.jsx b/frontend/src/components/metadata/MultiSelectDropdown.test.jsx new file mode 100644 index 0000000..328ffc1 --- /dev/null +++ b/frontend/src/components/metadata/MultiSelectDropdown.test.jsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MultiSelectDropdown from './MultiSelectDropdown' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'sortFilter.multiAny': 'Any', + 'sortFilter.multiSelected': `${o?.count} selected`, + 'sortFilter.clear': 'Clear', + 'common.search': 'Search', + 'common.noResults': 'No results', + })[k] || k, + }), +})) + +const options = [ + { value: 'osr', label: 'osr' }, + { value: 'fantasy', label: 'fantasy' }, + { value: 'grim', label: 'grim' }, +] + +function Harness({ initial = [] }) { + const [selected, setSelected] = useState(initial) + return ( +
+ + {JSON.stringify(selected)} +
+ ) +} + +describe('MultiSelectDropdown', () => { + it('shows "Any" when nothing is selected', () => { + render() + expect(screen.getByRole('button', { name: 'Tags' })).toHaveTextContent('Any') + }) + + it('opens and toggles a value', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'osr' })) + expect(screen.getByTestId('sel').textContent).toBe('["osr"]') + }) + + it('filters options by the search box', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.type(screen.getByLabelText('Search'), 'gr') + expect(screen.getByRole('checkbox', { name: 'grim' })).toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'osr' })).not.toBeInTheDocument() + }) + + it('shows a selected count and clears all', async () => { + render() + const trigger = screen.getByRole('button', { name: 'Tags' }) + expect(trigger).toHaveTextContent('2 selected') + // The clear (X) affordance inside the trigger resets the selection. + await userEvent.click(screen.getByLabelText('Clear')) + expect(screen.getByTestId('sel').textContent).toBe('[]') + }) + + it('renders an empty label when there are no options', () => { + render( + {}} + emptyLabel="No tags" + /> + ) + expect(screen.getByText('No tags')).toBeInTheDocument() + }) + + it('closes when clicking outside', async () => { + render( +
+ + +
+ ) + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + expect(screen.getByRole('checkbox', { name: 'osr' })).toBeInTheDocument() + fireEvent.mouseDown(screen.getByText('outside')) + expect(screen.queryByRole('checkbox', { name: 'osr' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/metadata/SingleSelectCombobox.jsx b/frontend/src/components/metadata/SingleSelectCombobox.jsx new file mode 100644 index 0000000..d1125fc --- /dev/null +++ b/frontend/src/components/metadata/SingleSelectCombobox.jsx @@ -0,0 +1,202 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' + +/** + * Single-value combobox with a filterable flat dropdown and an explicit + * "Create «text»" row for new values — a flat cousin of GenrePicker. The chosen + * value fills the input; typing filters the options and (when the text matches + * nothing) offers a create row. Creating best-effort persists the value to the + * lookup table at `createEndpoint` (admin only) and calls `onCreate` to refresh. + * + * Props: + * id – input id (also used for the clear button aria) + * value – current string value + * onChange – (nextValue) => void + * options – array of known string values + * placeholder – input placeholder + * createEndpoint – REST path to POST {name} for a new value (optional) + * onCreate – called after a successful create, to reload the lookup list + * createLabel – (name) => string for the create row (defaults to `Create "name"`) + */ +export default function SingleSelectCombobox({ + id, + value, + onChange, + options = [], + placeholder = '', + createEndpoint, + onCreate, + createLabel, +}) { + const { t } = useTranslation() + // `query` mirrors the input; it starts from the committed value and is kept in + // sync when the value changes from outside. + const [query, setQuery] = useState(value || '') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + useEffect(() => { + setQuery(value || '') + }, [value]) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + const matches = options.filter((o) => o.toLowerCase().includes(q)) + const exact = options.some((o) => o.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + const rows = [ + ...matches.map((name) => ({ type: 'option', name })), + ...(canCreate ? [{ type: 'create', name: query.trim() }] : []), + ] + + const commit = (name) => { + const v = name.trim() + onChange(v) + setQuery(v) + setOpen(false) + } + + const create = (name) => { + const v = name.trim() + if (!v) return + commit(v) + // Best-effort persist so the value appears in the list next time. Ignored if + // not permitted (non-admin) or already exists. + if (createEndpoint && !options.some((o) => o.toLowerCase() === v.toLowerCase())) { + api + .post(createEndpoint, { name: v }) + .then((created) => onCreate && onCreate(created)) + .catch(() => {}) + } + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create') create(row.name) + else commit(row.name) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (rows.length) choose(rows[activeIdx] || rows[0]) + else commit(query) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+
+ { + setQuery(e.target.value) + // Typing edits the value directly; picking/creating commits a final one. + onChange(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + style={{ width: '100%', paddingRight: query ? 26 : undefined }} + /> + {query && ( + + )} +
+ {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/SingleSelectCombobox.test.jsx b/frontend/src/components/metadata/SingleSelectCombobox.test.jsx new file mode 100644 index 0000000..e5b7f42 --- /dev/null +++ b/frontend/src/components/metadata/SingleSelectCombobox.test.jsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SingleSelectCombobox from './SingleSelectCombobox' +import api from '../../api' + +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'x' })) } })) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => (k === 'common.clear' ? 'Clear' : `Create "${o?.name}"`), + }), +})) + +beforeEach(() => vi.clearAllMocks()) + +function Harness({ options = ['Dungeons & Dragons', 'Cyberpunk'], initial = '', ...rest }) { + const [value, setValue] = useState(initial) + return ( +
+ + {value} +
+ ) +} + +describe('SingleSelectCombobox', () => { + it('lists options on focus and selects one by click', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + await userEvent.click(screen.getByRole('option', { name: 'Cyberpunk' })) + expect(screen.getByTestId('val').textContent).toBe('Cyberpunk') + }) + + it('filters options as you type', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'cyber' } }) + expect(screen.getByRole('option', { name: 'Cyberpunk' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Dungeons & Dragons' })).not.toBeInTheDocument() + }) + + it('offers a create row for unknown text and commits it', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + expect(screen.getByTestId('val').textContent).toBe('Pathfinder') + }) + + it('persists a created value to the endpoint and calls onCreate', async () => { + const onCreate = vi.fn() + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/parent-systems', { name: 'Pathfinder' }) + ) + await waitFor(() => expect(onCreate).toHaveBeenCalled()) + }) + + it('does not offer create for an exact existing match', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Cyberpunk' } }) + expect(screen.queryByRole('option', { name: /Create/ })).not.toBeInTheDocument() + }) + + it('clears the value via the clear button', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect(screen.getByTestId('val').textContent).toBe('') + }) + + it('navigates with arrows and commits on Enter', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + // Two options; ArrowDown moves to the second, Enter commits it. + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowUp' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('val').textContent).toBe('Dungeons & Dragons') + }) + + it('commits the raw query on Enter when no rows match', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: ' ' } }) // whitespace → no create row + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('val').textContent).toBe('') + }) + + it('closes the dropdown on Escape', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + expect(screen.getByRole('option', { name: 'Cyberpunk' })).toBeInTheDocument() + fireEvent.keyDown(input, { key: 'Escape' }) + await waitFor(() => + expect(screen.queryByRole('option', { name: 'Cyberpunk' })).not.toBeInTheDocument() + ) + }) + + it('does not persist when creating without a createEndpoint', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + expect(api.post).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/metadata/TagChipInput.jsx b/frontend/src/components/metadata/TagChipInput.jsx new file mode 100644 index 0000000..e48a807 --- /dev/null +++ b/frontend/src/components/metadata/TagChipInput.jsx @@ -0,0 +1,106 @@ +import { useRef } from 'react' +import { LuX } from 'react-icons/lu' + +/** + * Chip-style tag input shared by the system and book editors. Tags are stored + * lowercase; Enter/comma commits the pending text, Backspace on an empty input + * removes the last chip. The pending input value is controlled by the parent so + * it can be flushed into the payload on save. + */ +export default function TagChipInput({ + id, + tags, + onChange, + inputValue, + onInputChange, + placeholder = '', +}) { + const inputRef = useRef(null) + + const commit = () => { + const tag = inputValue.trim().toLowerCase().replace(/,+$/, '') + if (tag && !tags.includes(tag)) onChange([...tags, tag]) + onInputChange('') + } + + const handleKey = (e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + commit() + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + onChange(tags.slice(0, -1)) + } + } + + return ( +
inputRef.current?.focus()} + style={{ + display: 'flex', + flexWrap: 'wrap', + gap: 5, + alignItems: 'center', + padding: '6px 8px', + borderRadius: 6, + cursor: 'text', + background: 'var(--bg-input)', + border: '1px solid var(--border)', + minHeight: 36, + }} + > + {tags.map((tag) => ( + + {tag} + + + ))} + onInputChange(e.target.value)} + onKeyDown={handleKey} + onBlur={commit} + placeholder={tags.length === 0 ? placeholder : ''} + style={{ + fontSize: 13, + border: 'none', + outline: 'none', + background: 'transparent', + color: 'var(--text)', + minWidth: 80, + flex: 1, + }} + /> +
+ ) +} diff --git a/frontend/src/components/metadata/TagChipInput.test.jsx b/frontend/src/components/metadata/TagChipInput.test.jsx new file mode 100644 index 0000000..ab31074 --- /dev/null +++ b/frontend/src/components/metadata/TagChipInput.test.jsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagChipInput from './TagChipInput' + +function Harness({ initial = [] }) { + const [tags, setTags] = useState(initial) + const [input, setInput] = useState('') + return ( + + ) +} + +describe('TagChipInput', () => { + it('adds a lowercased tag on Enter', async () => { + render() + const input = screen.getByPlaceholderText('add tag…') + await userEvent.type(input, 'OSR{Enter}') + expect(screen.getByText('osr')).toBeInTheDocument() + }) + + it('adds on comma and ignores duplicates', async () => { + render() + const input = screen.getByRole('textbox') + await userEvent.type(input, 'osr,') + expect(screen.getAllByText('osr')).toHaveLength(1) + }) + + it('removes the last tag on Backspace when empty', async () => { + render() + const input = screen.getByRole('textbox') + input.focus() + fireEvent.keyDown(input, { key: 'Backspace' }) + expect(screen.queryByText('b')).not.toBeInTheDocument() + expect(screen.getByText('a')).toBeInTheDocument() + }) + + it('removes a tag via its chip button', async () => { + render() + await userEvent.click(screen.getByLabelText('Remove drop')) + expect(screen.queryByText('drop')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/metadata/diceMaterials.js b/frontend/src/components/metadata/diceMaterials.js new file mode 100644 index 0000000..eea4f39 --- /dev/null +++ b/frontend/src/components/metadata/diceMaterials.js @@ -0,0 +1,91 @@ +// Default dice / materials options for a game system, grouped for the picker. +// Groups are display-only (unselectable); their items are selectable values. +// User-entered values that aren't in any default group are shown under "Custom". + +export const DICE_MATERIAL_GROUPS = [ + { + key: 'dice', + label: 'Dice', + items: ['D4', 'D6', 'D8', 'D10', 'D12', 'D20', 'D100', 'Custom (System specific)'], + }, + { + key: 'cards', + label: 'Cards', + items: ['Playing Cards', 'Tarot Cards', 'Custom Deck'], + }, + { + key: 'other', + label: 'Other', + items: ['Tumbling Tower (Jenga Tower)', 'Candles', 'Poker Chips', 'Timers', 'Phone'], + }, +] + +// Flat set of all default item values (lowercased) for "is this custom?" checks. +const DEFAULT_VALUES = new Set( + DICE_MATERIAL_GROUPS.flatMap((g) => g.items.map((i) => i.toLowerCase())) +) + +export function isDefaultDiceMaterial(value) { + return DEFAULT_VALUES.has(String(value).trim().toLowerCase()) +} + +// Canonical group ordering for managed items; unknown groups come after these. +const GROUP_ORDER = ['Dice', 'Cards', 'Other', 'Custom'] + +/** + * Convert the managed dice/materials lookup rows ([{name, group}]) into the same + * grouped shape as DICE_MATERIAL_GROUPS. Groups are ordered Dice → Cards → Other + * → Custom, then any unexpected groups alphabetically. + */ +export function groupsFromManaged(items = []) { + const byGroup = new Map() + for (const item of items) { + const group = (item.group || 'Custom').trim() || 'Custom' + if (!byGroup.has(group)) byGroup.set(group, []) + byGroup.get(group).push(item.name) + } + const groupNames = [...byGroup.keys()].sort((a, b) => { + const ai = GROUP_ORDER.indexOf(a) + const bi = GROUP_ORDER.indexOf(b) + if (ai !== -1 || bi !== -1) return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi) + return a.localeCompare(b) + }) + return groupNames.map((name) => ({ + key: name.toLowerCase(), + label: name, + items: byGroup.get(name), + })) +} + +/** + * Build the flat, group-ordered option list for the picker. Each entry is + * either { type: 'group', label } (unselectable header) or + * { type: 'item', value, groupKey }. Selected values not present in any group + * are appended under a trailing "Custom" group so they can be seen/removed. + * + * @param selected currently selected values (to surface custom ones) + * @param customGroupLabel localized label for the trailing custom group + * @param groups group definitions to build from; defaults to the built-in list. + * Pass `groupsFromManaged(lookupRows)` to source from the managed table. + */ +export function buildDiceMaterialRows( + selected = [], + customGroupLabel = 'Custom', + groups = DICE_MATERIAL_GROUPS +) { + const rows = [] + const known = new Set() + for (const g of groups) { + rows.push({ type: 'group', label: g.label, key: g.key }) + for (const item of g.items) { + rows.push({ type: 'item', value: item, groupKey: g.key }) + known.add(String(item).trim().toLowerCase()) + } + } + const customs = selected.filter((v) => !known.has(String(v).trim().toLowerCase())) + if (customs.length) { + rows.push({ type: 'group', label: customGroupLabel, key: '__custom__' }) + for (const value of customs) rows.push({ type: 'item', value, groupKey: '__custom__' }) + } + return rows +} diff --git a/frontend/src/components/metadata/diceMaterials.test.js b/frontend/src/components/metadata/diceMaterials.test.js new file mode 100644 index 0000000..19ca7f9 --- /dev/null +++ b/frontend/src/components/metadata/diceMaterials.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { + DICE_MATERIAL_GROUPS, + isDefaultDiceMaterial, + buildDiceMaterialRows, + groupsFromManaged, +} from './diceMaterials' + +describe('diceMaterials', () => { + it('defines the Dice/Cards/Other default groups', () => { + const labels = DICE_MATERIAL_GROUPS.map((g) => g.label) + expect(labels).toEqual(['Dice', 'Cards', 'Other']) + const dice = DICE_MATERIAL_GROUPS.find((g) => g.key === 'dice') + expect(dice.items).toContain('D20') + expect(dice.items).toContain('Custom (System specific)') + }) + + it('recognizes default values case-insensitively', () => { + expect(isDefaultDiceMaterial('d20')).toBe(true) + expect(isDefaultDiceMaterial('Tarot Cards')).toBe(true) + expect(isDefaultDiceMaterial('Homebrew Widget')).toBe(false) + }) + + it('builds group headers followed by their items', () => { + const rows = buildDiceMaterialRows([]) + expect(rows[0]).toMatchObject({ type: 'group', label: 'Dice' }) + expect(rows[1]).toMatchObject({ type: 'item', value: 'D4' }) + }) + + it('appends selected custom values under a Custom group', () => { + const rows = buildDiceMaterialRows(['D6', 'Homebrew Widget'], 'Custom') + const customHeader = rows.find((r) => r.type === 'group' && r.label === 'Custom') + expect(customHeader).toBeTruthy() + const customItem = rows.find((r) => r.type === 'item' && r.value === 'Homebrew Widget') + expect(customItem.groupKey).toBe('__custom__') + // A default value like D6 is NOT duplicated into the custom group. + expect(rows.filter((r) => r.type === 'item' && r.value === 'D6')).toHaveLength(1) + }) + + describe('groupsFromManaged', () => { + it('groups managed rows and orders Dice → Cards → Other → Custom', () => { + const groups = groupsFromManaged([ + { name: 'Poker Chips', group: 'Other' }, + { name: 'D20', group: 'Dice' }, + { name: 'Tarot Cards', group: 'Cards' }, + { name: 'My Widget', group: 'Custom' }, + ]) + expect(groups.map((g) => g.label)).toEqual(['Dice', 'Cards', 'Other', 'Custom']) + expect(groups[0].items).toEqual(['D20']) + }) + + it('places unknown groups after the canonical ones, alphabetically', () => { + const groups = groupsFromManaged([ + { name: 'Zeta', group: 'Zeta' }, + { name: 'Alpha', group: 'Alpha' }, + { name: 'D6', group: 'Dice' }, + ]) + expect(groups.map((g) => g.label)).toEqual(['Dice', 'Alpha', 'Zeta']) + }) + + it('defaults a missing group to Custom', () => { + const groups = groupsFromManaged([{ name: 'Loose' }]) + expect(groups[0].label).toBe('Custom') + expect(groups[0].items).toEqual(['Loose']) + }) + }) + + it('builds rows from a supplied managed group list', () => { + const managed = groupsFromManaged([ + { name: 'D20', group: 'Dice' }, + { name: 'My Widget', group: 'Custom' }, + ]) + const rows = buildDiceMaterialRows([], 'Custom', managed) + expect(rows.find((r) => r.type === 'item' && r.value === 'D20')).toBeTruthy() + expect(rows.find((r) => r.type === 'item' && r.value === 'My Widget')).toBeTruthy() + // A value not in the supplied groups is surfaced under the trailing Custom group. + const rows2 = buildDiceMaterialRows(['Unlisted'], 'Custom', managed) + const unlisted = rows2.find((r) => r.type === 'item' && r.value === 'Unlisted') + expect(unlisted.groupKey).toBe('__custom__') + }) +}) diff --git a/frontend/src/components/metadata/metadataUtils.js b/frontend/src/components/metadata/metadataUtils.js new file mode 100644 index 0000000..71fa3c8 --- /dev/null +++ b/frontend/src/components/metadata/metadataUtils.js @@ -0,0 +1,37 @@ +// Shared helpers for the metadata editor components (issue #202). + +/** Drop links with neither a label nor a URL (used before saving). */ +export function cleanLinks(links) { + return (links || []).filter((l) => (l.label || '').trim() || (l.url || '').trim()) +} + +/** Ensure a link list has at least one (blank) row for editing. */ +export function linksForEditing(links) { + return links && links.length ? links : [{ label: '', url: '' }] +} + +/** + * Build a tiered, ordered list of genres from the flat lookup rows. Each entry + * is { id, name, depth } with children following their parent, so a dropdown + * can indent by depth. + */ +export function buildGenreTree(genres) { + const byParent = new Map() + for (const g of genres) { + const key = g.parent_id || '__root__' + if (!byParent.has(key)) byParent.set(key, []) + byParent.get(key).push(g) + } + for (const list of byParent.values()) { + list.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.name.localeCompare(b.name)) + } + const out = [] + const walk = (parentKey, depth) => { + for (const g of byParent.get(parentKey) || []) { + out.push({ id: g.id, name: g.name, depth }) + walk(g.id, depth + 1) + } + } + walk('__root__', 0) + return out +} diff --git a/frontend/src/components/metadata/metadataUtils.test.js b/frontend/src/components/metadata/metadataUtils.test.js new file mode 100644 index 0000000..7548aa7 --- /dev/null +++ b/frontend/src/components/metadata/metadataUtils.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { cleanLinks, linksForEditing, buildGenreTree } from './metadataUtils' + +describe('cleanLinks', () => { + it('drops rows with neither label nor url', () => { + expect( + cleanLinks([ + { label: '', url: '' }, + { label: 'X', url: '' }, + { label: '', url: 'http://y' }, + ]) + ).toEqual([ + { label: 'X', url: '' }, + { label: '', url: 'http://y' }, + ]) + }) + + it('handles null input', () => { + expect(cleanLinks(null)).toEqual([]) + }) +}) + +describe('linksForEditing', () => { + it('returns a blank row when empty', () => { + expect(linksForEditing([])).toEqual([{ label: '', url: '' }]) + expect(linksForEditing(null)).toEqual([{ label: '', url: '' }]) + }) + + it('passes through existing links', () => { + const links = [{ label: 'a', url: 'b' }] + expect(linksForEditing(links)).toBe(links) + }) +}) + +describe('buildGenreTree', () => { + const genres = [ + { id: 'sci', name: 'Science Fiction', parent_id: null, sort_order: 2 }, + { id: 'cyber', name: 'Cyberpunk', parent_id: 'sci', sort_order: 1 }, + { id: 'fan', name: 'Fantasy', parent_id: null, sort_order: 1 }, + ] + + it('orders parents by sort_order with children nested below', () => { + const tree = buildGenreTree(genres) + const names = tree.map((g) => g.name) + expect(names).toEqual(['Fantasy', 'Science Fiction', 'Cyberpunk']) + }) + + it('assigns depth by nesting level', () => { + const tree = buildGenreTree(genres) + const cyber = tree.find((g) => g.name === 'Cyberpunk') + expect(cyber.depth).toBe(1) + expect(tree.find((g) => g.name === 'Fantasy').depth).toBe(0) + }) + + it('handles an empty list', () => { + expect(buildGenreTree([])).toEqual([]) + }) +}) diff --git a/frontend/src/components/metadata/useLookups.js b/frontend/src/components/metadata/useLookups.js new file mode 100644 index 0000000..dead41f --- /dev/null +++ b/frontend/src/components/metadata/useLookups.js @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import api from '../../api' + +/** + * Load the metadata lookup lists once for editor dropdowns: genres (tree), + * system families, parent systems, licenses, and dice/materials. Returns the + * lists plus `reload` to re-fetch after a custom value is created. Failures + * degrade to empty lists (custom free-text entry still works). + */ +export default function useLookups() { + const [genres, setGenres] = useState([]) + const [families, setFamilies] = useState([]) + const [parentSystems, setParentSystems] = useState([]) + const [licenses, setLicenses] = useState([]) + const [diceMaterials, setDiceMaterials] = useState([]) + + const load = () => { + api + .get('/genres') + .then((r) => setGenres(r.genres || [])) + .catch(() => setGenres([])) + api + .get('/system-families') + .then((r) => setFamilies(r.families || [])) + .catch(() => setFamilies([])) + api + .get('/parent-systems') + .then((r) => setParentSystems(r.parent_systems || [])) + .catch(() => setParentSystems([])) + api + .get('/licenses') + .then((r) => setLicenses(r.licenses || [])) + .catch(() => setLicenses([])) + api + .get('/dice-materials') + .then((r) => setDiceMaterials(r.dice_materials || [])) + .catch(() => setDiceMaterials([])) + } + + useEffect(() => { + load() + }, []) + + return { genres, families, parentSystems, licenses, diceMaterials, reload: load } +} diff --git a/frontend/src/components/metadata/useLookups.test.js b/frontend/src/components/metadata/useLookups.test.js new file mode 100644 index 0000000..12a897a --- /dev/null +++ b/frontend/src/components/metadata/useLookups.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import useLookups from './useLookups' +import api from '../../api' + +vi.mock('../../api', () => ({ default: { get: vi.fn() } })) + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockImplementation((path) => { + if (path.includes('genres')) return Promise.resolve({ genres: [{ id: 'g' }] }) + if (path.includes('system-families')) return Promise.resolve({ families: [{ id: 'f' }] }) + if (path.includes('parent-systems')) return Promise.resolve({ parent_systems: [{ id: 'p' }] }) + if (path.includes('licenses')) return Promise.resolve({ licenses: [{ id: 'l' }] }) + if (path.includes('dice-materials')) return Promise.resolve({ dice_materials: [{ id: 'd' }] }) + return Promise.resolve({}) + }) +}) + +// Number of lookup endpoints fetched on each load. +const ENDPOINT_COUNT = 5 + +describe('useLookups', () => { + it('loads every lookup list on mount', async () => { + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(result.current.genres).toHaveLength(1)) + expect(result.current.families).toHaveLength(1) + expect(result.current.parentSystems).toHaveLength(1) + expect(result.current.licenses).toHaveLength(1) + expect(result.current.diceMaterials).toHaveLength(1) + }) + + it('degrades to empty lists on failure', async () => { + api.get.mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(result.current.genres).toEqual([]) + expect(result.current.families).toEqual([]) + expect(result.current.parentSystems).toEqual([]) + expect(result.current.licenses).toEqual([]) + expect(result.current.diceMaterials).toEqual([]) + }) + + it('reload re-fetches every list', async () => { + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(result.current.genres).toHaveLength(1)) + api.get.mockClear() + act(() => result.current.reload()) + await waitFor(() => expect(api.get).toHaveBeenCalledTimes(ENDPOINT_COUNT)) + }) +}) diff --git a/frontend/src/components/reader/BookmarkDialog.test.jsx b/frontend/src/components/reader/BookmarkDialog.test.jsx new file mode 100644 index 0000000..e1f6f1e --- /dev/null +++ b/frontend/src/components/reader/BookmarkDialog.test.jsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BookmarkDialog from './BookmarkDialog' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'bookmark.bookmarkSelection': 'Bookmark selection', + 'bookmark.bookmarkPage': `Bookmark page ${o?.page}`, + 'bookmark.notes': 'Notes', + 'bookmark.notesPlaceholder': 'Notes…', + 'bookmark.cancel': 'Cancel', + 'bookmark.save': 'Save', + })[k] || k, + }), +})) + +function baseProps(over = {}) { + return { + pendingBookmark: { page: 5 }, + pendingLabel: '', + pendingNotes: '', + onLabelChange: vi.fn(), + onNotesChange: vi.fn(), + onSave: vi.fn(), + onClose: vi.fn(), + ...over, + } +} + +describe('BookmarkDialog', () => { + it('shows the page title and the selected-text preview', () => { + render( + + ) + expect(screen.getByText('Bookmark selection')).toBeInTheDocument() + expect(screen.getByText('"a fireball"')).toBeInTheDocument() + }) + + it('shows a page-based title when no text is selected', () => { + render() + expect(screen.getByText('Bookmark page 5')).toBeInTheDocument() + }) + + it('propagates label and notes edits', async () => { + const onLabelChange = vi.fn() + const onNotesChange = vi.fn() + render() + await userEvent.type(screen.getByLabelText('bookmark.label'), 'x') + await userEvent.type(screen.getByLabelText('Notes'), 'y') + expect(onLabelChange).toHaveBeenCalled() + expect(onNotesChange).toHaveBeenCalled() + }) + + it('saves and closes via the buttons', async () => { + const onSave = vi.fn() + const onClose = vi.fn() + render() + await userEvent.click(screen.getByText('Save')) + expect(onSave).toHaveBeenCalled() + await userEvent.click(screen.getByText('Cancel')) + expect(onClose).toHaveBeenCalled() + }) + + it('closes on backdrop click and on Escape in a field', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByLabelText('Notes'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('dialog')) + expect(onClose).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/components/reader/SelectionPopup.test.jsx b/frontend/src/components/reader/SelectionPopup.test.jsx new file mode 100644 index 0000000..bf117ce --- /dev/null +++ b/frontend/src/components/reader/SelectionPopup.test.jsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import SelectionPopup from './SelectionPopup' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: () => 'Bookmark selection' }), +})) + +describe('SelectionPopup', () => { + it('renders the bookmark button and calls onBookmark with page + text', () => { + const onBookmark = vi.fn() + render( + + ) + fireEvent.mouseDown(screen.getByText('Bookmark selection')) + expect(onBookmark).toHaveBeenCalledWith(7, 'a spell') + }) +}) diff --git a/frontend/src/components/settings/CollapsibleSection.jsx b/frontend/src/components/settings/CollapsibleSection.jsx new file mode 100644 index 0000000..849026f --- /dev/null +++ b/frontend/src/components/settings/CollapsibleSection.jsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { LuChevronDown, LuChevronRight } from 'react-icons/lu' + +/** + * Collapsible wrapper for a settings section: a clickable header (title + + * optional description) that toggles the body. Open by default; the open/closed + * state is remembered per-browser under `storageKey` when provided. + */ +export default function CollapsibleSection({ + title, + description, + storageKey, + defaultOpen = true, + children, +}) { + const [open, setOpen] = useState(() => { + if (!storageKey) return defaultOpen + const saved = localStorage.getItem(storageKey) + return saved === null ? defaultOpen : saved === '1' + }) + + const toggle = () => { + setOpen((prev) => { + const next = !prev + if (storageKey) localStorage.setItem(storageKey, next ? '1' : '0') + return next + }) + } + + return ( +
+ + {open && ( +
+ {description && ( +

+ {description} +

+ )} + {children} +
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/CollapsibleSection.test.jsx b/frontend/src/components/settings/CollapsibleSection.test.jsx new file mode 100644 index 0000000..d9ca25a --- /dev/null +++ b/frontend/src/components/settings/CollapsibleSection.test.jsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CollapsibleSection from './CollapsibleSection' + +describe('CollapsibleSection', () => { + beforeEach(() => localStorage.clear()) + + it('renders open by default with title, description, and children', () => { + render( + +
body
+
+ ) + expect(screen.getByText('Genres')).toBeInTheDocument() + expect(screen.getByText('Manage genres')).toBeInTheDocument() + expect(screen.getByText('body')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /genres/i })).toHaveAttribute('aria-expanded', 'true') + }) + + it('toggles the body when the header is clicked', async () => { + render( + +
body
+
+ ) + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(screen.queryByText('body')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(screen.getByText('body')).toBeInTheDocument() + }) + + it('remembers collapsed state via storageKey', async () => { + const { unmount } = render( + +
body
+
+ ) + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(localStorage.getItem('k1')).toBe('0') + unmount() + + render( + +
body
+
+ ) + // Persisted collapsed → body hidden on remount. + expect(screen.queryByText('body')).not.toBeInTheDocument() + }) + + it('honors defaultOpen=false when no stored state', () => { + render( + +
body
+
+ ) + expect(screen.queryByText('body')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/DeleteAccountSection.test.jsx b/frontend/src/components/settings/DeleteAccountSection.test.jsx new file mode 100644 index 0000000..96ec98d --- /dev/null +++ b/frontend/src/components/settings/DeleteAccountSection.test.jsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DeleteAccountSection from './DeleteAccountSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) +vi.mock('../../api', () => ({ default: { delete: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => vi.clearAllMocks()) + +describe('DeleteAccountSection', () => { + it('disables the delete button for admins and shows the admin warning', () => { + render() + expect(screen.getByText('userSettings.deleteAccount.deleteButton')).toBeDisabled() + expect(screen.getByText('userSettings.deleteAccount.adminWarning')).toBeInTheDocument() + }) + + it('confirms then deletes the account and logs out', async () => { + const onLogout = vi.fn() + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + // Confirmation panel appears. + await userEvent.click(screen.getByText('userSettings.deleteAccount.confirmDelete')) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/users/me')) + expect(onLogout).toHaveBeenCalled() + }) + + it('can back out of the confirmation', async () => { + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + await userEvent.click(screen.getByText('common.cancel')) + expect(screen.queryByText('userSettings.deleteAccount.confirmDelete')).not.toBeInTheDocument() + }) + + it('does not log out when the delete request fails', async () => { + api.delete.mockRejectedValueOnce(new Error('nope')) + const onLogout = vi.fn() + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + await userEvent.click(screen.getByText('userSettings.deleteAccount.confirmDelete')) + await waitFor(() => expect(api.delete).toHaveBeenCalled()) + // On failure it exits the confirming state and never logs out. + expect(onLogout).not.toHaveBeenCalled() + expect(screen.queryByText('userSettings.deleteAccount.confirmDelete')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/DiceMaterialManagerSection.jsx b/frontend/src/components/settings/DiceMaterialManagerSection.jsx new file mode 100644 index 0000000..c11ae2e --- /dev/null +++ b/frontend/src/components/settings/DiceMaterialManagerSection.jsx @@ -0,0 +1,246 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' + +const GROUPS = ['Dice', 'Cards', 'Other', 'Custom'] + +/** + * Admin manager for the dice / materials lookup list. Each entry has a name and + * a group ("Dice", "Cards", "Other", "Custom"); the display is grouped and new + * entries pick their group. Removing an in-use value confirms first (force=true). + */ +export default function DiceMaterialManagerSection() { + const { t } = useTranslation() + const [items, setItems] = useState(null) + const [name, setName] = useState('') + const [group, setGroup] = useState('Custom') + const [confirm, setConfirm] = useState(null) + const [error, setError] = useState('') + + const load = () => { + api + .get('/dice-materials') + .then((r) => setItems(r.dice_materials || [])) + .catch(() => setItems([])) + } + + useEffect(load, []) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post('/dice-materials', { name: name.trim(), group }) + .then(() => { + setName('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (item, force = false) => { + api + .delete(`/dice-materials/${item.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ + id: item.id, + name: detail.name || item.name, + count: detail.usage_count || 0, + }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (items === null) return + + // Group the items for display, preserving the canonical group order with any + // unexpected groups appended after. + const byGroup = {} + for (const item of items) { + const g = item.group || 'Custom' + ;(byGroup[g] = byGroup[g] || []).push(item) + } + const orderedGroups = [ + ...GROUPS.filter((g) => byGroup[g]), + ...Object.keys(byGroup).filter((g) => !GROUPS.includes(g)), + ] + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={t('lookupSettings.diceNamePlaceholder')} + aria-label={t('lookupSettings.diceNamePlaceholder')} + style={{ flex: '1 1 160px', minWidth: 0 }} + /> + + +
+ + {error &&
{error}
} + +
+ {orderedGroups.map((g) => ( +
+
+ {t(`lookupSettings.diceGroup.${g.toLowerCase()}`, { defaultValue: g })} +
+
+ {byGroup[g].map((item) => ( + + {item.name} + + + ))} +
+
+ ))} +
+ + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx b/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx new file mode 100644 index 0000000..14d37c1 --- /dev/null +++ b/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DiceMaterialManagerSection from './DiceMaterialManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => (o?.defaultValue ? o.defaultValue : o ? `${k}:${JSON.stringify(o)}` : k), + }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) + +const items = [ + { id: 'd1', name: 'D20', group: 'Dice', is_default: true }, + { id: 'c1', name: 'Tarot Cards', group: 'Cards', is_default: true }, + { id: 'x1', name: 'Spinner', group: 'Custom', is_default: false }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ dice_materials: items }) + api.post.mockResolvedValue({ id: 'n1', name: 'Fudge Dice', group: 'Dice' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('DiceMaterialManagerSection', () => { + it('lists items grouped by their group', async () => { + render() + expect(await screen.findByText('D20')).toBeInTheDocument() + expect(screen.getByText('Tarot Cards')).toBeInTheDocument() + expect(screen.getByText('Spinner')).toBeInTheDocument() + // "Dice" appears both as a group heading and a select option (>1 match). + expect(screen.getAllByText('Dice').length).toBeGreaterThan(1) + }) + + it('creates an item with the selected group', async () => { + render() + await screen.findByText('D20') + await userEvent.type(screen.getByLabelText('lookupSettings.diceNamePlaceholder'), 'Fudge Dice') + await userEvent.selectOptions(screen.getByLabelText('lookupSettings.diceGroupLabel'), 'Dice') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/dice-materials', { name: 'Fudge Dice', group: 'Dice' }) + }) + + it('removes an item', async () => { + render() + await screen.findByText('Spinner') + await userEvent.click(screen.getByLabelText('common.remove Spinner')) + expect(api.delete).toHaveBeenCalledWith('/dice-materials/x1') + }) +}) diff --git a/frontend/src/components/settings/DisplayNameSection.test.jsx b/frontend/src/components/settings/DisplayNameSection.test.jsx new file mode 100644 index 0000000..76db12f --- /dev/null +++ b/frontend/src/components/settings/DisplayNameSection.test.jsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DisplayNameSection from './DisplayNameSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { username: 'gm', display_name: 'Game Master' } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { username: 'gm', display_name: 'Game Master' } +}) + +describe('DisplayNameSection', () => { + it('seeds the input from the current display name', () => { + render() + expect(screen.getByLabelText('userSettings.displayName.label').value).toBe('Game Master') + }) + + it('saves a trimmed display name and refreshes the user', async () => { + render() + const input = screen.getByLabelText('userSettings.displayName.label') + await userEvent.clear(input) + await userEvent.type(input, ' New Name ') + await userEvent.click(screen.getByText('userSettings.displayName.save')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { display_name: 'New Name' }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('shows an error message when saving fails', async () => { + api.patch.mockRejectedValueOnce(new Error('boom')) + render() + await userEvent.click(screen.getByText('userSettings.displayName.save')) + await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/settings/EmailSection.test.jsx b/frontend/src/components/settings/EmailSection.test.jsx new file mode 100644 index 0000000..4e5bf74 --- /dev/null +++ b/frontend/src/components/settings/EmailSection.test.jsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EmailSection from './EmailSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { username: 'gm', email: 'gm@example.com' } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { username: 'gm', email: 'gm@example.com' } +}) + +describe('EmailSection', () => { + it('seeds the input from the current email', () => { + render() + expect(screen.getByLabelText('userSettings.email.label').value).toBe('gm@example.com') + }) + + it('saves the trimmed email and refreshes the user', async () => { + render() + const input = screen.getByLabelText('userSettings.email.label') + await userEvent.clear(input) + await userEvent.type(input, ' new@example.com ') + await userEvent.click(screen.getByText('common.save')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { + email: 'new@example.com', + }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('surfaces an error when the save fails', async () => { + api.patch.mockRejectedValueOnce(new Error('bad email')) + render() + await userEvent.click(screen.getByText('common.save')) + await waitFor(() => expect(screen.getByText('bad email')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/settings/ExplicitContentSection.test.jsx b/frontend/src/components/settings/ExplicitContentSection.test.jsx new file mode 100644 index 0000000..c3359bf --- /dev/null +++ b/frontend/src/components/settings/ExplicitContentSection.test.jsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ExplicitContentSection from './ExplicitContentSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { allow_explicit: true } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { allow_explicit: true } +}) + +describe('ExplicitContentSection', () => { + it('reflects the current allow_explicit state', () => { + render() + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('toggles the preference off and refreshes the user', async () => { + render() + await userEvent.click(screen.getByRole('checkbox')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { allow_explicit: false }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('defaults to allowed when the flag is undefined', () => { + mockUser = {} + render() + expect(screen.getByRole('checkbox')).toBeChecked() + }) +}) diff --git a/frontend/src/components/settings/GenreManagerSection.jsx b/frontend/src/components/settings/GenreManagerSection.jsx new file mode 100644 index 0000000..b899a1e --- /dev/null +++ b/frontend/src/components/settings/GenreManagerSection.jsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' +import { buildGenreTree } from '../metadata/metadataUtils' + +/** + * Admin panel to manage the genre lookup list: add tiered genres and remove + * any (default or custom). Removing a genre that is attached to systems/books + * requires confirmation and re-issues the delete with force=true. + */ +export default function GenreManagerSection() { + const { t } = useTranslation() + const [genres, setGenres] = useState(null) + const [name, setName] = useState('') + const [parentId, setParentId] = useState('') + const [confirm, setConfirm] = useState(null) // { id, name, count } + const [error, setError] = useState('') + + const load = () => { + api + .get('/genres') + .then((r) => setGenres(r.genres || [])) + .catch(() => setGenres([])) + } + + useEffect(load, []) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post('/genres', { name: name.trim(), parent_id: parentId || null }) + .then(() => { + setName('') + setParentId('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (g, force = false) => { + api + .delete(`/genres/${g.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + // 409 → in use; surface a confirm modal with the usage count. + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ id: g.id, name: detail.name || g.name, count: detail.usage_count || 0 }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (genres === null) return + + const tree = buildGenreTree(genres) + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={t('lookupSettings.namePlaceholder')} + aria-label={t('lookupSettings.namePlaceholder')} + style={{ flex: '1 1 160px', minWidth: 0 }} + /> + + +
+ + {error &&
{error}
} + +
+ {tree.map((g) => ( +
+ + {g.depth > 0 && '└ '} + {g.name} + + +
+ ))} +
+ + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/GenreManagerSection.test.jsx b/frontend/src/components/settings/GenreManagerSection.test.jsx new file mode 100644 index 0000000..8b6f6ed --- /dev/null +++ b/frontend/src/components/settings/GenreManagerSection.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GenreManagerSection from './GenreManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ + default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})) + +const genres = [ + { id: 'g1', name: 'Fantasy', parent_id: null, is_default: true, sort_order: 1 }, + { id: 'g2', name: 'Grimdark', parent_id: 'g1', is_default: true, sort_order: 1 }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ genres }) + api.post.mockResolvedValue({ id: 'g3', name: 'New' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('GenreManagerSection', () => { + it('lists genres after load', async () => { + render() + // "Fantasy" appears in both the parent dropdown and the list, so assert + // via the unique remove button label instead. + expect(await screen.findByLabelText('common.remove Fantasy')).toBeInTheDocument() + expect(screen.getByLabelText('common.remove Grimdark')).toBeInTheDocument() + }) + + it('creates a genre', async () => { + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.type(screen.getByLabelText('lookupSettings.namePlaceholder'), 'Western') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/genres', { name: 'Western', parent_id: null }) + }) + + it('deletes an unused genre directly', async () => { + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.click(screen.getByLabelText('common.remove Fantasy')) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/genres/g1')) + }) + + it('shows a confirm modal when a genre is in use, then force-deletes', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Fantasy', usage_count: 3 } }, + }) + ) + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.click(screen.getByLabelText('common.remove Fantasy')) + // Confirm modal appears with usage count. + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok', removed_usage: 3 }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => expect(api.delete).toHaveBeenLastCalledWith('/genres/g1?force=true')) + }) +}) diff --git a/frontend/src/components/settings/LevelBadge.test.jsx b/frontend/src/components/settings/LevelBadge.test.jsx new file mode 100644 index 0000000..02737af --- /dev/null +++ b/frontend/src/components/settings/LevelBadge.test.jsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import LevelBadge from './LevelBadge' + +describe('LevelBadge', () => { + it('renders a coloured, labelled badge for a known level', () => { + render() + const el = screen.getByText('ERROR') + expect(el).toHaveAttribute('aria-label', 'Log level: ERROR') + expect(el.style.color).toBeTruthy() + }) + + it('renders the raw level for an unknown level', () => { + render() + expect(screen.getByText('TRACE')).toBeInTheDocument() + }) + + it('uses a lighter weight for DEBUG', () => { + render() + expect(screen.getByText('DEBUG').style.fontWeight).toBe('400') + }) +}) diff --git a/frontend/src/components/settings/LicenseManagerSection.jsx b/frontend/src/components/settings/LicenseManagerSection.jsx new file mode 100644 index 0000000..d578913 --- /dev/null +++ b/frontend/src/components/settings/LicenseManagerSection.jsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the license lookup list (OGL, ORC, CC-BY, Proprietary, + * …). The section title/description live in the collapsible header. + */ +export default function LicenseManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/LicenseManagerSection.test.jsx b/frontend/src/components/settings/LicenseManagerSection.test.jsx new file mode 100644 index 0000000..722cc63 --- /dev/null +++ b/frontend/src/components/settings/LicenseManagerSection.test.jsx @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import LicenseManagerSection from './LicenseManagerSection' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +vi.mock('./SimpleLookupManager', () => ({ + default: ({ endpoint, listKey }) => ( +
+ ), +})) + +describe('LicenseManagerSection', () => { + it('renders SimpleLookupManager pointed at /licenses', () => { + render() + const mgr = screen.getByTestId('mgr') + expect(mgr).toHaveAttribute('data-endpoint', '/licenses') + expect(mgr).toHaveAttribute('data-listkey', 'licenses') + }) +}) diff --git a/frontend/src/components/settings/LogRow.test.jsx b/frontend/src/components/settings/LogRow.test.jsx new file mode 100644 index 0000000..3a6e246 --- /dev/null +++ b/frontend/src/components/settings/LogRow.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import LogRow from './LogRow' + +const entry = (over = {}) => ({ + timestamp: '2026-07-25T09:15:42.123Z', + level: 'INFO', + message: 'Scan complete', + ...over, +}) + +describe('LogRow', () => { + it('renders the time, level badge, and message', () => { + render() + expect(screen.getByText('09:15:42.123')).toBeInTheDocument() + expect(screen.getByText('INFO')).toBeInTheDocument() + expect(screen.getByLabelText('Message: Scan complete')).toBeInTheDocument() + }) + + it('highlights the matched search query in the message', () => { + render() + const mark = screen.getByText('opening') + expect(mark.tagName).toBe('MARK') + }) + + it('renders plain message when the search query does not match', () => { + render() + expect(screen.queryByText('zzz')).not.toBeInTheDocument() + expect(screen.getByLabelText('Message: all good')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/MetadataTab.jsx b/frontend/src/components/settings/MetadataTab.jsx new file mode 100644 index 0000000..e3cba70 --- /dev/null +++ b/frontend/src/components/settings/MetadataTab.jsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import CollapsibleSection from './CollapsibleSection' +import GenreManagerSection from './GenreManagerSection' +import SystemFamilyManagerSection from './SystemFamilyManagerSection' +import ParentSystemManagerSection from './ParentSystemManagerSection' +import LicenseManagerSection from './LicenseManagerSection' +import DiceMaterialManagerSection from './DiceMaterialManagerSection' + +/** Admin settings tab: manage the metadata lookup lists, each collapsible. */ +export default function MetadataTab() { + const { t } = useTranslation() + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/frontend/src/components/settings/MetadataTab.test.jsx b/frontend/src/components/settings/MetadataTab.test.jsx new file mode 100644 index 0000000..412283d --- /dev/null +++ b/frontend/src/components/settings/MetadataTab.test.jsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import MetadataTab from './MetadataTab' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k) => k }), +})) + +vi.mock('./GenreManagerSection', () => ({ default: () =>
genre-manager
})) +vi.mock('./SystemFamilyManagerSection', () => ({ default: () =>
family-manager
})) +vi.mock('./ParentSystemManagerSection', () => ({ default: () =>
parent-manager
})) +vi.mock('./LicenseManagerSection', () => ({ default: () =>
license-manager
})) +vi.mock('./DiceMaterialManagerSection', () => ({ default: () =>
dice-manager
})) + +describe('MetadataTab', () => { + it('renders every metadata manager (all sections open by default)', () => { + render() + expect(screen.getByText('genre-manager')).toBeInTheDocument() + expect(screen.getByText('family-manager')).toBeInTheDocument() + expect(screen.getByText('parent-manager')).toBeInTheDocument() + expect(screen.getByText('license-manager')).toBeInTheDocument() + expect(screen.getByText('dice-manager')).toBeInTheDocument() + }) + + it('collapses a section when its header is clicked', async () => { + const { default: userEvent } = await import('@testing-library/user-event') + render() + // The genres header toggles its manager body. + await userEvent.click(screen.getByRole('button', { name: /lookupSettings.genresTitle/i })) + expect(screen.queryByText('genre-manager')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/OPDSSection.test.jsx b/frontend/src/components/settings/OPDSSection.test.jsx new file mode 100644 index 0000000..2092217 --- /dev/null +++ b/frontend/src/components/settings/OPDSSection.test.jsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import OPDSSection from './OPDSSection' +import { opds } from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) +vi.mock('../../context/UISettingsContext', () => ({ useUISettings: () => ({}) })) + +vi.mock('../../api', () => ({ + opds: { + getStatus: vi.fn(), + generateToken: vi.fn(), + revokeToken: vi.fn(), + }, +})) + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(navigator, { clipboard: { writeText: vi.fn() } }) +}) + +describe('OPDSSection', () => { + it('renders nothing when OPDS is disabled server-side', async () => { + opds.getStatus.mockResolvedValue({ opds_enabled: false }) + const { container } = render() + await waitFor(() => expect(opds.getStatus).toHaveBeenCalled()) + expect(container.querySelector('h3')).toBeNull() + }) + + it('shows the enable button and generates a token', async () => { + opds.getStatus.mockResolvedValue({ opds_enabled: true, has_token: false }) + opds.generateToken.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.enable')) + await waitFor(() => expect(opds.generateToken).toHaveBeenCalled()) + // After generating, the feed URL is shown. + expect(screen.getByLabelText('userSettings.opds.feedUrl').value).toBe('http://feed') + }) + + it('copies the feed URL to the clipboard', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByTitle('userSettings.opds.copy')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://feed') + expect(screen.getByText('userSettings.opds.copied')).toBeInTheDocument() + }) + + it('confirms and revokes the token', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + opds.revokeToken.mockResolvedValue({ opds_enabled: true, has_token: false }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.disable')) + await userEvent.click(screen.getByText('userSettings.opds.confirmDisable')) + await waitFor(() => expect(opds.revokeToken).toHaveBeenCalled()) + // Back to the enable button once the token is gone. + expect(screen.getByText('userSettings.opds.enable')).toBeInTheDocument() + }) + + it('can cancel the revoke confirmation', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.disable')) + await userEvent.click(screen.getByText('common.cancel')) + expect(screen.queryByText('userSettings.opds.confirmDisable')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/ParentSystemManagerSection.jsx b/frontend/src/components/settings/ParentSystemManagerSection.jsx new file mode 100644 index 0000000..9637bc3 --- /dev/null +++ b/frontend/src/components/settings/ParentSystemManagerSection.jsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the parent-system lookup list (e.g. "Dungeons & + * Dragons"). The section title/description live in the collapsible header. + */ +export default function ParentSystemManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/ParentSystemManagerSection.test.jsx b/frontend/src/components/settings/ParentSystemManagerSection.test.jsx new file mode 100644 index 0000000..a47ae80 --- /dev/null +++ b/frontend/src/components/settings/ParentSystemManagerSection.test.jsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import ParentSystemManagerSection from './ParentSystemManagerSection' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +// Assert the wrapper wires the parent-systems endpoint/listKey through. +vi.mock('./SimpleLookupManager', () => ({ + default: ({ endpoint, listKey }) => ( +
+ ), +})) + +describe('ParentSystemManagerSection', () => { + it('renders SimpleLookupManager pointed at /parent-systems', () => { + render() + const mgr = screen.getByTestId('mgr') + expect(mgr).toHaveAttribute('data-endpoint', '/parent-systems') + expect(mgr).toHaveAttribute('data-listkey', 'parent_systems') + }) +}) diff --git a/frontend/src/components/settings/ReaderSection.test.jsx b/frontend/src/components/settings/ReaderSection.test.jsx new file mode 100644 index 0000000..e24904d --- /dev/null +++ b/frontend/src/components/settings/ReaderSection.test.jsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ReaderSection from './ReaderSection' +import { getUserPrefs, saveUserPref } from '../../hooks/useUserPrefs' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../../hooks/useUserPrefs', () => ({ + getUserPrefs: vi.fn(() => ({})), + saveUserPref: vi.fn(), +})) +// SegmentedControl renders a button per option; keep it real but simple. +vi.mock('./SegmentedControl', () => ({ + default: ({ options, onChange }) => ( +
+ {options.map((o) => ( + + ))} +
+ ), +})) + +beforeEach(() => { + vi.clearAllMocks() + getUserPrefs.mockReturnValue({}) +}) + +describe('ReaderSection', () => { + it('saves the reader mode when an option is picked', async () => { + render() + await userEvent.click(screen.getByText('userSettings.reader.spread')) + expect(saveUserPref).toHaveBeenCalledWith('readerMode', 'spread') + }) + + it('toggles the wheel-nav switch and persists it', async () => { + render() + const sw = screen.getByRole('switch') + // Defaults to on (wheelNav !== false). + expect(sw).toHaveAttribute('aria-checked', 'true') + await userEvent.click(sw) + expect(saveUserPref).toHaveBeenCalledWith('wheelNav', false) + expect(sw).toHaveAttribute('aria-checked', 'false') + }) + + it('reflects a stored wheelNav=false preference', () => { + getUserPrefs.mockReturnValue({ wheelNav: false }) + render() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) +}) diff --git a/frontend/src/components/settings/SectionDivider.test.jsx b/frontend/src/components/settings/SectionDivider.test.jsx new file mode 100644 index 0000000..e4faff0 --- /dev/null +++ b/frontend/src/components/settings/SectionDivider.test.jsx @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import SectionDivider from './SectionDivider' + +describe('SectionDivider', () => { + it('renders a bordered divider', () => { + const { container } = render() + expect(container.firstChild.style.borderTop).toBe('1px solid var(--border)') + }) +}) diff --git a/frontend/src/components/settings/SimpleLookupManager.jsx b/frontend/src/components/settings/SimpleLookupManager.jsx new file mode 100644 index 0000000..954e2ff --- /dev/null +++ b/frontend/src/components/settings/SimpleLookupManager.jsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' + +/** + * Generic manager for a flat name-only lookup list (system families, parent + * systems, licenses). Handles load / add / remove with the shared in-use + * confirmation flow (409 → re-issue with force=true). The parent supplies the + * REST endpoint and the response list key. + * + * Props: + * endpoint – REST base path, e.g. "/parent-systems" + * listKey – key in the GET response holding the array, e.g. "parent_systems" + * addPlaceholder – localized placeholder / aria-label for the name input + */ +export default function SimpleLookupManager({ endpoint, listKey, addPlaceholder }) { + const { t } = useTranslation() + const [items, setItems] = useState(null) + const [name, setName] = useState('') + const [confirm, setConfirm] = useState(null) + const [error, setError] = useState('') + + const load = () => { + api + .get(endpoint) + .then((r) => setItems(r[listKey] || [])) + .catch(() => setItems([])) + } + + useEffect(load, [endpoint, listKey]) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post(endpoint, { name: name.trim() }) + .then(() => { + setName('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (item, force = false) => { + api + .delete(`${endpoint}/${item.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ + id: item.id, + name: detail.name || item.name, + count: detail.usage_count || 0, + }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (items === null) return + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={addPlaceholder || t('lookupSettings.namePlaceholder')} + aria-label={addPlaceholder || t('lookupSettings.namePlaceholder')} + style={{ flex: '1 1 200px', minWidth: 0 }} + /> + +
+ + {error &&
{error}
} + + {items.length === 0 ? ( +

{t('lookupSettings.empty')}

+ ) : ( +
+ {items.map((item) => ( + + {item.name} + + + ))} +
+ )} + + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/SimpleLookupManager.test.jsx b/frontend/src/components/settings/SimpleLookupManager.test.jsx new file mode 100644 index 0000000..1288033 --- /dev/null +++ b/frontend/src/components/settings/SimpleLookupManager.test.jsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SimpleLookupManager from './SimpleLookupManager' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) + +const items = [{ id: 'p1', name: 'Dungeons & Dragons', is_default: false, sort_order: 0 }] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ parent_systems: items }) + api.post.mockResolvedValue({ id: 'p2', name: 'Cyberpunk' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +const renderManager = () => + render( + + ) + +describe('SimpleLookupManager', () => { + it('loads and lists items from the endpoint', async () => { + renderManager() + expect(await screen.findByText('Dungeons & Dragons')).toBeInTheDocument() + expect(api.get).toHaveBeenCalledWith('/parent-systems') + }) + + it('shows an empty message when the list is empty', async () => { + api.get.mockResolvedValue({ parent_systems: [] }) + renderManager() + expect(await screen.findByText('lookupSettings.empty')).toBeInTheDocument() + }) + + it('creates an item via the add button', async () => { + renderManager() + await screen.findByText('Dungeons & Dragons') + await userEvent.type(screen.getByLabelText('Parent system name'), 'Cyberpunk') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/parent-systems', { name: 'Cyberpunk' }) + }) + + it('confirms before force-deleting an in-use item', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Dungeons & Dragons', usage_count: 3 } }, + }) + ) + renderManager() + await screen.findByText('Dungeons & Dragons') + await userEvent.click(screen.getByLabelText('common.remove Dungeons & Dragons')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok' }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => + expect(api.delete).toHaveBeenLastCalledWith('/parent-systems/p1?force=true') + ) + }) +}) diff --git a/frontend/src/components/settings/SystemFamilyManagerSection.jsx b/frontend/src/components/settings/SystemFamilyManagerSection.jsx new file mode 100644 index 0000000..e230a24 --- /dev/null +++ b/frontend/src/components/settings/SystemFamilyManagerSection.jsx @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the system-family lookup list. Thin wrapper over the + * shared SimpleLookupManager; the section title/description live in the + * collapsible header in MetadataTab. + */ +export default function SystemFamilyManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx b/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx new file mode 100644 index 0000000..6882dbf --- /dev/null +++ b/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SystemFamilyManagerSection from './SystemFamilyManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ + default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})) + +const families = [{ id: 'f1', name: 'Fate', is_default: true, sort_order: 0 }] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ families }) + api.post.mockResolvedValue({ id: 'f2', name: 'GURPS' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('SystemFamilyManagerSection', () => { + it('lists families', async () => { + render() + expect(await screen.findByText('Fate')).toBeInTheDocument() + }) + + it('creates a family', async () => { + render() + await screen.findByText('Fate') + await userEvent.type(screen.getByLabelText('lookupSettings.namePlaceholder'), 'GURPS') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/system-families', { name: 'GURPS' }) + }) + + it('confirms before force-deleting an in-use family', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Fate', usage_count: 2 } }, + }) + ) + render() + await screen.findByText('Fate') + await userEvent.click(screen.getByLabelText('common.remove Fate')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok' }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => + expect(api.delete).toHaveBeenLastCalledWith('/system-families/f1?force=true') + ) + }) +}) diff --git a/frontend/src/components/settings/ToolbarButton.test.jsx b/frontend/src/components/settings/ToolbarButton.test.jsx new file mode 100644 index 0000000..e522dc5 --- /dev/null +++ b/frontend/src/components/settings/ToolbarButton.test.jsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import ToolbarButton from './ToolbarButton' + +describe('settings/ToolbarButton', () => { + it('renders children with a title and fires onClick', () => { + const onClick = vi.fn() + render( + + B + + ) + const btn = screen.getByTitle('Bold') + expect(btn).toHaveTextContent('B') + fireEvent.click(btn) + expect(onClick).toHaveBeenCalled() + }) + + it('prevents default on mousedown (keeps the editor selection)', () => { + render(I) + const btn = screen.getByTitle('Italic') + const evt = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + btn.dispatchEvent(evt) + expect(evt.defaultPrevented).toBe(true) + }) +}) diff --git a/frontend/src/components/system/BookBulkEditFields.jsx b/frontend/src/components/system/BookBulkEditFields.jsx new file mode 100644 index 0000000..8ae643c --- /dev/null +++ b/frontend/src/components/system/BookBulkEditFields.jsx @@ -0,0 +1,275 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CATEGORY_ORDER, categoryLabel } from '../../constants' +import GenrePicker from '../metadata/GenrePicker' +import CategoryPicker from '../metadata/CategoryPicker' +import LinkListEditor from '../metadata/LinkListEditor' +import LookupCombobox from '../metadata/LookupCombobox' +import TagChipInput from '../metadata/TagChipInput' +import useLookups from '../metadata/useLookups' +import { linksForEditing } from '../metadata/metadataUtils' + +// Rich editor body for one book inside the bulk-edit carousel. Mirrors the +// single-book editor and reuses the same shared components (CategoryPicker, +// GenrePicker with inherit-from-system, TagChipInput, LinkListEditor). +export default function BookBulkEditFields({ + draft, + setField, + existingCategories = [], + systemGenres = [], +}) { + const { t } = useTranslation() + const { genres: genreTree, licenses, reload: reloadLookups } = useLookups() + const licenseOptions = licenses.map((l) => l.name) + const [tagInput, setTagInput] = useState('') + + const categoryOptions = [ + ...new Set([...CATEGORY_ORDER, ...existingCategories, draft.category].filter(Boolean)), + ].map((slug) => ({ value: slug, label: categoryLabel(slug) })) + + const csv = (arr) => (Array.isArray(arr) ? arr.join(', ') : '') + const setCsv = (field, value) => + setField( + field, + value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ) + + return ( +
+
+ + setField('title', e.target.value)} + style={input} + /> +
+ +
+ +