diff --git a/.dockerignore b/.dockerignore index 656a8225..2f32bfe4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -**/.dockerignore +**/.dockerignore **/.env **/.git **/.gitignore diff --git a/.editorconfig b/.editorconfig index 53c287cb..f9a8bb60 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,9 @@ -# Inspired by https://github.com/dotnet/roslyn/blob/master/.editorconfig +# Inspired by https://github.com/dotnet/roslyn/blob/master/.editorconfig root = true [*] +charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space diff --git a/.fossa.yml b/.fossa.yml index 4bd36f5a..defbb0fd 100644 --- a/.fossa.yml +++ b/.fossa.yml @@ -1,4 +1,4 @@ -version: 3 +version: 3 # https://github.com/fossas/fossa-cli/blob/master/docs/references/files/fossa-yml.md paths: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 160d1045..25d12dde 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: CI +name: CI # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # purpose: Continuous Integration pipeline @@ -39,13 +39,13 @@ jobs: markup-lint: name: Markup - uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@58dbfe0e9927f2191796ac616a4bb43126e59d98 + uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@00907950b2c345e8ad6934b07268cef056d50d86 code-quality: name: Code needs: git-check if: needs.git-check.outputs.app_changed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.run-code-quality) - uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@58dbfe0e9927f2191796ac616a4bb43126e59d98 + uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@00907950b2c345e8ad6934b07268cef056d50d86 with: # TODO: need a safe solution for service account json content # @@ -67,10 +67,12 @@ jobs: dotnet-test-args: "--report-xunit-trx --coverage --coverage-output-format cobertura" extra-vars: | AllowedOrigins__0=5207 + AllowedOrigins__1=7042 Features__IsScalarEnabled=true Features__IsHttpsRedirectionEnabled=false Infrastructure__MongoDB__ConnectionString=mongodb://localhost:27017 Infrastructure__MongoDB__DatabaseName=keeptrack_ci + Logging__LogLevel__Keeptrack=Debug fossa-enabled: true sonar-enabled: true sonar-cpd-exclusions: "**/*.g.cs,**/*.generated.cs,**/src/WebApi.Contracts/Dto/*.cs,**/src/Domain/Models/*.cs,**/src/Infrastructure.MongoDb/Entities/*.cs" @@ -78,7 +80,7 @@ jobs: sonar-organization: ${{ vars.SONAR_ORG }} sonar-project-key: ${{ vars.SONAR_PROJECT_KEY }} sonar-project-name: Keeptrack - workflow-parts-version: 58dbfe0e9927f2191796ac616a4bb43126e59d98 + workflow-parts-version: 00907950b2c345e8ad6934b07268cef056d50d86 secrets: fossa-api-key: ${{ secrets.FOSSA_API_KEY }} sonar-token: ${{ secrets.SONAR_TOKEN }} @@ -89,6 +91,10 @@ jobs: Tmdb__ApiKey=${{ secrets.TMDB_APIKEY }} Rawg__ApiKey=${{ secrets.RAWG_APIKEY }} Discogs__Token=${{ secrets.DISCOGS_TOKEN }} + GoogleBooks__ApiKey=${{ secrets.GOOGLEBOOKS_APIKEY }} + Omdb__ApiKey=${{ secrets.OMDB_APIKEY }} + Igdb__ClientId=${{ secrets.IGDB_CLIENTID }} + Igdb__ClientSecret=${{ secrets.IGDB_CLIENTSECRET }} FIREBASE_APIKEY=${{ secrets.FIREBASE_APIKEY }} FIREBASE_USERNAME=${{ secrets.FIREBASE_TESTUSERNAME }} FIREBASE_PASSWORD=${{ secrets.FIREBASE_TESTPASSWORD }} @@ -109,7 +115,7 @@ jobs: - name: "Web Api" image-name: "keeptrack-webapi" image-definition: "src/WebApi/Dockerfile" - uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@58dbfe0e9927f2191796ac616a4bb43126e59d98 + uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@00907950b2c345e8ad6934b07268cef056d50d86 with: image-definition: ${{ matrix.image-definition }} image-name: ${{ matrix.image-name }} @@ -117,4 +123,4 @@ jobs: image-tag: "${{ needs.git-check.outputs.version_major_minor }}.${{ github.run_id }}" max-high-cves: 1 # temporary (2026-07-19): libxml2-2 │ SUSE-SU-2026:3097-1 │ HIGH │ fixed │ 2.12.10-150700.4.11.1 │ 2.12.10-150700.4.14.1 │ Security update for libxml2 max-medium-cves: 0 - workflow-parts-version: 58dbfe0e9927f2191796ac616a4bb43126e59d98 + workflow-parts-version: 00907950b2c345e8ad6934b07268cef056d50d86 diff --git a/.github/workflows/pkg.yaml b/.github/workflows/pkg.yaml index 422ecf66..ba15307b 100644 --- a/.github/workflows/pkg.yaml +++ b/.github/workflows/pkg.yaml @@ -1,4 +1,4 @@ -name: PKG +name: PKG # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # purpose: Continuous Delivery (aka Packaging & Shipping) @@ -28,14 +28,14 @@ jobs: permissions: id-token: write contents: read - uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@58dbfe0e9927f2191796ac616a4bb43126e59d98 + uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@00907950b2c345e8ad6934b07268cef056d50d86 with: create-latest: ${{ github.ref_name == 'main' }} image-definition: ${{ matrix.image-definition }} image-name: ${{ matrix.image-name }} image-path: ${{ vars.CONTAINER_REGISTRY_PATH }} image-tag: "${{ needs.git-check.outputs.version_major_minor }}.${{ github.run_id }}" - workflow-parts-version: 58dbfe0e9927f2191796ac616a4bb43126e59d98 + workflow-parts-version: 00907950b2c345e8ad6934b07268cef056d50d86 secrets: container-registry-password: ${{ secrets.DOCKERHUB_TOKEN }} container-registry-username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/reusable-git-check.yml b/.github/workflows/reusable-git-check.yml index 1993f7ee..dbf41024 100644 --- a/.github/workflows/reusable-git-check.yml +++ b/.github/workflows/reusable-git-check.yml @@ -1,4 +1,4 @@ -name: Reusable - Git check +name: Reusable - Git check on: workflow_call: diff --git a/.istarci.yml b/.istarci.yml new file mode 100644 index 00000000..68190150 --- /dev/null +++ b/.istarci.yml @@ -0,0 +1,25 @@ +# IstarCI configuration, read from the root of the repository. + +runner: + image: ghcr.io/devpro/ubuntu-dotnet:latest + images: + "git-check*": ghcr.io/devpro/debian-node:latest + "markup-lint*": ghcr.io/devpro/debian-node:latest + "image-scan*": ghcr.io/devpro/debian-node:latest + # The scan jobs build an image and scan it through the host Docker daemon. + docker: true + +workflow: + # Actions published by GitHub or Docker, and the composite replacements for every other one kept in github-workflow-parts. + allowedActions: + - actions/* + - github/* + - docker/* + - devpro/github-workflow-parts/* + # Delivery publishes on a push to the default branch, which a local run must never reach. + exclude: + - pkg.yaml + +# Repository variables the pipeline reads, which a local run otherwise gets as placeholders, and an image tag refuses the uppercase in one. +vars: + CONTAINER_REGISTRY_PATH: devprofr diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 5e56cf49..5e44c73d 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -1,6 +1,6 @@ gitignore: true ignores: - - "**/node_modules/**" + - CLAUDE.md config: default: true MD013: diff --git a/.yamllint.yaml b/.yamllint.yaml index 4a888422..5fef8a9c 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -1,4 +1,4 @@ -extends: default +extends: default # ref. https://yamllint.readthedocs.io/en/stable/configuration.html ignore-from-file: - .gitignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f08bbfe3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,822 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. + +## Project overview + +Keeptrack is source-available (PolyForm Strict 1.0.0, see `LICENSE`, not open source). +It saves and reviews everything read, watched, listened to or played: books, movies, TV shows, albums, video games, plus car, house and health journals. + +Three-tier .NET 10 / C#: `BlazorApp` (Blazor Server UI), `WebApi` (ASP.NET REST API), MongoDB. + +## Hard rules + +**Never run a third party container image on this workstation.** +No `docker run` and no `docker pull` of any image not published by Docker or by GitHub. +Security scanners are invoked as locally installed binaries, never containerised. +A tool with no local install path is left unwired and recorded in `docs/backlog.md`. + +**Linters are never run by an agent, and never imitated by hand.** +No `markdownlint`, no `yamllint`, no formatter (the owner applies `npx neatmd .` to Markdown), no `npx` invocation of any of them. +Reading a lint configuration and reshaping text to fit it, or rewrapping a paragraph so a tool would be quieter, is the same thing as running the linter. +An agent that notices prose a linter might flag says so in its report and changes nothing. +Test commands are not linters. + +**Every command runs in the foreground, and the agent waits for it.** +No background command, no subagent, no fork, no parallel task, even for a long command. + +**Only GitHub Actions published by `github` or `docker` are allowed in workflows.** +Reusable actions owned by this account, in `../github-workflow-parts`, are also allowed. +Anything else is replaced by an explicit command that downloads the official release binary and verifies its SHA256 checksum. + +**IstarCI is recommended but optional, and runs from its package.** +The CI is the GitHub Actions pipeline, which IstarCI only runs locally first. +`task ci:setup`, `task ci` and `task ci:logs` use the installed `@devpro/istarci` package, and a clone of IstarCI is for developing IstarCI only (`task ci:from-clone`). + +**The harness is Node.js and bash only.** +Python scripts or code is not allowed. + +**Test before code when applicable.** +Tests are written before the implementation and versioned as the source of truth for expected behavior. +A regression test is seen failing for the stated reason before the fix, a refactor of untested code gets its test first, seen green against the unmodified code. + +**Preserve existing comments and formatting when editing a file.** +Commit only when asked, and never push. + +**Documentation is as short as possible, and headers stay.** +Short documentation still has sections. +It keeps what is needed to understand the code: why it is shaped this way and the known gotchas, never the story of how it got there. + +## Writing style + +These are hard rules too. +They apply to Markdown, code comments, commit messages, chat replies, error message strings and any prose in scripts. +They are the repository's conventions, applied by default: a report states what the repository does, never presents a convention as the reader's request. + +**One thought per line.** +Every sentence starts on its own line, and the renderer joins them back into a paragraph, so a diff shows only the sentence that changed. +A sentence that runs long may continue on the next line at a clause boundary, never mid-clause and never to fit a width. +There is no maximum line length. + +**A comment says why, not what, and the why is timeless.** +The code says what it does. +A comment records only what cannot be read off it: the failure it prevents, the constraint that made the obvious shape wrong, the alternative rejected and why. +It is written as if it had always been so: never "used to", never "this replaced", never the story of the session that produced it. +"Filters the parent id with `Eq`, since MongoDB allows one `$text` per query" earns its place, "filter by parent" does not. +A comment that would restate the member name, or a paragraph that walks through the method below it, is left out. + +**Never use the em dash (`—`) or the en dash (`–`), nor a spaced hyphen standing in for one.** +Use a colon when introducing an explanation, a comma when joining clauses, or a full stop and a new sentence. + +**Never use the second person.** +No "you", no "your", not even in placeholders such as ``, which reads ``. +The documentation describes the repository, it does not address a reader. +The contribution terms in `CONTRIBUTING.md` are legal text and the one exception. + +**Other conventions.** +Use `ini` as the fence language for `.properties` blocks, never `properties`. +Prefer `>` over `→` for UI navigation, for example **Project Settings > Quality Gate**, arrows remain acceptable in diagrams. + +## Repository conventions + +This file holds what cannot be read off the code: a convention, a decision and its reason, a gotcha measured against a real system, in one or two lines each. +What was done and when is git history's, and the detailed why and gotchas of a subject belong to `docs/findings/`. +A sentence that only says something used to be otherwise is deleted rather than kept. + +Shell scripts are named in `snake_case` and committed with the executable bit set (`git update-index --chmod=+x path/to/script.sh`), since a script committed as `100644` fails on a fresh clone. + +The root `README.md` stays as short as possible. +Shared content lives in `docs/` and is linked, never copied. +`CONTRIBUTING.md` holds only the guided steps to be up and running, and design explanation stays here. +Superseded plans, finished migrations and dated assessments live in `docs/archived/`. +Known debt not yet scheduled is listed in `docs/backlog.md`. + +Target platform is Linux, including WSL2, and `bash`. + +## Commands + +```bash +dotnet restore && dotnet build + +dotnet run --project src/WebApi # https://localhost:5011/ +dotnet run --project src/BlazorApp # https://localhost:7042/ + +dotnet test # Microsoft.Testing.Platform runner, xunit v3 +dotnet test --project test/WebApi.UnitTests/WebApi.UnitTests.csproj +dotnet test --filter-method "Keeptrack.WebApi.UnitTests.Services.WatchNextServiceTest.ComputeInProgressShows_*" + +docker build . -t devprofr/keeptrack-blazorapp:local -f src/BlazorApp/Dockerfile +docker build . -t devprofr/keeptrack-webapi:local -f src/WebApi/Dockerfile + +docker run --name mongodb -d -p 27017:27017 mongo:8.2 # required for WebApi + integration tests +``` + +Integration tests need Firebase test-user credentials and MongoDB settings, as env vars or in a `Local.runsettings` at the repo root (template in `CONTRIBUTING.md`, never committed). + +**Gotcha: `--settings Local.runsettings` runs zero tests (exit code 5)**, filtered or not, since the Microsoft.Testing.Platform runner does not read a runsettings file. +On the command line, `scripts/load-runsettings.js` exports its variables instead: + +```bash +eval "$(node scripts/load-runsettings.js)" +dotnet test --project test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj --filter-method "*WishlistResourceTest*" +``` + +The PowerShell equivalent: + +```powershell +[xml]$rs = Get-Content Local.runsettings +$rs.RunSettings.RunConfiguration.EnvironmentVariables.ChildNodes | Where-Object { $_.NodeType -eq 'Element' } | ForEach-Object { Set-Item -Path "env:$($_.Name)" -Value $_.InnerText } +``` + +**Never convert those values into plain `NAME=value` lines and source them.** +Sourcing runs each value through shell expansion, so a secret containing `$` silently loses everything from the `$` to the next non-word character, which surfaces as Firebase answering `INVALID_PASSWORD`. +The script single-quotes every value, escapes any embedded `'`, decodes XML entities and skips commented-out variables, and `Set-Item -Value` never re-parses what it is given. + +## Architecture + +Layered / clean architecture across small single-purpose projects (`src/*`), `Domain` at the center, nothing references outward. + +Project | Depends on | Responsibility +-------------------------|--------------------------------------------------------|--------------- +`Common.System` | none | Cross-cutting primitives: `IHasId`, `IHasIdAndOwnerId`, `PagedRequest`, `PagedResult`, `TitleNormalizer`, `ListSort`, `RefuelCostCheck`. +`Domain` | `Common.System` | Business models (`Models/*Model`), repository interfaces (`Repositories/I*Repository`), pure services (`Services/`). No persistence or web concerns. +`Infrastructure.MongoDb` | `Domain` | BSON `Entities/`, `Repositories/`, `Mappers/`. +`WebApi.Contracts` | `Common.System` | Public REST DTOs (`Dto/`), shared with `BlazorApp` so the client deserializes without duplicate classes. +`WebApi` | `Infrastructure.MongoDb`, `Domain`, `WebApi.Contracts` | Controllers, DTO mappers, DI, JWT auth, OpenAPI/Scalar. +`BlazorApp` | `Common.System`, `WebApi.Contracts` | Blazor Server UI over HTTP, never references `Domain`/`Infrastructure.MongoDb`. + +### Data model conventions + +Every user-owned entity implements `IHasIdAndOwnerId` at all three layers, one class each. +Mapping is compile-time via [Riok.Mapperly](https://github.com/riok/mapperly): one `[Mapper]` partial per pair in `Infrastructure.MongoDb/Mappers/` (`IStorageMapper`) and `WebApi/Mappers/` (`IDtoMapper`). + +- Unmapped members are **build errors** (`RMG012`/`RMG020` escalated in `.editorconfig`). + A member a direction genuinely doesn't need gets an explicit `[MapperIgnoreSource]`/`[MapperIgnoreTarget]`. +- **A single-word entity property is stored camelCase, not snake_case**: `CamelCaseElementNameConvention` is registered globally in `InfrastructureServiceCollectionExtensions`, + so only multi-word members carry an explicit `[BsonElement("public_reimbursement")]`. + Repository code names fields with **expressions**, which resolve through the class map. + Anything written by hand against raw names must use the stored one: an index or a `$rename` naming `Specialty` instead of `specialty` matches zero documents and reports success. +- `OwnerId` uses `[MapValue(nameof(Model.OwnerId), "")]` on DTO to model (a plain ignore won't compile, it's `required`). + `DataCrudControllerBase` overwrites it from the caller's claims, it is never trusted from client input. +- Read-only feature controllers (`WatchNextController`, `WishlistController`, Car/House metrics, `ReferenceDataController`) use a small one-directional Model to Dto mapper injected by its concrete type. +- An enum used in a Domain model needs a **separate** copy in `WebApi.Contracts` (Contracts doesn't depend on Domain). + Member names stay identical and DTO mappers set `EnumMappingStrategy.ByName`, so drift becomes a build diagnostic. + Mongo entities reuse the Domain enum directly. +- A nullable DTO member mapping to a `required` model member needs an explicit fallback: `CommonDtoMappings.ToRequiredString` (attached via `[UseStaticMapper]`). + `CarDto.EnergyType` (nullable, and a different enum type) has its own hand-written `[UserMapping]` wrapping the generated `ByName` conversion. +- Never name a property bare `Type`: discriminators are `CarHistoryModel.EventType`, `HouseEventType`, `HealthEventType`, `TvShowModel.State`. + +**Gotcha: a DTO used by `InventoryPageBase` can never have a `required` member.** +That base is constrained `where TDto : IHasId, new()`, and any `required` member breaks `new()` (`CS9040`). +That is why `BookDto.Title`, `CarDto.Name` and friends are nullable while their models are `required`, and DTOs with no `InventoryPageBase` usage (`CarHistoryDto`) mirror `required` in full. + +**Data-shape renames need an idempotent migration script** (`scripts/migrate-*.js`, a `$rename` or equivalent, followed by a re-run of `scripts/mongodb-create-index.js`), +not just updated `[BsonElement]` attributes, or every pre-existing document silently loses the field. +A C# rename that keeps the stored element name (`[BsonElement("status")]` on `State`) needs none. + +### Adding a new trackable item type + +Follow `Book`/`Movie`/`Album`/`TvShow`/`VideoGame`/`Car` as the template. +A new type touches every layer: + +1. `Domain/Models/Model.cs` plus `Domain/Repositories/IRepository.cs` (extends `IDataRepository`). +2. `Infrastructure.MongoDb/Entities/.cs` plus `Repositories/Repository.cs` (extends `MongoDbRepositoryBase`, overrides `CollectionName` and, if searchable, `GetFilter`). +3. `WebApi.Contracts/Dto/Dto.cs` (XML doc comments feed the OpenAPI spec). +4. `WebApi/Controllers/Controller.cs`: a one-line class extending `DataCrudControllerBase`, CRUD logic is never duplicated per controller. +5. Register the repository and storage mapper in `WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs`, the DTO mapper in `Program.cs`. +6. `BlazorApp/Components/Inventory/Clients/ApiClient.cs` (extends `InventoryApiClientBase`) plus `Pages/.razor`/`.razor.cs` (extends `InventoryPageBase`, overrides `ListRoute`). + The Add form carries only identity fields, everything else is edited on the detail page, which starts with a ``. + List rows are uniform media rows rendered by `InventoryList`, the whole row opens the detail page, and there is deliberately no per-row edit modal. +7. Declare indexes in `scripts/mongodb-create-index.js` (natural-key uniqueness, query shapes, partial indexes for sparse flags). +8. If reference-linked: the DTO implements `IReferenceLinkedDto` (server-hydrated `ImageUrl`, ignored both ways in the mapper), the reference repository gets a batched `FindByIdsAsync`, + and the controller overrides `OnListMappedAsync` to hydrate covers via `ReferenceImageHydrator`, one batched lookup per page, never one per item. + +### Ownership: owned versions, never a stored flag + +An item is owned exactly when it has at least one owned copy, so there is no stored `is_owned` flag to drift. + +- `Movie`/`TvShow`/`Book`/`Album` embed `List` (`owned_versions`): `CopyType` (`Physical` first so it's the default, or `Digital`), + optional `Price` (`decimal`/Decimal128, currency-agnostic), `AcquiredAt` (`DateOnly` via `CommonStorageMappings`), `Vendor`, and free-text `Reference` (edition/order number, unrelated to `ReferenceId`). +- Video games have **no** `OwnedVersions`: their per-platform entries carry a `CopyType` and *are* the copies, so a game is owned when `Platforms` is non-empty. + `VideoGamePlatformModel.ProductName` (the store's own product/edition text) renders through `OwnedVersionFields`' `ExtraFields` slot rather than joining `IOwnedCopyDto`, since no other type has the concept. +- `IsOwned` exists only as a query parameter: repositories translate it to `SizeGt(OwnedVersions/Platforms, 0)`, the `*_owned` partial indexes match that predicate, and storage mappers ignore it both ways. +- Detail pages share `OwnedVersionsEditor`/`OwnedVersionFields` (`Components/Inventory/Shared/`, unnumbered `col-md` columns so an extra column re-shares width), list rows derive the Owned badge from `OwnedVersions.Count > 0`. + A new copy is a draft card with Save/Cancel, an already-saved copy auto-saves on change like every other detail field, and removing a saved non-empty copy asks through `ConfirmModal`. + +### Bulk store/retailer transaction imports + +Three importers of the same shape, controller in `Controllers/` and pure parsing/computation in `Domain/Services/`: + +- `AmazonImportController`/`AmazonOrderPreviewService`: order-history CSV, keeps only what's Amazon-specific (`FormatOrderReference` = ASIN plus order id, `BuildAmazonProvenanceNotes`). +- `GenericVideoGameImportController`/`GenericVideoGameImportService`: video-game-only transaction CSV (PSN's GDPR export, `Vendor` is a per-row column so any store with that shape works). +- `GenericImportController`/`GenericImportService` (`POST /api/import/generic`, `MemberOnly`): **the store-agnostic default, extended rather than adding another store-specific importer.** + It reads a canonical, case-insensitive column set (all optional except `Title`). + A `Type` column sets each row's `ImportMediaType` (`ParseMediaType` tolerates "TV Show", "Video Game", "Film", "Jeu"), a blank or unrecognized value falls back to the per-row picker rather than guessing. + Aliases cover real headers (`Product Name` to Title, `ASIN`/`SKU` to `ProductId`, `Total Amount` to Price, `Product Condition` to Condition). + `Vendor` becomes the copy's Vendor and `Website` its `Reference`, and `Condition` is kept on the copy's `ProductName` (owner's request). + +The shared engine is never duplicated: `Domain/Services/OwnedItemImportMergeService.cs` (`ComputeCommitPlan`/`FindImportedReferences`, matching by normalized title via `TitleNormalizer`, +merging within the same commit batch) returns `Domain/Models/ImportCommitPlan.cs`, and `Domain/Services/OwnedItemImportCommitCoordinator.cs` owns per-type create/merge orchestration for both multi-type controllers. + +`ImportMediaType` and `CopyType` exist in both `Domain.Models` and `WebApi.Contracts.Dto`, so a controller importing both namespaces aliases one. + +- **An import reference or dedup key needs a per-product disambiguator**: Product Name for PSN, ASIN for Amazon, order id plus product id for generic. + One PSN transaction bundles several products (three "Far Cry 4" DLC packs), so transaction plus order id collides and silently skips lines as duplicates. +- **`VideoGamesCreated`/`VideoGamesMergedInto` count distinct items, not rows**, since rows sharing a normalized title consolidate into one item. + `RowsImported` is the per-row count (`RowsImported + Skipped` equals rows submitted) and `SkippedRowTitles` names the rest, so the UI shows "X of Y selected rows imported". + +### Child entities (1-to-many owned by another entity) + +`CarHistory`/`Car`, `Episode`/`TvShow`, `HouseHistory`/`House`, `HealthRecord`/`HealthProfile` are separate top-level collections referencing the parent by id (`car_id`, `tv_show_id`), not embedded arrays. +They grow unbounded per parent, and features query them across *all* of a user's parents at once (Watch Next), which needs a plain indexed query rather than `$unwind`. +Embed only small, always-together, never-queried-alone data (`TvShowReferenceModel.Episodes`: bounded, always fetched whole). + +- `GetFilter` on a child repository filters the parent id with `Eq`, **not** `Text`: MongoDB allows one `$text` per query, so a `Text` id filter throws whenever a free-text `search` is also supplied. +- **Every parent cascades its delete**: its controller overrides `OnDeletedAsync` and calls the child repository's `DeleteAllForAsync`, + a one-liner over `MongoDbRepositoryBase.DeleteAllByParentAsync` taking the parent-id **expression**. + A child is only reachable through its parent id, so it would otherwise be orphaned forever. + Each cascade has a real-MongoDB `*ResourceTest` case, since a mocked repository can't prove the filter matches. + +**Car.** +`CarHistoryModel.DeltaMileage` is user-entered (read off the trip computer), not derived, and `CarMetricsService` cross-checks it against consecutive `Mileage` readings to flag typos or skipped entries. +`CarMetricsService` (consumption across a full refill, cost history, mileage warnings, next maintenance due) is a pure singleton exposed via `CarController.GetMetrics`. + +- **A refuel's station is a reference** (`CarHistoryModel.StationId` to `car_station`), owner-less like the `*_reference` collections since a station at an address is a public fact. + A refuel carries no location of its own, `CarHistoryController.OnListMappedAsync` hydrates `StationBrandName`/`StationCity` through `CarStationHydrator`, one batched lookup per page. + Maintenance and Other entries keep their own location and free-text `Garage`, having no station to inherit one from. +- **Members create stations inline, admins curate them.** + `POST /api/car-stations` is find-or-create on the natural key (normalized brand, normalized city, postal code, unique index), so the picker never mints duplicates and a member never waits on an admin. + `/admin/car-stations` (`AdminOnly`) adds the real city and coordinates and merges duplicates. + `city_normalized` is `""`, never null or missing, or the unique index would collapse every cityless station onto one key. +- **A station in use cannot be deleted, only merged** (409 naming the count), since deleting blanks every referencing refuel's location. + The merge re-points every entry (`RepointStationAsync`) **before** deleting the absorbed document and fills only the survivor's gaps, adopting the city only when it doesn't collide with a third station's key. +- **The refuel form checks the total against what was pumped, and only warns** (`Common.System/RefuelCostCheck`, shared by `BlazorApp` and `Domain`). + A receipt legitimately differs from the pump (car wash, loyalty discount), so it never auto-fills or blocks, but a missing cost when one is computable counts as a mismatch. + The tolerance derives from what the pump display rounds away (volume to 0.01, unit price to 0.001), since a flat constant false-positives on a big tank and misses a real error on a small one. +- `CarHistoryDto.FuelCategory` ("SP95-E10") is offered through `SuggestInput` over `GET /api/car-history/fuel-categories`, like gear categories, both over `MongoDbRepositoryBase.FindDistinctValuesAsync`. + +**House.** +Deliberately smaller than Car (owner priority: browsable insurance log plus yearly cost review), with only `HouseMetricsService.ComputeAnnualCostHistory`. +`HistoryDate` is `DateOnly` and reuses `CommonStorageMappings`, and one `Provider` field covers every category. + +**Health.** +The parent is a *person* (`HealthProfileModel.Name`), and both controllers are `MemberOnly`. +`HistoryDate` is a full `DateTime` like Car's (appointment time is real data), stamped UTC via an explicit `[MapProperty(Use = ...)]` in `HealthRecordStorageMapper`. +The money model is the French reimbursement flow: `Price`, `PublicReimbursement`, `InsuranceReimbursement`, `NotCovered`. + +- A record is *settled* when `price - public - insurance - notCovered == 0` within `HealthMetricsService.BalanceTolerance` (0.005), anything else lands in `HealthMetricsModel.UnbalancedRecords` with the signed missing amount. +- **The balance rule lives only in `HealthMetricsService`**, the journal's "to check" badges come from the metrics' id list and are never re-derived client-side. +- The detail page is journal-first and badge-only (owner feedback): no "to check" list, no chart, no per-row reimbursement column, the yearly table after the journal. +- **`Specialty` and `Practitioner` are free text with suggestions over what this account already recorded** (`GET /api/health-records/suggestions`, both lists in one `HealthRecordSuggestionsDto` since the form needs both at once). + Owner-scoped and never a shared catalogue: a doctor's name is one account's medical history, not a public fact. + +**Charts.** +Charts are Razor components drawing SVG markup (`ConsumptionChart`, `CarCostHistoryChart`, `HouseCostHistoryChart`), never `RenderTreeBuilder` code with computed sequence numbers (`ASP0006`). +Axes are drawn once by `Components/Shared/ChartAxes.razor`, geometry and coordinate formatting live in `SvgChartHelpers`, and each chart keeps its own series drawing. +**SVG coordinates are always formatted with the invariant culture** (`SvgChartHelpers.ToSvg`), since a host under a decimal-comma culture would otherwise write `40,0` and break every chart. +Chart CSS (`.kt-callout*`, `.kt-chart-*`, `.kt-sheet-table`, `.kt-legend-*`) is global in `app.css`. +House's yearly cost chart is a single-series bar chart plus a breakdown table. + +### Web API request flow + +`DataCrudControllerBase` implements the whole CRUD surface once, reading the caller's `user_id` claim via `ControllerBaseExtensions.GetUserId()` to scope every query and stamp `OwnerId`. +Every controller uses that extension rather than re-reading the claim. + +**A record update never changes a reference link** (`DataCrudControllerBase.PreserveServerOwnedFieldsAsync`, over `IReferenceLinkedModel`). +A PUT is a full replace, and a detail page holding a copy fetched before background resolution linked the item would otherwise write the empty link back. +Resolution, the detail page's check and an admin unlink write the link through a repository, not through the CRUD controller. + +`ApiExceptionFilterAttribute` logs every unhandled exception, then converts it: `ArgumentException` to 400, a failed provider call to 502, else 500. + +- **A third-party provider that timed out, exhausted its retries or tripped its circuit breaker is a 502, not a 500** (`TimeoutRejectedException`/`BrokenCircuitException`/`HttpRequestException`, logged as a warning), + so an outage is distinguishable from a defect here. +- **The 502's `{ error }` says what the provider actually did** (`DescribeUpstreamFailure`: its status, unreachable, timed out or circuit-open). +- **`BlazorApp/Components/Shared/ApiResponseExtensions` (`EnsureSuccessOrThrowAsync`/`ReadJsonOrThrowAsync`) is used wherever a failure is shown to a user, never `EnsureSuccessStatusCode`/`GetFromJsonAsync`**, which discard the body. + They throw `ApiRequestException`, whose `IsUpstreamProviderFailure` tells a provider outage from a defect. + `InlineReferenceLinker` then names the provider, quotes the detail, and points at the provider picker when the domain has more than one. + +**Resilience:** every outbound third-party client chains `.AddStandardResilienceHandler()` on its `AddHttpClient<...>()` registration, a new one included, never hand-rolled (`ExternalProviderResilienceTest`). + +`HostOptions.BackgroundServiceExceptionBehavior = Ignore` in `Program.cs` is a backstop, since by default an exception escaping any `BackgroundService` stops the **entire host**. +A background service still catches what it can anticipate. + +Read-only cross-entity aggregations (`WatchNextController`, `WishlistController`, `StatsController`, `SystemStatusController`) are plain `ControllerBase` in `WebApi/Controllers/`, with any real computation in `Domain/Services/`. +`WebApi/Import/` and `WebApi/ReferenceData/` use an older feature-folder shape that new code does not extend. + +**Wishlist sharing is capability-URL based**: `GET/POST /api/wishlist/shares`, `DELETE /api/wishlist/shares/{id}` (`wishlist_share`, one document per link, `token` unique), plus `GET /api/wishlist/shared/{token}`, +the app's **one deliberately anonymous read**, backing a static-SSR `noindex` page at `/shared/wishlist/{token}`. +The 128-bit token *is* the access control: there is no mail infrastructure and it works for unregistered recipients. +Revoking deletes one owner-scoped document, and `SharedWishlistApiClient` is registered **without** `AuthenticationTokenHandler`, which would bounce an anonymous recipient to login. + +**Long-running work runs as a background job, never a blocking request**: buffer the input, start the work on a fresh `IServiceScopeFactory.CreateScope()`, return a job id, poll status. +`JobStore` (`WebApi/Jobs/`) is backed by MongoDB (`background_job`, TTL 7 days), since the replica answering a poll isn't the one running the job. +Owner id is checked in the repository query on every read, and a background task resolves its own `JobStore` from its own scope. + +**A detached background job runs on `IHostApplicationLifetime.ApplicationStopping`, not an unbounded token.** +Otherwise on shutdown it keeps working against disposed singletons (Mongo client, HTTP clients, IGDB's rate limiter), every step throws `ObjectDisposedException`, and the job reads "Running" forever. +For the same reason, per-item catches that keep one failing document from aborting a run (`ReferenceSyncService`, `ExploreCatalogueRefreshService`) exclude `OperationCanceledException`. + +### Auth, tiers and admin settings + +- Firebase auth: cookie in `BlazorApp`, JWT bearer validated against Firebase in `WebApi`, `AuthenticationTokenHandler` attaches the bearer to outgoing calls. +- Authorization is **policy**-based: `AdminOnly` = `RequireClaim("role", "admin")`, `MemberOnly` = `RequireClaim("role", "member", "admin")`, registered in both `Program.cs` files. + Firebase sends a plain `role` claim, which `BlazorApp`'s `AuthenticationController` copies into the cookie principal at sign-in (`FirebaseClaimsBuilder`). +- **Gotcha:** `AddJwtBearer` sets `MapInboundClaims = false`, otherwise the handler renames short JWT claim names to legacy `ClaimTypes.*` URIs and `RequireClaim("role", ...)` never matches. + A new custom claim is checked against this. +- **Free preview tier:** an account with no `role` claim gets movies and TV shows only, capped at `Features:FreeTierItemLimit` per collection (default 20, `AppConfiguration.GetFreeTierItemLimit`), + episodes at 100x that (`EpisodeController.FreeTierLimitFactor`, only to stop a raw-API caller flooding the database). + Enforcement is API-side: `[Authorize(Policy = "MemberOnly")]` on every restricted controller plus the creation quota in `DataCrudControllerBase.Post` (403 with `{ error }`). + `NavMenu.razor` hiding sections is UX, never security, and `FreeTierTest` carries a reflection guard asserting each controller's expected policy. +- **Runtime-changeable global admin settings** live in one `app_setting` document (`_id: "global"`, one field per setting) via `IAppSettingRepository`, written with a targeted `$set`. + A new setting is a new field there, and deploy-time values stay in `AppConfiguration`/env vars. + +### Reference data (shared, owner-less) + +`tvshow_reference`, `movie_reference`, `book_reference`, `videogame_reference`, `album_reference` and `person_reference` hold provider metadata. +They are the deliberate exception to "every collection has `owner_id`": public facts about a real work, stored once, pointed at by every tenant's `ReferenceId`. + +Providers: TMDB (TV/movie), IGDB and RAWG (video games), Discogs (albums), Google Books, Open Library and BnF (books), OMDb (IMDb ratings for TV/movie). +Images are hotlinked from the provider CDN (TMDB's sanctioned pattern), so there is no local image storage. + +- These repositories do **not** extend `IDataRepository`/`MongoDbRepositoryBase`, both constrained to `IHasIdAndOwnerId` and owner-scoped CRUD. + A new owner-less collection gets a small purpose-built repository. +- `ReferenceEnrichmentService` is one `partial class` split by domain file, each with `TryLinkExistingReferenceAsync`/`TryAutoResolveAsync`/`ResolveAsync`/`RefreshReferenceAsync`, shared helpers in the core file. +- It is the single place a title and identity resolve to a provider id. + Automatic resolution fires from `Controller.OnCreatedAsync` and from `TvTimeImportService` on its own DI scope, never awaited inline. +- **A link reaches only the record it was made for** (`ReferenceLinkTarget`): creating an item, the check button and Explore's add link that one item, since another owner's copy is theirs to check. + Only an admin's link reaches other records, every unlinked one matching the title and year it was made with, plus the artist for an album, which the admin must supply. +- `SetReferenceLinkAsync` also sets `Title`, `Year` and per domain `Author`/`Artist`/`Genre`/`Language` from the canonical record, but **never overwrites with nothing**. + `VideoGameModel.Platform`/`State` describe the tenant's own copy and are never overwritten. +- **Person dedup is by provider person id, never by name** (`ResolvePersonReferenceIdAsync`, covering actors, authors and artists). + References store only the id and `ReferenceDataController` hydrates names and pictures server-side, which is why those DTO members are `[MapperIgnoreTarget]`. + +**Gotcha: "no reference link yet" is null *or* empty.** +Documents written before Mapperly store `""`, so `Eq(x => x.ReferenceId, null)` misses them. +Any "is this string field unset" query copies `TvShowRepository`/`MovieRepository`'s `UnresolvedFilter()`, and only a real-MongoDB test catches this class of bug. + +**Gotcha: every `Find*Async` that can find nothing checks `entity is null` before mapping**, `MongoDbRepositoryBase.FindOneAsync` included, since Mapperly throws on a null source. + +#### Local aliases + +**Every match path asks the local aliases before a provider** (`TryLinkKnownReferenceAsync`, first in every `TryAutoResolveAsync`, and the whole of `TryLinkExistingReferenceAsync`). +A stored alias is an established answer, and re-deriving it through a fuzzy provider search is at best slower and at worst different. + +`MatchedAliases` (`List`, `(Title, Year, Creator, Isbn)`) records every combination confirmed to mean this work, merged and never overwritten. +Queries use `Builders.Filter.ElemMatch` so every condition holds on the *same* element, written once in `Infrastructure.MongoDb/Repositories/ReferenceAliasQueries.cs` over `IHasMatchedAliases`. + +**An alias carries its domain's whole identity or is not stored** (`Domain/Services/ReferenceAliasRule.cs`), since a half-key answers questions nobody confirmed. + +- **Film, show, game: title plus year** (`TitleAndYear`). +- **Album: title plus creator, no year** (`TitleAndCreator`), since one release exists as many pressings under many years. +- **Book: title plus creator, year recorded when known** (`TitleAndCreatorWithYear`), or an ISBN alone. + The year is recorded but not required: a book with no provider year would otherwise get no alias and be *unlinked* by the next "check for reference match". +- The book lookup is a ladder (`FindKnownBookReferenceAsync`): ISBN, then `(title, creator, year)`, then `(title, creator)` whatever the year. +- `Creator` always comes from the canonical provider response, never from tenant text, and `Isbn` is recorded only on the alias that used it. +- Indexes follow each domain's lookup (`title`+`year`, `title`+`creator`+`year`, `title`+`creator`, partial `matched_aliases.isbn`), or the `ElemMatch` scans the collection. + +**The title-only lookup refuses to choose: `FindByTitleAsync` returns a match only when there is exactly one** (`ReferenceAliasQueries.FindSingleMatchAsync`), +since ambiguous and no-match are the same answer to a caller that must not guess. +Not applied to `FindByTitleYearAsync` or to the album lookup, where two documents sharing the whole identity are a duplicate to merge. + +#### Resolution and confirmation + +`ResolveAsync` looks up an existing reference **by provider id first** (`FindByExternalIdAsync`), then by the domain's identity, since title text can't prevent duplicates and the provider id is invariant. + +External-id indexes are `unique: true` with `partialFilterExpression: { "external_ids.": { $exists: true } }` (not `sparse`, so documents missing the key don't collide on null). +There is **one index per provider that can write a collection**, since a document holds ids from several: books cover `googlebooks`/`openlibrary`/`bnf`, +and `person_reference` covers `tmdb`/`discogs`/`googlebooks`/`openlibrary`/`bnf` (`ResolvePersonReferenceIdAsync` is handed the linking client's `ProviderKey`). + +`TryLinkExistingReferenceAsync` backs `POST /api//{id}/refresh-reference`, the "check for reference match" control shown on every detail page to every authenticated user. + +- It checks local references first and **escalates to the provider when nothing matches** (`LinkReferenceAsync`), linking exactly what resolution on create would have. +- It does **not** short-circuit on an existing `ReferenceId`, since `Title`/`Year` are editable and replacing a bad match is the point. +- On a match it updates the tenant's document and no other record. +- On **no** match for a linked item the link is cleared (`ReferenceId = ""`), returning it to the admin queue. +- The title-only fallback runs **even when `Year` is null**, since `FindByTitleYearAsync(title, null)` only matches a reference whose year is also null. +- Editing a field never searches by itself: the button is the only thing that re-resolves. + +**Automatic resolution confirms a *named* match, never that a provider returned one row** (`ReferenceMatchRules`, shared by all five domains). +Row counting refuses ordinary titles (TMDB's fuzzy `The Bear` + 2022 returns 8 results) and links titles nobody compared (`Fallout` + 2025 returns one result, and it is "Thirst Trap: The Fame. The Fantasy. The Fallout."). + +- **Two identity shapes, a real difference between domains.** + Film, show and game are title **plus year** (`ConfirmedMatches`), and a single confirmed match links. + Book and album are title **plus creator** (`ConfirmedCreatorMatches`), the year is a tie-break and never a filter, and several confirmed candidates are printings of one work, so the best one links. +- **An identity field is mandatory for any automatic link** (owner's rule): a year for films, shows and games, a creator for books and albums. + Without it the item waits for "check for reference match". +- Candidates must agree with the creator the tenant supplied, not with each other ("J.R.R. Tolkien" and "John Ronald Reuel Tolkien" are one author). +- **An exactly-spelled title beats a loosely-matched one**: `NormalizeLoose` drops "the" and parenthesised groups, + so the loose tier is used only when nothing matches exactly (`Alien` must not link *The Alien*, `Shogun` still links *Shōgun*). +- **A hard year filter is never the only query asked.** + TMDB TV's `first_air_date_year` and Discogs' `year` are hard, so those searches run with and without it and union the results. + TMDB movie's `year` is not hard, so movies are ranked and never widened. +- **A lower unattended-link rate is the design, not a regression** (owner's call): when matching looks too strict, improve the queries, never loosen what counts as identity. + +`ReferenceDataAdminController` (`AdminOnly`) handles manual search and link over a 5-way `ReferenceItemType`, with provider-neutral `ExternalId`/`Provider` DTO fields. + +#### Providers + +**IGDB** is a plain typed `HttpClient` with three differences handled around it. + +- It authenticates with a **Twitch app access token**: `IgdbTokenProvider` caches one per process (a token is not a shared quota, unlike OMDb's budget), + and `IgdbAuthenticationHandler` attaches `Client-ID` plus bearer and retries **once** on a 401 with a fresh token. + The renewal margin is capped at half the token's lifetime, or every call would fetch a new token. +- It allows **4 requests/second**, paced by a `TokenBucketRateLimiter` in a singleton (`IgdbRateLimiter`), since `IHttpClientFactory` rebuilds handlers on rotation. +- Queries are **Apicalypse POST bodies**, so a tenant title is escaped before being embedded in a string literal. +- **Handler order is authentication, then resilience, then the rate limiter (outermost first).** + The limiter sits innermost so its queue wait counts against the resilience total timeout (`HttpClient.Timeout` is infinite), and its bounded queue's synthesized 429 is retried with backoff. +- Missing credentials are supported (`IgdbSettings.IsConfigured`): every call returns an empty result. +- **Gotcha: a stale field name fails silently.** `where category = 0` parses and matches nothing, since the field is now `game_type`. + Other field notes are in `docs/igdb-api-notes.md`. +- It reports no Metacritic score, and `aggregated_rating` gets its own `igdbcritic` key. +- `first_release_date` is unix **seconds**, and covers are `https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg`. + +**Open Library** never sends `year` as a filter (`first_publish_year` is the work's original year), and searches `q=` rather than `title=`, which misses regional variants. +`GetBookDetailsAsync` falls back to a `q=key:{workKey}` re-query when the work JSON lacks `first_publish_date`. +It exposes no reliable series, so `BookModel.Series` is not auto-filled. +Its search is the slowest endpoint here (tens of seconds, and 503s), so **the cross-provider rating fallback is guarded**: `AddOpenLibraryRatingFallbackAsync` catches everything +but `OperationCanceledException` and keeps the previously stored rating when the lookup never answered. +An optional secondary provider never fails the primary operation. + +**An optional narrowing parameter never silently zeroes out results a broader search would find.** +`DiscogsClient` and `OpenLibraryClient` retry without the artist/author when the constrained search is empty (Discogs indexes "Artist (2)"), +and `DiscogsClient` asks with and without `year=` and unions them (`Kid A` + Radiohead + 2001 returns only *Amnesiac*). + +**A provider's free-text `q=` is not a title field.** +Discogs' `q=` also matches artist, label, credits and tracklist, so `SearchAlbumsCoreAsync` drops candidates whose parsed release title fails `TitleNormalizer.LooselyContains`, +inside the core search so the artist retry still sees "nothing". +`release_title=` is precise but ranks badly (`Nevermind` + Nirvana puts the 1991 album fourth), hence filtering. +Open Library's `q=` has the same noise and is deliberately **not** filtered: read `docs/findings/by-design-and-gaps.md` before changing that. + +**BnF**'s `and (bib.author ...)` clause is not a strict intersection, so `BnfClient.SearchBooksCoreAsync` re-checks each candidate's author (`AuthorMatches`). +It is the one XML/SRU client, its `ExternalId` is the bare ARK, `dc:creator` "LastName, FirstName (dates). Role" is normalized to "FirstName LastName", and its records carry no cover art. + +**Google Books** is the book default (synopses, covers, language, widest catalogue including manga), querying `intitle:`/`inauthor:`, with an `isbn` as the sole query when given. +Its `volumes?q=` endpoint can be down for days while `volumes/{id}` answers, so a "book search is broken" report is diagnosed by curling the endpoint. +`CleanDescription` decodes entities **first**, converts newlines to `
`, then rebuilds only bare `b`/`i`/`br` tags and strips everything else, which is what makes `BookDetail.razor`'s `MarkupString` safe: +**a `MarkupString` is never rendered from text that hasn't been through it.** +Thumbnails are upgraded to `https://` against mixed-content blocking. + +#### Search policies + +**The book search policy is written once in `BookReferenceClientBase`**: ISBN alone, then title plus author, then title alone, widening only on an **empty** step, an ISBN miss included. +A provider supplies only `SearchByIsbnAsync`/`SearchByTitleAsync`, and no book provider sends `year` as a filter. + +**Books and video games are the multi-provider domains** (`IBookReferenceClient`, `IVideoGameReferenceClient`, both `IReferenceProviderClient` with `ProviderKey`/`DisplayName`), resolved through `ReferenceClientRegistry`. +`Program.cs` registers every provider (typed `AddHttpClient` bridged via transient `AddTransient`, so handler rotation still works), and registration order is the admin picker's order. +`ReferenceData:BookProvider`/`ReferenceData:VideoGameProvider` name the defaults, matched case-insensitively, and an admin picks any provider per action. +A new provider needs only its class plus one registration block, since enrichment code never names a provider. +Admin provider buttons select, and exactly one control triggers search. + +**`RefreshBookReferenceAsync` checks every registered provider's key against `ExternalIds`**, otherwise a reference linked through a non-default provider never refreshes. + +**The video game search policy is written once in `VideoGameReferenceClientBase`**: `FindGamesByExactTitleAsync` first, unioned with a `RelevancePoolSize` (50) relevance pool, then ranked. +A provider's relevance ranking truncated to five loses the game (`Code Vein` 2019 ranks sixth behind its sequel and DLC). +No video game client sends the year as a filter, so a wrong year costs a place in the list, never the result. + +**`ReferenceMatchRules` is the single declaration of "is this candidate that work"**, read by the search ranking, auto-resolution and provider adoption. + +- `OrderByBestMatch` ranks by names-the-work (`TitleNormalizer.LooselyEqual`), then year, then title distance (an edition or DLC is the game plus something), then title, never by provider relevance. +- `YearRank` is **three-state**: the requested year, then no year reported, then a contradicting year. +- `ConfirmedMatches` returns only the best year tier any candidate reaches, and a contradicting year never confirms however alone the candidate is. + +**`TryAutoResolveVideoGameAsync` links a single *confirmed* match and requires a year**, and this domain has **no** title-only local fallback (IGDB holds eight games named "Resident Evil 2", three from 1998). + +`VideoGameReferenceMatchSmokeTest` covers the journey through the real UI and real IGDB, and deletes every reference it creates (`End2EndFixture.RemoveVideoGameReferencesAsync`), +since a leftover lets the local lookup answer and hides a broken escalation. +Every regression of this domain is in `docs/findings/video-game-matching.md`. + +TV, movie and album stay hard-wired to TMDB and Discogs (provider-named DTOs and keys on purpose, swapping one is a redesign rather than config). + +#### Video game providers and reconciliation + +IGDB (`igdb`) is the default and RAWG (`rawg`) stays registered for admin search and so its stored `rawg`/`metacritic` values keep rendering. + +**Only the default provider is called on refresh**, unlike books: a second provider exists because the first went down, and falling back would make every pass pay a retry-and-timeout cycle against a dead host. +A reference that can't be adopted keeps its data and is stamped as checked so the staleness queue rotates past it. + +**A reference linked before the default changed adopts the new provider's id during the sync** (`TryAdoptDefaultVideoGameProviderAsync`), with no migration script. +It requires exactly one candidate whose title matches the reference's with a compatible year, since the reference's own title is canonical data. + +- **Failed adoption breaks Explore**: its exclusion asks each linked reference for the discovery provider's id, so an unadopted reference is a tracked game that keeps being suggested. +- `FindAdoptionCandidatesAsync` runs a ladder, widening when nothing **matched** (not when nothing came back, since a relevance search answers with unrelated results), accumulating candidates across rungs: + exact title (`where name ~ "..."`), the same with `TitleNormalizer.StripDisambiguator` (RAWG's `GoldenEye 007 (1997)`), `TitleNormalizer.ToProviderQuery` (`NieR Automata` finds what `NieR:Automata` does not), + trailing words dropped (`MaxTruncatedQueries` = 3, never below `MinTruncatedQueryWords` = 2), and finally every word as a substring (`FindGamesContainingAllWordsAsync`), shortlisted to the eight closest titles. +- Confirmation is always against the **reference's** title with `NormalizeLoose`, never against the query that found the candidate. + `Normalize` stays strict because it keys stored aliases against tenant text. +- `Marvel's Avengers` is deliberately not adopted unattended: a rule equating it with `Marvel Avengers` would equate `The Sim` with `The Sims`. +- A fruitless attempt is stamped (`ProviderAdoptionCheckedAt` per provider, retried after `ProviderAdoptionReattemptAfter` = 7 days), and the admin action ignores the window. + +**Admin provider reconciliation** (`GET/POST /api/reference-data/provider-reconciliation*`, `AdminOnly`, video games only) resolves what adoption refuses to guess, plus duplicates a provider change leaves behind. + +- The gap list and duplicate groups are database reads (`FindWithoutExternalIdAsync`, `Exists(..., false)`), and candidates are fetched per row on demand. +- A row can be searched with admin text or a pasted provider URL or numeric id (`FindGameByIdentifierAsync`); `ProviderWebLinks.TryReadIdentifier` treats nothing else as an address, since `Half-Life` looks like a slug. +- `AdoptVideoGameProviderIdAsync` writes the id onto the **existing** document then reuses `RefreshVideoGameReferenceAsync`, never `ResolveVideoGameAsync`, + which could mint a second document, and refuses when another document claims the id, naming it. +- `MergeVideoGameReferencesAsync` fills the survivor's gaps, re-points every tenant item (`RepointReferenceAsync`), then deletes the absorbed document. + `MergedImageUrl` is computed **before** the ids are unioned, since "is this a RAWG image" is "does this document carry a rawg id". + +**A stored cover on a RAWG-linked reference is never overwritten by another provider** (`PreferredImageUrl`): RAWG's landscape key art still serves from its CDN and beats IGDB's box art. +RAWG itself is exempt, or re-linking through RAWG would discard the key art it just fetched. + +**A provider only overwrites its own sources' ratings** (`MergeProviderRatings`, keyed on `SupportedRatingSources`), and a source it owns but no longer reports is dropped. + +#### Ratings + +`RatingSourceCatalog` declares every source key a stored value can carry, its scale and `RatingReattemptAfter` (90 days). +A source key is not a provider, so `rawg` and `metacritic` stay declared while stored values carry them. +`RatingSourceOptions` answers which sources a domain offers and which is effective. + +- **The video game sources come from the default client's `SupportedRatingSources`**, never a hardcoded list, so switching provider needs no code or migration. +- **A stored override that isn't on offer is ignored, never erased**, which keeps a provider change reversible. +- Movies and TV are a fixed pair (TMDB vs IMDb), since IMDb is a per-title lookup layered on TMDB. +- The admin card and `rating-sources` GET/PUT/`recompute` are generic over `RatingSourceCatalog.SelectableDomains`, and the picker is a `form-select`. +- **A tenant item's denormalized rating carries its source** (`ReferenceRatingSource` next to `ReferenceRating`/`ReferenceRatingScale`), written by every path that writes the value, even when that source has no value. +- **`recompute` opens with `CountLinkedOnOtherRatingSourceAsync`** and returns `(0, 0)` without reading references when nothing differs; an unstamped item counts as different, so the first run backfills. + It is not a value-drift repair, which is the sync's job. +- **A batch is one projected read and one bulk write** (`RecomputeBatchSize` = 500): + `FindRatingsAsync(afterId, limit)` pages by `_id`, and `SetReferenceRatingsAsync` writes one unordered `BulkWrite` of `UpdateMany`, shared in `ReferenceRatingQueries.cs` over `IHasReferenceRating`. +- Books have no selectable source: `BookPrimaryRating` reads whichever key the reference stores, which is why the source is nullable end-to-end. + +**OMDb** supplies IMDb ratings keyed by the IMDb id TMDB exposes, optional (`OmdbSettings.ApiKey` nullable). + +- **The free tier is 1000 calls/day, so every call goes through `OmdbCallBudget`**: one shared document per (provider, UTC day) in `provider_quota` (TTL 7 days), reserved **before** the call with an atomic filtered upsert, + so an over-count is possible and an under-count is not. + `Omdb:DailyCallBudget` (1000) and `Omdb:InteractiveReserve` (50): `Background` stops short of the reserve, so a user's action is never left unrated. +- **`OmdbClient` never throws for anything OMDb or the network can do**, returning an `OmdbLookupResult`, and both 401s (limit reached, rejected key) mark the day spent. +- **`OmdbLookupResult.Attempted` decides stamping**: "answered with nothing" is recorded, "never asked" leaves no stamp. +- **A backfill the spent quota skipped doesn't stamp `LastEnrichedAt`** (`ImdbBackfillOutcome.Deferred`), or it would wait a full staleness window; a missing key or a failed request still stamps. +- The `/changes` short-circuit would skip IMDb backfill forever, so `BackfillImdbRatingAsync` resolves the IMDb id cheaply via `/{tv,movie}/{id}/external_ids` on the no-change path. +- `RatingsCheckedAt` (per source) remembers a title IMDb has nothing for, checked before the id lookup, carried over on re-resolve, and ignored by interactive paths. +- `RebuildRatingsAsync` keeps the known IMDb value when the call never happened. + +#### Export and import + +`GET/POST /api/reference-data/export`/`import` round-trip the six reference collections as a zip of JSON arrays. +`FindAllAsync()` exists only for the export, and the export serializes the models so Mapperly keeps it field-complete. + +**The import is a background job** (202 plus job id, `GET /api/reference-data/import/{jobId}`), and the upload is buffered on both sides, since a blocking import outlives the client's 100s timeout. + +**The import matches by provider id, never by the exported `_id`** (`Domain/Services/ReferenceDataImportService.cs`, one algorithm over six collections via delegates). +Matching keeps the **target's** `_id`, which every tenant's `ReferenceId` points at, and falls back to `_id` only for a document with no provider id. + +- **People are imported first**, and every citing document is re-pointed (`Cast[].PersonReferenceId`, `AuthorReferenceId`, `ArtistReferenceId`). +- **A match merges**: `MatchedAliases` and `Ratings` are unioned, `RatingsCheckedAt` keeps the later attempt, and a missing value leaves the target's. +- A provider id another document claims is skipped and reported (`SkippedExternalIds`). +- An IGDB-linked export lands beside RAWG-linked copies of the same games and is reported (`PossibleDuplicates`), never auto-merged, since title text is not identity. +- `ReferenceDataImportResourceTest` covers each domain and provider over real HTTP and MongoDB. + +### Keeping reference data fresh + +`ReferenceSyncBackgroundService` is an in-process `BackgroundService` on a 24h `PeriodicTimer` with an immediate pass at startup, rather than a Kubernetes CronJob. +Each tick tries `ILeaseRepository.TryAcquireAsync("reference-sync", Environment.MachineName, 1h)`, so one replica syncs per cycle (`LeaseRepositoryTest`). + +`ReferenceSyncService.SyncStaleReferencesAsync` is the single algorithm behind the loop and `POST /api/reference-data/sync-now`. +The two differ only by the staleness windows, declared once in `ReferenceSyncWindows` (`Periodic`: 3 days for references, 7 for Explore; `Forced`: zero), guarded by `ReferenceSyncWindowsTest`. +`sync-now?force=true` rechecks everything, and without it runs exactly the background tick. +One failing document never aborts the run, and one generic loop covers five one-line domain arms (`SyncDomainAsync`). + +**`IReferenceRepository.FindStaleAsync(cutoff, limit)` picks and orders the work** (`ReferenceStalenessQueries.cs`): +never-enriched first, then least-recently-enriched, capped at `MaxDocumentsPerDomainPerPass` (500), so what a pass misses leads the next one. + +**Gotcha: "never enriched" cannot come from the date comparison**, since `Lte(LastEnrichedAt, cutoff)` matches neither null nor missing. +`Eq(field, null)` matches both and BSON sorts null first, proven only by the real-Mongo `ReferenceStalenessRepositoryTest`; `last_enriched_at` is indexed on all five collections. + +TV and movie refreshes pre-check TMDB's `/changes?start_date=...` and only bump `LastEnrichedAt` when nothing changed. +IGDB, RAWG, Discogs and the book providers have no equivalent, so they always full-fetch past the cutoff. + +**Gotcha:** the service only works when `Features:IsReferenceSyncEnabled` (default `true`, read every tick), +which `KestrelWebAppFactory` sets `false` through `ConfigureAppConfiguration` (`UseSetting` doesn't work with a top-level-statement `Program.cs`). +**Every integration fixture uses `KestrelWebAppFactory`** to inherit that, or it fires real TMDB calls. + +### TV Time import + +`POST /api/import/tv-time` is a background job. +All of the following is confirmed against real export data. + +- `seen_episode_source.csv` alone is incomplete, so `tracking-prod-records.csv` and `-v2.csv` are merged and deduplicated per (show, season, episode), earliest date winning. +- `followed_tv_show.csv` is incomplete too, so `ImportEpisodesAsync` creates shows from watch events and never skips an unfollowed show. +- Movies carry watch dates in `tracking-prod-records.csv` (`entity_type == "movie"`: watch, towatch when unwatched, follow), and `-v2.csv` has no movie data. +- **Idempotency is by stable id, never by title**, since enrichment rewrites `Title`. + Imported items carry `TvTimeId` (`IHasTvTimeId`, round-tripped on edits): TV Time's show id or the movie's tracking `uuid`, else a deterministic `tvtime_title:` via `ResolveTvTimeId`, + with `BuildIdByTitle` mapping title-only files onto the id-bearing ones. +- `UpsertIndex` matches by `TvTimeId`, falls back to title only for a record with no id yet (back-filled once by `BackfillTvTimeIdAsync`), and leaves a record with a different id alone. +- **A matched record is never modified**, so edits made in the app survive a re-import. +- **Gotcha:** a CSV property missing from some files' headers needs CsvHelper's `[Optional]` on top of being nullable, or header validation throws. + +### Watch Next + +`WatchNextService.ComputeInProgressShows(shows, episodes, referencesByShowId)` reports a show only when its `State` is `TvShowStatus.Current` **and** the reference's episode list has an entry after the last one watched, +compared by `(SeasonNumber, EpisodeNumber)`, whose `AirDate` is past or unset. +A show with no reference is excluded rather than guessed at, and `InProgressShowDto.Next*` reports that confirmed next episode. + +`FilterMoviesToWatch` excludes a movie once `FirstSeenAt` is set even if `WantToWatch` is still true, since the flag can go stale. +**`WantToWatch` is movie-only**: a "shows to start" surface would be a real Watch Next section, not a flag. + +`TvShowDetail.razor`'s episode checklist hides episodes not yet aired, and is a full watch-through checklist once the show has a `ReferenceId` (checking creates an `Episode`, unchecking deletes it), +falling back to recorded episodes plus a manual add form without one. + +### Explore (discovery) + +`ExploreController`/`ExploreService` (`/explore`) suggests top-rated titles the caller doesn't track, with one-click add and dismiss/undo, for movies, TV shows and video games (books and albums answer 400, having no best-of listing). + +- **The list originates from the provider, never from local `*_reference` collections**, which only hold titles someone already tracks. + Sources: TMDB `/{movie,tv}/top_rated` and IGDB `sort {rating|aggregated_rating} desc`. +- **Requests read `explore_catalogue`, never the provider**: a materialized copy of each ranking, rewritten weekly by `ExploreCatalogueRefreshService` (owner-less, a ranking is a global fact). + `CatalogueDepth` (1000 per ranking) sets how far a user can scroll. +- A **ranking** is a domain plus an ordering, declared once in the injected `ExploreRankings` and derived from `RatingSourceCatalog`, so a game provider gets one ranking per supported source. + `DisplaySource` falls back to the ranking's own number when the catalogue can't carry a source, and a pass prunes rankings no longer maintained. +- Ordering follows the admin-selected primary rating source. + Under **IMDb**, movies and TV keep TMDB's order and IMDb only fills the number (backfilled by the refresh from the shared `OmdbCallBudget`), since partial coverage would float unrated titles up. + `app_setting.explore_use_tmdb` forces TMDB's vote and skips the backfill. +- **A card links to the title's provider page in a new tab** (`ProviderUrl`/`ProviderName`), following the displayed source where possible. + URLs are **stored per source** (`web_urls`, merged key by key), since IGDB keys pages by slug; `ProviderWebLinks` holds only id-derived ones. + The IMDb link is stored even when OMDb was never called, and `FindMissingRatingOrLinkAsync` picks up an entry with a rating but no link. +- **Refresh safety:** one `$set` per rating key (never replacing the map), pruning only after a complete pass, and staleness read from the ***oldest*** `refreshed_at` in a ranking. +- The refresh rides the reference sync's tick and lease on its own 7-day window, and `sync-now?exploreOnly=true` rebuilds only the rankings (`ReferenceSyncStage.RefreshingExplore`). +- **Gotcha:** a plain average ranks a single-vote title above every classic, so IGDB uses a vote floor (`MinUserRatingCount`/`MinCriticRatingCount`) as its only filter, and RAWG constrained `metacritic={MinMetacritic},100` server-side. + Never filter client-side: the paging loop stops on an empty page. +- Explore deliberately does not restrict IGDB to main games: a well-reviewed DLC or remaster is a legitimate suggestion. +- **The "already have it" exclusion needs both halves**: by provider id (`FindLinkedReferenceIdsAsync` to `FindExternalIdsAsync`, a projected read) and by title (`FindDistinctTitlesAsync` with `NormalizeLoose`, + since the linking and discovery providers spell titles differently). + Both are only as good as adoption, and `ExploreExclusionQueries` implements them once over `IExploreSourceRepository`. +- `explore_dismissal` is keyed `{owner_id, item_type, external_source, external_id}` on the **discovery** provider's id (`ExploreRankings.DiscoverySource`), since TMDB and RAWG ids are both plain integers. +- **Adding goes through `POST /api/explore/{type}/add/{externalId}`**: it creates the item then awaits `Resolve*Async` with the exact provider id, and enforces the free-tier quota via `FreeTierQuota.CheckAsync`. + The controller is plain `[Authorize]` with video games member-gated per request (`RequireAccessTo`), and the page hides that tab behind ``. +- **Paging is a rank cursor (`?after=`), never skip/limit**, since per-caller exclusions apply after the ranked read. + `ExploreSuggestionPageDto` carries `NextCursor` (null when exhausted) and `CataloguePending` ("not built yet" vs "seen everything"), and the cursor advances over every entry examined, so a short page can still have more. +- `ExplorePage.razor` keeps the tab in `?tab=`, one `TabState` per tab, and appends below the current cards (`AppendNextPageAsync`, shared by "Load more" and the top-up after an add or dismiss, both through `ActAsync`). + +## Blazor app + +`InventoryPageBase` centralizes list, paging, search and filter state and calls `InventoryApiClientBase`. +A concrete page supplies only its `Api` and `CloneItem`, plus an optional `ExtraQuery` override for its own filters. +Detail pages have two bases of the same kind, each concrete page keeping its own `[PersistentState]` properties since persisted state is keyed by the declaring component: + +- `ReferenceLinkedDetailPageBase` for the five reference-linked types: loading, the pending-link watch, `SaveAsync`, and `ReferenceMatchControls` for the check, unlink and toast. +- `JournalDetailPageBase` for Car, House and Health: owner or shared loading, year tabs, and `JournalEntryModal` with its unsaved-changes check. + +Other pages (Watch Next, Import) build on the shared `kt-*` classes in `app.css`, with their API clients in their own feature folder. + +**List state lives in the URL query string** (`?search=&page=&sort=` plus lowercase per-filter params), read via `[SupplyParameterFromQuery]`. +Search, filter and pagination navigate (`ApplyQueryChanges`/`ToggleFilter`/`SetFilter`) and the reload happens once in `OnParametersSetAsync`, so browser-back restores the exact list position. +A new filter is a `[SupplyParameterFromQuery]` property, an `ExtraQuery` entry (API key, `IsFavorite`) and a button calling `ToggleFilter`/`SetFilter` with the URL param (`favorite`), never a mutate-then-`LoadAsync` handler. + +**List ordering is deterministic everywhere.** +`MongoDbRepositoryBase.FindAllAsync` sorts every page, defaulting to `_id` descending with `_id` as tie-break under every other key, since an unsorted skip/limit page can duplicate or drop items. +`PagedRequest.Sort` carries a `ListSort` key (`title`, `rating`), and a repository opts in by overriding `SortTitleField`/`SortRatingField` with an **expression** (`Car.Name` stores as `commercial_name`). +The title sort attaches a per-query `Collation` ("en", strength 2). + +- **Gotcha:** MongoDB rejects a collation with a `$text` filter, safe only because every `GetFilter` searches with regex `Contains`, so a `$text` repository must gate the collation. +- `InventoryList`'s search box keeps a local copy of the text so a racing re-render can't revert characters, and adopts an external `Search` only when it didn't originate there (`OnParametersSet`). +- **`SuggestInput`'s menu survives the blur its own click causes** (`@onmousedown:preventDefault` on item and menu, plus `_menuMouseDown`): + Blazor Server runs the `focusout` handler to completion before the click is dispatched, which no `Task.Delay` fixes. +- **Typing highlights a match immediately** (`DefaultActiveIndex`, the ARIA combobox automatic selection), so Enter completes; an empty field highlights nothing. +- **Enter takes the highlight, Tab only one reached with the arrow keys**, since completing on Tab turns "Dr Kim" into "Dr Kimura"; Escape drops the highlight, and there is no `preventDefault` on keydown. + +**Gotcha: a `string`-typed component `[Parameter]` needs the `@` prefix.** +`Title="_movie.Title"` binds the literal text and still compiles, since Razor infers C# only when the parameter type can't accept a string (`Year="_movie.Year"` on `int?` works), so it is always `Title="@_movie.Title"`. + +**A page never renders after the user navigated away** (`Home.razor`'s `ShouldRender`): a page still loading when a link is clicked otherwise paints itself back over the destination. +Any page whose initialisation can outlive a click needs the same guard. + +**A detail page's pending-link watch never reads over the user** (`PendingReferenceLink`, driven by `ReferenceLinkedDetailPageBase`). +It replaces the page's model only when the fresh read is linked, since a replaced model swaps the objects a pending action holds (a copy awaiting its removal confirmation then removes nothing and is saved back). +It stops at the first save (`MarkEdited`, called before the PUT), and discards an answer when a save landed while the read was in flight. + +**Scaling is designed in, never assumed**, since the app may sit behind a Cloudflare tunnel with no sticky sessions. +`DataProtection:MongoDb:*` persists the key ring (`DataProtection/MongoDbXmlRepository`) so cookies decrypt on every replica, the only reason `BlazorApp.csproj` references `MongoDB.Driver`. +`Features:IsWebSocketsOnlyEnabled` (default `true`) pins a circuit to its pod, and is set `false` only behind a proxy without WebSockets, single-replica. + +### Missing pages and missing items: 404, never the error page + +`Components/Pages/NotFound.razor` is reached by `UseStatusCodePagesWithReExecute("/not-found")` on a full page load, by the Router's `NotFoundPage` in-circuit, and by `NavigationManager.NotFound()`. +It carries `[ExcludeFromInteractiveRouting]` so it renders statically with the `HttpContext` available and opens no circuit, reads the original status from `IStatusCodeReExecuteFeature` (relabelling below 400 as 404), +and has no `[Authorize]`, which would reveal the page exists. + +A missing *item* is a 404 at every layer: + +- `InventoryApiClientBase.GetOneAsync` returns null for a 404 only, and throws for everything else. +- `MongoDbRepositoryBase` treats an invalid ObjectId as naming no document, instead of a `FormatException` 500 (`MalformedIdResourceTest`). +- A detail page fetches its parent **before** its children and returns early when the parent is null. + +### Theme + +Dark-only: `App.razor` sets `data-bs-theme="dark"` statically on ``, server-rendered, so there's no flash. +It is never set client-side, since enhanced navigation diffs the whole document and strips it. +System-ui fonts only, no webfonts. + +`wwwroot/Keeptrack.BlazorApp.lib.module.js` is autoloaded by name (never a manual ` +
diff --git a/src/BlazorApp/Components/Layout/ReconnectModal.razor.js b/src/BlazorApp/Components/Layout/ReconnectModal.razor.js index a44de78d..fc57dd02 100644 --- a/src/BlazorApp/Components/Layout/ReconnectModal.razor.js +++ b/src/BlazorApp/Components/Layout/ReconnectModal.razor.js @@ -41,6 +41,7 @@ async function retry() { } } catch (err) { // We got an exception, server is currently unavailable + console.error("Blazor reconnect failed:", err); document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); } } diff --git a/src/BlazorApp/Components/Pages/Error.razor b/src/BlazorApp/Components/Pages/Error.razor index 0cf80985..e0b1a9e4 100644 --- a/src/BlazorApp/Components/Pages/Error.razor +++ b/src/BlazorApp/Components/Pages/Error.razor @@ -1,4 +1,4 @@ -@page "/error" +@page "/error" @using System.Diagnostics Error diff --git a/src/BlazorApp/Components/Pages/Home.razor b/src/BlazorApp/Components/Pages/Home.razor index f451ec98..9e8526c5 100644 --- a/src/BlazorApp/Components/Pages/Home.razor +++ b/src/BlazorApp/Components/Pages/Home.razor @@ -1,77 +1,81 @@ @page "/" @inject StatsApiClient StatsApi +@inject NavigationManager Navigation Keeptrack -
- - -
Your personal collection
- @if (!_loaded) - { - @if (_loading) +@if (IsStillTheCurrentPage) +{ +
+ + +
Your personal collection
+ @if (!_loaded) { -
-
- Loading… + @if (_loading) + { +
+
+ Loading… +
+ } + } + else if (Stats is not null && TotalItems > 0) + { +

Welcome back —
@TotalItems.ToString("N0") things remembered.

+

+ Good work building this up@(Stats.EpisodesWatched > 0 ? $" — including {Stats.EpisodesWatched:N0} episodes watched across your shows" : ""). + Every entry below is one more thing you'll never lose. +

+ } - } - else if (Stats is not null && TotalItems > 0) - { -

Welcome back —
@TotalItems.ToString("N0") things remembered.

-

- Good work building this up@(Stats.EpisodesWatched > 0 ? $" — including {Stats.EpisodesWatched:N0} episodes watched across your shows" : ""). - Every entry below is one more thing you'll never lose. -

- - } - else - { + else + { +

Everything you do,
remembered.

+

+ Your collection is waiting for its first entry - log a film watched, a book read, an album on repeat. + Rate it, note it, never lose it. +

+ + } + + +
Your personal collection

Everything you do,
remembered.

- Your collection is waiting for its first entry - log a film watched, a book read, an album on repeat. - Rate it, note it, never lose it. + Keeptrack is your quiet space to log the things that matter - films watched, books read, places visited, habits tracked. + Rate them, note them, never lose them.

- - } - - -
Your personal collection
-

Everything you do,
remembered.

-

- Keeptrack is your quiet space to log the things that matter - films watched, books read, places visited, habits tracked. - Rate them, note them, never lose them. -

-
- ◼ Movies - ▭ TV Shows - ▬ Books - ▲ Refueling - + more -
+
+ Movies + TV shows + Books + Refuelling + + more +
- -
- -
+ + +
+
+} @code { [CascadingParameter] private Task? AuthenticationState { get; set; } @@ -80,7 +84,7 @@ // prerender pass are carried over to the interactive circuit, so the first interactive render reuses // them instead of resetting to null and re-fetching. CollectionStatsDto is a handful of counts, well // within the Blazor hub's 32 KB message-size limit, so this follows the detail pages' [PersistentState] - // pattern rather than WatchNext/Wishlist's re-fetch-on-circuit-start one (see docs/prerender-flash-fix.md). + // pattern rather than WatchNext/Wishlist's re-fetch-on-circuit-start one (see docs/archived/prerender-flash-fix.md). [PersistentState] public CollectionStatsDto? Stats { get; set; } @@ -98,6 +102,32 @@ ? 0 : Stats.Movies + Stats.TvShows + Stats.Books + Stats.Albums + Stats.VideoGames + Stats.Playlists + Stats.Cars + Stats.Houses + Stats.Collectibles + Stats.Gear; + /// + /// Never paint over a page the user has already navigated to. + /// + /// Blazor renders a component automatically when its asynchronous initialisation completes, and this page's initialisation is a call to /api/stats - eleven counts, so under load it is measured in seconds rather than milliseconds. + /// With enhanced navigation the sidebar link the user clicks meanwhile swaps the DOM to the destination while this component is still mounted and still loading, so that completion render puts the dashboard back over whatever they went to, and it stays. + /// + /// + /// Measured from a failing run's trace, and it is a user-facing bug rather than a test artifact: the click landed 150ms after this page loaded, /health rendered at 0.79s, and at 1.02s the dashboard was back - with the URL still /health and the sidebar still showing Health as the active item. + /// Another run put it back five seconds after the navigation. + /// Anyone clicking a nav link while this page is still loading sees the same thing. + /// + /// + /// This guard alone isn't enough: Blazor never calls ShouldRender for a component's first render, and the static-to-interactive handoff on circuit connect counts as one. + /// A second trace caught it: the circuit connected 20ms before a sidebar click, so this page's unconditional first interactive paint landed after enhanced navigation had already swapped the DOM to /explore, stomping over it with ShouldRender never in the loop. + /// The markup's own @if (IsStillTheCurrentPage) wrapper is what closes that gap, since it applies to every render including the first. + /// + /// + protected override bool ShouldRender() => IsStillTheCurrentPage; + + /// + /// Whether the browser is still on this page's own route. + /// The circuit's is told about an enhanced navigation (it is what re-marks the sidebar's active item), so this is a reliable "am I still the page being shown" test rather than a guess. + /// + private bool IsStillTheCurrentPage => + Navigation.ToBaseRelativePath(Navigation.Uri).Split('?')[0].Trim('/').Length == 0; + protected override async Task OnInitializedAsync() { // Stats already holds the prerendered data once [PersistentState] restores it on the interactive diff --git a/src/BlazorApp/Components/Pages/NotFound.razor b/src/BlazorApp/Components/Pages/NotFound.razor index 091b6b8b..3576999e 100644 --- a/src/BlazorApp/Components/Pages/NotFound.razor +++ b/src/BlazorApp/Components/Pages/NotFound.razor @@ -1,5 +1,56 @@ -@page "/not-found" +@page "/not-found" +@attribute [ExcludeFromInteractiveRouting] @layout MainLayout +@using Microsoft.AspNetCore.Diagnostics -

Not Found

-

Sorry, the content you are looking for does not exist.

+@* Reached three ways, which is why this page keeps a real route of its own and renders statically: + - an unknown URL on a full page load, re-executed onto this path by UseStatusCodePagesWithReExecute + (Program.cs) - so the path has to be routable, and the original status code is still on the response; + - an unknown URL followed inside a live circuit, rendered here by the Router's NotFoundPage (Routes.razor), + where there is no HttpContext at all; + - NavigationManager.NotFound(), for a route that matched but whose target doesn't exist. + [ExcludeFromInteractiveRouting] is what makes the first case readable: App.razor's RenderModeForPage checks + for it, so this page renders statically and the cascaded HttpContext is actually there. It also means a + bogus URL - a stale bookmark, a crawler walking dead links - never opens a SignalR circuit to say "no". + Deliberately no [Authorize]: a signed-out visitor following a dead link must land here rather than be + bounced to login, which would tell them the page exists. *@ + +@_heading - Keeptrack + +
+
+
Error @_statusCode
+

@_heading

+

@_message

+ Back to home +
+ +@code { + private const int FirstErrorStatusCode = 400; + + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private int _statusCode = StatusCodes.Status404NotFound; + private string _heading = "Page not found"; + private string _message = "This page doesn't exist, or the link that brought you here is out of date."; + + protected override void OnInitialized() + { + // The re-execute leaves the original status on the response; the feature reports it explicitly and + // keeps reporting it whatever the re-executed pipeline does to the response afterwards. + // Anything below 400 means this page was opened through its own /not-found route, or rendered by the + // Router with no request behind it at all - both can only mean 404, never the 200 the response carries. + var status = HttpContext?.Features.Get()?.OriginalStatusCode + ?? HttpContext?.Response.StatusCode + ?? StatusCodes.Status404NotFound; + _statusCode = status < FirstErrorStatusCode ? StatusCodes.Status404NotFound : status; + + // The middleware re-executes onto this path for *every* 400-599 that has no body of its own, not just + // 404s, so the page must not claim "page not found" for something like a rejected antiforgery token. + if (_statusCode != StatusCodes.Status404NotFound) + { + _heading = "Something went wrong"; + _message = "The server couldn't complete that request. Trying again, or starting over from the home page, usually clears it."; + } + } +} diff --git a/src/BlazorApp/Components/Pages/NotFound.razor.css b/src/BlazorApp/Components/Pages/NotFound.razor.css new file mode 100644 index 00000000..3df49b91 --- /dev/null +++ b/src/BlazorApp/Components/Pages/NotFound.razor.css @@ -0,0 +1,21 @@ +/* Scoped rather than added to app.css: these rules exist for this one page only and would just add noise + to the shared kt-* vocabulary. The surrounding .kt-empty / .kt-empty-icon / .kt-empty p rules are the + global ones - only the pieces this page adds live here. */ + +.kt-not-found-code { + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--kt-text-subtle); + margin-bottom: 0.5rem; +} + +.kt-not-found h1 { + font-size: 1.6rem; + color: var(--kt-text); + margin: 0 0 0.75rem; +} + +.kt-not-found .btn { + margin-top: 1.75rem; +} diff --git a/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor b/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor index 7f8e58e4..b84c3c7d 100644 --- a/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor +++ b/src/BlazorApp/Components/QuickAdd/QuickAddPage.razor @@ -11,11 +11,11 @@ {
- ◼ + Movie - ▭ + TV show @* Free preview accounts only get movies and TV shows, same rule as NavMenu.razor - hiding is UX, @@ -23,27 +23,27 @@ - ▬ + Book - ♪ + Album - ◆ + Video game - ◉ + Car record - ▲ + House record - ✚ + Health record @@ -228,7 +228,7 @@ else else if (_cars.Count == 0) {
-
◉
+

No cars yet. Add one first, then come back here to log a record.

} @@ -261,7 +261,7 @@ else else if (_houses.Count == 0) {
-
▲
+

No houses yet. Add one first, then come back here to log a record.

} diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor index dc073242..2f528bc2 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor @@ -2,16 +2,20 @@ + @* No "not yet matched to a reference source" line any more: this panel is now only rendered after + an admin has clicked the title row's check-for-match button and it came back without a link, so + saying so would restate what the toast beside that button just said. It used to sit permanently + above the item's own data on every unlinked item, which for an admin is most of them. *@
-

Not yet matched to a reference source (admin only).

- - @if (Type == ReferenceItemType.Book && _bookProviders.Count > 0) + @* only the domains with more than one registered provider return anything, so this renders a + picker for books and video games and nothing at all for the rest. *@ + @if (_providers.Count > 1) {
- @foreach (var bookProvider in _bookProviders) + @foreach (var provider in _providers) { - + }
} @@ -22,7 +26,7 @@ Re-runs with the page's *current* title/year (and author/artist) field values - the wrong-candidates fix is "edit the fields above, then search again". *@ @if (_searching) @@ -81,8 +85,9 @@ [Parameter] public string? Creator { get; set; } /// - /// The book's ISBN, when known - Book-only, and only actually used by Google Books (see - /// 's own doc comment). Edited on the detail page + /// The book's ISBN, when known - Book-only. Every book provider searches by it (see + /// 's own doc comment), so switching provider is a real + /// fallback for an ISBN search rather than a silent downgrade to a title match. Edited on the detail page /// itself (BookDetail.razor's own ISBN field), not here - same convention as . /// [Parameter] public string? Isbn { get; set; } @@ -94,23 +99,20 @@ private bool _linking; private List _results = []; private string? _error; - private List _bookProviders = []; + private List _providers = []; private string? _selectedProvider; - private string ProviderName => Type switch + private string ProviderName => _providers.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? Type switch { ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", - ReferenceItemType.Book => _bookProviders.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? "Google Books", - ReferenceItemType.VideoGame => "RAWG", ReferenceItemType.Album => "Discogs", _ => "reference" }; protected override async Task OnInitializedAsync() { - if (Type != ReferenceItemType.Book) return; - _bookProviders = await Api.GetBookProvidersAsync(); - _selectedProvider = _bookProviders.FirstOrDefault()?.Key; + _providers = await Api.GetProvidersAsync(Type); + _selectedProvider = _providers.FirstOrDefault(p => p.IsDefault)?.Key ?? _providers.FirstOrDefault()?.Key; } private async Task SearchAsync() @@ -120,17 +122,14 @@ _error = null; try { - _results = await Api.SearchAsync(Type, Title, Year, Creator, Type == ReferenceItemType.Book ? _selectedProvider : null, Type == ReferenceItemType.Book ? Isbn : null); + _results = await Api.SearchAsync(Type, Title, Year, Creator, _selectedProvider, Type == ReferenceItemType.Book ? Isbn : null); } catch (Exception ex) { // an uncaught exception here would crash the whole Blazor Server circuit (not just this // component), forcing a full page reload - same reasoning as LinkAsync's own try/catch below. - // The provider's own exchange (which HTTP client, which status code) is an implementation - // detail the admin shouldn't have to parse out of a raw HttpRequestException message - - // "Search failed" plus a retry hint says the one thing that's actually actionable. _results = []; - _error = $"{ProviderName} search failed ({ex.Message}). This is usually transient - wait a moment and try again."; + _error = DescribeFailure("search", ex); } finally { @@ -150,18 +149,44 @@ Title = Title, Year = Year, ExternalId = candidate.ExternalId, - Provider = Type == ReferenceItemType.Book ? _selectedProvider : null, - Isbn = Type == ReferenceItemType.Book ? Isbn : null + Provider = _selectedProvider, + Isbn = Type == ReferenceItemType.Book ? Isbn : null, + Creator = Type == ReferenceItemType.Album ? Creator : null }); await OnLinked.InvokeAsync(); } catch (Exception ex) { - _error = ex.Message; + _error = DescribeFailure("link", ex); } finally { _linking = false; } } + + /// + /// Names the provider, says what it actually did, and points at the way out. + /// + /// This used to report ex.Message, which for any provider failure was the Blazor client's own + /// "Response status code does not indicate success: 502 (Bad Gateway)" - this API's gateway status, with + /// the provider's real one nowhere in sight. It read as a Keeptrack defect during what was actually a + /// total Google Books search outage. ApiRequestException now carries the API's own explanation. + /// + /// Whether another provider is worth trying is the genuinely useful next step, and it is only suggested + /// when both halves are actually known: that the failure was upstream (so a different upstream may well + /// work) and that this domain has somewhere else to go - books and video games do, TV/movies/albums have a + /// single provider each. Anything this component can't identify falls back to the plain retry hint rather + /// than sending an admin round a picker for a failure that may have nothing to do with the provider. + /// + private string DescribeFailure(string action, Exception ex) + { + var api = ex as ApiRequestException; + var detail = api?.Message ?? "The request could not be completed."; + var nextStep = api is { IsUpstreamProviderFailure: true } && _providers.Count > 1 + ? "Try one of the other providers above, or wait a moment and try again." + : "This is usually transient - wait a moment and try again."; + + return $"{ProviderName} {action} failed. {detail} {nextStep}"; + } } diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs index e318a5f9..14fccfca 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Keeptrack.BlazorApp.Components.Shared; using Keeptrack.WebApi.Contracts.Dto; namespace Keeptrack.BlazorApp.Components.ReferenceDataAdmin; @@ -19,24 +20,73 @@ public async Task> SearchAsync(ReferenceItemType if (!string.IsNullOrEmpty(provider)) query += $"&provider={Uri.EscapeDataString(provider)}"; if (!string.IsNullOrEmpty(isbn)) query += $"&isbn={Uri.EscapeDataString(isbn)}"; - var results = await http.GetFromJsonAsync>(query); + // Not GetFromJsonAsync: this call reaches a live third-party provider, so it is the one most likely to + // fail for a reason worth reporting, and EnsureSuccessStatusCode would discard the API's explanation + // of it (see ApiResponseExtensions). + var response = await http.GetAsync(query); + return await response.ReadJsonOrThrowAsync>() ?? []; + } + + /// + /// Every registered provider for (see + /// ReferenceDataAdminController.GetProviders). Empty for the domains with a single provider, which + /// is what tells the caller not to render a picker at all. + /// + public async Task> GetProvidersAsync(ReferenceItemType type) + { + var results = await http.GetFromJsonAsync>($"/api/reference-data/providers?type={type}"); return results ?? []; } /// - /// Every registered book provider (see ReferenceDataAdminController.GetBookProviders) - Book is - /// the one reference domain with more than one, so this is the only per-type provider list needed. + /// Every domain whose primary rating source is admin-selectable, with available/selected sources (see + /// ReferenceDataAdminController.GetRatingSources). /// - public async Task> GetBookProvidersAsync() + public async Task> GetRatingSourcesAsync() { - var results = await http.GetFromJsonAsync>("/api/reference-data/book-providers"); + var results = await http.GetFromJsonAsync>("/api/reference-data/rating-sources"); return results ?? []; } + /// + /// Stores a domain's primary rating source (does not re-propagate - call for that). + /// + public async Task SetRatingSourceAsync(ReferenceItemType domain, string source) + { + var response = await http.PutAsJsonAsync($"/api/reference-data/rating-sources/{domain}", new SetRatingSourceRequestDto { Source = source }); + response.EnsureSuccessStatusCode(); + } + + /// + /// Re-applies a domain's current primary rating source to every linked tenant item (see + /// ReferenceDataAdminController.RecomputeRatingSource). + /// + public async Task RecomputeRatingsAsync(ReferenceItemType domain) + { + var response = await http.PostAsync($"/api/reference-data/rating-sources/{domain}/recompute", null); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync() ?? new RecomputeRatingsResultDto(); + } + + /// The global Explore-feature settings. + public async Task GetExploreSettingsAsync() => + await http.GetFromJsonAsync("/api/reference-data/explore-settings") ?? new ExploreSettingsDto(); + + /// Updates the global Explore-feature settings. + public async Task SetExploreSettingsAsync(bool useTmdbRanking) + { + var response = await http.PutAsJsonAsync("/api/reference-data/explore-settings", new ExploreSettingsDto { UseTmdbRanking = useTmdbRanking }); + response.EnsureSuccessStatusCode(); + } + + /// + /// Linking re-fetches the chosen candidate's full details from the provider, so it can fail upstream for + /// exactly the same reasons the search can - and is reported the same way. + /// public async Task LinkAsync(LinkReferenceRequestDto request) { var response = await http.PostAsJsonAsync("/api/reference-data/link", request); - response.EnsureSuccessStatusCode(); + await response.EnsureSuccessOrThrowAsync(); } /// @@ -50,9 +100,21 @@ public async Task ExportAsync() } /// - /// Idempotent (upsert-by-id) re-import of a previously exported zip. + /// Re-import of a previously exported zip. Documents are matched by provider id, not by the _id + /// they were exported with, so this is idempotent and safe against a database that already holds some of + /// the same references - see ReferenceDataImportService. + /// + /// Runs in the background; poll with the returned job id for progress. + /// A real export is tens of thousands of documents, which takes far longer than this client's default + /// 100s timeout - waiting on one response reported a timeout for an import that was still running fine. + /// /// - public async Task ImportAsync(Stream zipStream, string fileName) + /// + /// Read to the end before the request is sent, so pass a stream that is already in memory. Handing + /// IBrowserFile.OpenReadStream() straight to makes the API wait on the + /// browser drip-feeding the file down the SignalR circuit mid-request. + /// + public async Task StartImportAsync(Stream zipStream, string fileName) { using var content = new MultipartFormDataContent(); using var streamContent = new StreamContent(zipStream); @@ -61,18 +123,29 @@ public async Task ImportAsync(Stream zipStream, st var response = await http.PostAsync("/api/reference-data/import", content); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync() - ?? new ReferenceDataImportResultDto { TvShowCount = 0, MovieCount = 0, PersonCount = 0, BookCount = 0, VideoGameCount = 0, AlbumCount = 0 }; + + var job = await response.Content.ReadFromJsonAsync(); + return job!.JobId; + } + + public async Task GetImportStatusAsync(Guid jobId) + { + var status = await http.GetFromJsonAsync($"/api/reference-data/import/{jobId}"); + return status ?? new ReferenceDataImportJobStatusDto { Stage = ReferenceDataImportStage.Failed, ErrorMessage = "Lost track of the import job." }; } /// - /// Starts an immediate re-check of every reference document against its provider (see - /// ReferenceDataAdminController.SyncNow), instead of waiting for the periodic background sync. + /// Runs the reference sync now instead of waiting for the periodic background one (see + /// ReferenceDataAdminController.SyncNow). With it re-checks every + /// reference document and rebuilds every Explore ranking; without it, it takes exactly what the + /// background tick would have taken - only what is past its staleness window. + /// With it rebuilds the Explore rankings alone, skipping the five reference + /// domains - much the cheaper half, and the only one that matters after an Explore change. /// Runs in the background; poll with the returned job id for progress. /// - public async Task StartSyncAsync() + public async Task StartSyncAsync(bool force, bool exploreOnly) { - var response = await http.PostAsync("/api/reference-data/sync-now", null); + var response = await http.PostAsync($"/api/reference-data/sync-now?force={force}&exploreOnly={exploreOnly}", null); response.EnsureSuccessStatusCode(); var job = await response.Content.ReadFromJsonAsync(); @@ -85,6 +158,50 @@ public async Task GetSyncStatusAsync(Guid jobId) return status ?? new ReferenceSyncJobStatusDto { Stage = ReferenceSyncStage.Failed, ErrorMessage = "Lost track of the sync job." }; } + /// + /// How far the video game reference documents have caught up with the domain's current default provider, + /// plus any that look like duplicates of one another (see + /// ReferenceDataAdminController.GetProviderReconciliation). Pure database reads - no provider call, + /// so it is safe to load with the page. + /// + public async Task GetProviderReconciliationAsync() => + await http.GetFromJsonAsync("/api/reference-data/provider-reconciliation") + ?? new ProviderReconciliationDto { Provider = "", ProviderDisplayName = "" }; + + /// + /// The default provider's candidates for one stuck reference. Reaches a live provider, so it is read + /// through like the admin search is. + /// + /// + /// What the admin typed instead of the reference's own title, or a pasted provider page URL. Null asks + /// with the stored title, which is what opening a row does. + /// + public async Task> GetAdoptionCandidatesAsync(string referenceId, string? query = null) + { + var url = $"/api/reference-data/provider-reconciliation/{referenceId}/candidates" + + (string.IsNullOrWhiteSpace(query) ? "" : $"?query={Uri.EscapeDataString(query)}"); + var response = await http.GetAsync(url); + return await response.ReadJsonOrThrowAsync>() ?? []; + } + + /// Attaches an admin-picked provider id to an existing reference and refreshes it through that provider. + public async Task AdoptProviderIdAsync(string referenceId, string externalId) + { + var response = await http.PostAsJsonAsync( + $"/api/reference-data/provider-reconciliation/{referenceId}/adopt", new AdoptProviderIdRequestDto { ExternalId = externalId }); + await response.EnsureSuccessOrThrowAsync(); + } + + /// Folds one duplicate reference document into another, re-pointing every tenant item that linked it. + public async Task MergeReferencesAsync(string keepReferenceId, string mergeReferenceId) + { + var response = await http.PostAsJsonAsync( + "/api/reference-data/provider-reconciliation/merge", + new MergeReferencesRequestDto { KeepReferenceId = keepReferenceId, MergeReferenceId = mergeReferenceId }); + return await response.ReadJsonOrThrowAsync() + ?? new MergeReferencesResultDto { KeptReferenceId = keepReferenceId }; + } + /// /// Operational snapshot: the answering instance's configuration plus the shared reference-sync lease /// and recent background jobs (see SystemStatusController). diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index 89c070ac..db9898ae 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -31,7 +31,7 @@ @if (_unresolved.Count == 0) {
-
◈
+

Nothing waiting on a manual match.

} @@ -63,11 +63,13 @@ @if (_type == ReferenceItemType.Book) { - @foreach (var bookProvider in _bookProviders) - { - - } + } + @* selection only, never a search trigger - one control starts a search. Rendered + for any domain with more than one registered provider (books, video games). *@ + @foreach (var provider in _providers) + { + }
@@ -133,11 +135,25 @@

Reference data (episode guides, genres, posters, cast) drifts out of date after TMDB updates a show - or movie - a background sync re-checks documents older than 3 days every 24h. Use this to force an - immediate re-check of everything instead of waiting. + or movie - a background sync runs every 24h, re-checking documents older than 3 days and rebuilding + Explore rankings older than 7 days. Use this to run that same pass now instead of waiting: it only + touches what is past those windows, so it reports nothing checked when everything is already fresh. + Tick Force to ignore the windows and re-check every document and ranking - the + thorough option, and the expensive one in provider calls. + Tick Explore only to rebuild the discovery rankings without touching reference data: + a few listing pages instead of up to 500 documents per domain, so it is the cheap way to pick up an + Explore change - and the one to combine with Force.

-
+
+
+ + +
+
+ + +
@if (_syncing) { @@ -149,11 +165,27 @@ @if (_syncResult is not null) {
- TV shows: @_syncResult.TvShowsChecked checked, @_syncResult.TvShowsUpdated updated. - Movies: @_syncResult.MoviesChecked checked, @_syncResult.MoviesUpdated updated. - Books: @_syncResult.BooksChecked checked, @_syncResult.BooksUpdated updated. - Video games: @_syncResult.VideoGamesChecked checked, @_syncResult.VideoGamesUpdated updated. - Albums: @_syncResult.AlbumsChecked checked, @_syncResult.AlbumsUpdated updated. + @(_syncResultForced + ? "Full re-check, ignoring every freshness window." + : "Incremental pass: only what is past its freshness window, exactly like the background sync.") +
+ @* An Explore-only run left every reference count at zero. Printing them anyway would read as + "the pass looked and found nothing", which is the opposite of "the pass never looked". *@ + @if (!_syncResultExploreOnly) + { + + TV shows: @_syncResult.TvShowsChecked checked, @_syncResult.TvShowsUpdated updated. + Movies: @_syncResult.MoviesChecked checked, @_syncResult.MoviesUpdated updated. + Books: @_syncResult.BooksChecked checked, @_syncResult.BooksUpdated updated. + Video games: @_syncResult.VideoGamesChecked checked, @_syncResult.VideoGamesUpdated updated. + Albums: @_syncResult.AlbumsChecked checked, @_syncResult.AlbumsUpdated updated. + Finished shows reopened as current: @_syncResult.FinishedShowsReopened. +
+
+ } + Explore: @_syncResult.ExploreRankingsRefreshed ranking(s) rebuilt, + @_syncResult.ExploreEntriesRefreshed entries written, + @_syncResult.ExploreImdbRatingsBackfilled IMDb rating(s) backfilled.
} @if (_syncError is not null) @@ -162,25 +194,221 @@ }
+@* Provider reconciliation. Sits right under the sync card because it is where a sync's "couldn't adopt" + outcomes land: everything here is a reference document the automatic rule refused to guess about. *@ +@if (_reconciliation is not null && (_reconciliation.Gaps.Count > 0 || _reconciliation.Duplicates.Count > 0)) +{ +
+
+

Provider reconciliation

+ +
+

+ Video game references are refreshed through the current default provider only, so one still living + in a previous provider's id space is stuck: it stops being refreshed, and Explore keeps + suggesting it even though you already have it - discovery recognises what you own by + @(_reconciliation.ProviderDisplayName)'s id. The nightly sync adopts an id automatically whenever + there is exactly one match; what is left below is what it refused to guess about. +

+ + @if (_reconciliation.Gaps.Count > 0) + { +

+ @_reconciliation.Gaps.Count of @_reconciliation.TotalReferences video game references carry no + @_reconciliation.ProviderDisplayName id. +

+ + + @foreach (var gap in _reconciliation.Gaps) + { + + + + + + + @if (gap.ReferenceId == _selectedGapId) + { + + + + } + } + +
@gap.Title@gap.Year@(gap.Providers.Count == 0 ? "no provider id" : string.Join(", ", gap.Providers))@(gap.LastAttemptedAt is null ? "never searched" : $"last tried {gap.LastAttemptedAt:yyyy-MM-dd}")
+ @* The provider's search has real dead ends - it can answer a title with + nothing, or with unrelated games and never the one asked for - so the row + always offers a way to ask differently, rather than only when it failed. *@ +
+ + +
+ @if (_loadingCandidates) + { + Asking @_reconciliation.ProviderDisplayName… + } + else if (_candidates.Count == 0) + { + + @_reconciliation.ProviderDisplayName has nothing under this title. Search above with its own spelling of + the name - or open its page and paste the address, which names the game exactly when no wording finds it. + + } + else + { +
+ @foreach (var candidate in _candidates) + { +
+ @if (!string.IsNullOrEmpty(candidate.ImageUrl)) + { + @candidate.Title + } +

@candidate.Title

+

@candidate.Year

+ @if (candidate.Synopsis is not null) + { +

✓ @candidate.Synopsis

+ } + +
+ } +
+ } +
+ } + + @if (_reconciliation.Duplicates.Count > 0) + { +

Duplicate references (@_reconciliation.Duplicates.Count)

+

+ Two documents describing one work - what a provider change, or a reference-data import from an + environment on a different provider, leaves behind. Each account's items point at whichever one + existed when they linked, so the work's provider ids, ratings and cover are split between them. + Keep the one you want to survive; the other's data is merged into it and every item pointing at + it is moved over. A RAWG cover always wins the merge whichever one you keep - its key art can't + be fetched again without RAWG's API, so losing it would be permanent. +

+ @foreach (var group in _reconciliation.Duplicates) + { +
+ @group.Title + @foreach (var reference in group.References) + { +
+

@reference.Title @(reference.Year is null ? "" : $"({reference.Year})")

+

ids: @(reference.ExternalIds.Count == 0 ? "none" : string.Join(", ", reference.ExternalIds.Select(pair => $"{pair.Key} {pair.Value}")))

+

ratings: @(reference.RatingSources.Count == 0 ? "none" : string.Join(", ", reference.RatingSources))

+ +
+ } +
+ } + } + + @if (_reconciliationMessage is not null) + { +
@_reconciliationMessage
+ } + @if (_reconciliationError is not null) + { +
@_reconciliationError
+ } +
+} + +@if (_ratingSources.Count > 0) +{ +
+

+ Which provider score is shown as the rating pill and drives the "Ref ★" sort. Changing the source + only affects new links and syncs until you recompute - use Recompute to re-apply the selected + source to every already-linked item at once. IMDb (movies/TV) is out of 10 like TMDB, so switching + rarely changes the pill, but a few titles have no IMDb rating and will blank/sort last. +
+ The video game list follows whichever provider is configured as that domain's default, so it shows + IGDB's two scores today and would show RAWG's own plus Metacritic again if the deployment went back + to RAWG. A choice you made under another provider is kept, not erased - it simply falls back to the + default while that provider isn't in charge, and is honoured again as soon as it is. Values already + stored (a RAWG-era Metacritic score, say) keep showing on detail pages either way. +

+ @foreach (var option in _ratingSources) + { +
+ @DomainLabel(option.Domain) + + + @if (_recomputedDomain == option.Domain && _recomputeResult is not null) + { + @_recomputeResult.ReferencesChecked references checked, @_recomputeResult.ItemsUpdated items updated. + } +
+ } +
+
+ + +
+ @if (_ratingSourceError is not null) + { +
@_ratingSourceError
+ } +
+} +

- Export the whole reference dataset (TV shows, movies, cast) as a zip, or re-import a previously - exported one - re-importing is safe to run more than once, since every document is upserted by id. + Export the whole reference dataset (TV shows, movies, books, video games, albums, cast) as a zip, or + re-import a previously exported one. Documents are matched by their provider id (TMDB, IGDB, Google + Books...), so re-importing into a database that already knows a title updates that title in place + rather than adding a second copy of it - safe to run as often as you like, and safe against a + database that earned some of its reference data on its own.

- @if (_importing) - { -
- }
+ @if (_importing) + { +
+
+
+

@ImportStageLabel(_importStage)

+ } @if (_importResult is not null) {
- Imported @_importResult.TvShowCount TV show(s), @_importResult.MovieCount movie(s), @_importResult.PersonCount person(s), - @_importResult.BookCount book(s), @_importResult.VideoGameCount video game(s), @_importResult.AlbumCount album(s). + Imported @Summarize("TV show", _importResult.TvShows), @Summarize("movie", _importResult.Movies), + @Summarize("person", _importResult.People), @Summarize("book", _importResult.Books), + @Summarize("video game", _importResult.VideoGames), @Summarize("album", _importResult.Albums).
+ @if (_importResult.SkippedExternalIds.Count > 0) + { +
+ @_importResult.SkippedExternalIds.Count provider id(s) were left out because another document here + already claims them - this database holds two reference documents for the same work, which only you + can merge: @string.Join(", ", _importResult.SkippedExternalIds) +
+ } + @if (_importResult.PossibleDuplicates.Count > 0) + { +
+ @_importResult.PossibleDuplicates.Count work(s) were imported as a second document because this + database already had them under a different provider's id - matching is by provider id, and an + import never fuses two records on title alone. Merge them in Provider reconciliation above: + @string.Join(", ", _importResult.PossibleDuplicates) +
+ } } @if (_importExportError is not null) { @@ -191,7 +419,7 @@

System

- +

Configuration is reported by whichever instance answered this request - with several replicas, @@ -229,6 +457,10 @@ Book provider @_systemStatus.BookProvider + + Video game provider + @_systemStatus.VideoGameProvider + Sync lease @@ -304,14 +536,14 @@ private int? _queryYear; private string? _creator; private string? _isbn; - private List _bookProviders = []; + @* the registered providers for the selected domain, reloaded on every domain switch: empty for the + single-provider ones, which is what hides the picker without a per-type condition in the markup. *@ + private List _providers = []; private string? _selectedProvider; - private string ProviderName => _type switch + private string ProviderName => _providers.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? _type switch { ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", - ReferenceItemType.Book => _bookProviders.FirstOrDefault(p => p.Key == _selectedProvider)?.DisplayName ?? "Google Books", - ReferenceItemType.VideoGame => "RAWG", ReferenceItemType.Album => "Discogs", _ => "reference" }; @@ -320,24 +552,40 @@ private bool _importing; private ReferenceDataImportResultDto? _importResult; private string? _importExportError; + private ReferenceDataImportStage _importStage; private static readonly TimeSpan SyncPollInterval = TimeSpan.FromMilliseconds(600); + private static readonly TimeSpan ImportPollInterval = TimeSpan.FromMilliseconds(600); private bool _syncing; + private bool _forceSync; + private bool _exploreOnlySync; private ReferenceSyncResultDto? _syncResult; private string? _syncError; private ReferenceSyncStage _syncStage; + ///

+ /// Which mode the *finished* run was started in - read while the checkboxes are free to be toggled again, + /// so the result alert always describes the run it belongs to rather than the current checkbox state. + /// + private bool _syncResultForced; + + private bool _syncResultExploreOnly; + private async Task SyncNowAsync() { + var force = _forceSync; + var exploreOnly = _exploreOnlySync; _syncing = true; _syncError = null; _syncResult = null; - _syncStage = ReferenceSyncStage.SyncingTvShows; + // an Explore-only run never enters the reference stages, so starting the bar on "TV shows" would show + // a phase it will never reach + _syncStage = exploreOnly ? ReferenceSyncStage.RefreshingExplore : ReferenceSyncStage.SyncingTvShows; try { - var jobId = await Api.StartSyncAsync(); + var jobId = await Api.StartSyncAsync(force, exploreOnly); while (true) { @@ -347,6 +595,8 @@ if (status.Stage == ReferenceSyncStage.Completed) { _syncResult = status.Result; + _syncResultForced = force; + _syncResultExploreOnly = exploreOnly; break; } @@ -376,7 +626,8 @@ ReferenceSyncStage.SyncingMovies => 35, ReferenceSyncStage.SyncingBooks => 55, ReferenceSyncStage.SyncingVideoGames => 75, - ReferenceSyncStage.SyncingAlbums => 90, + ReferenceSyncStage.SyncingAlbums => 85, + ReferenceSyncStage.RefreshingExplore => 95, ReferenceSyncStage.Completed => 100, _ => 0 }; @@ -388,10 +639,14 @@ ReferenceSyncStage.SyncingBooks => "Checking books…", ReferenceSyncStage.SyncingVideoGames => "Checking video games…", ReferenceSyncStage.SyncingAlbums => "Checking albums…", + ReferenceSyncStage.RefreshingExplore => "Rebuilding Explore rankings…", ReferenceSyncStage.Completed => "Done", _ => "" }; + private static string Summarize(string noun, ReferenceDataImportCountsDto counts) => + $"{counts.Created} new + {counts.Updated} updated {noun}(s)"; + private async Task ExportAsync() { _exporting = true; @@ -419,10 +674,41 @@ _importing = true; _importExportError = null; _importResult = null; + _importStage = ReferenceDataImportStage.Parsing; try { - await using var stream = e.File.OpenReadStream(MaxImportFileSize); - _importResult = await Api.ImportAsync(stream, e.File.Name); + // buffered here rather than handed to the API client as a live stream: an IBrowserFile stream is + // pulled down the SignalR circuit in small chunks, and doing that *during* the POST would count the + // whole upload against the request's own timeout. + using var buffer = new MemoryStream(); + await using (var stream = e.File.OpenReadStream(MaxImportFileSize)) + { + await stream.CopyToAsync(buffer); + } + + buffer.Position = 0; + var jobId = await Api.StartImportAsync(buffer, e.File.Name); + + while (true) + { + var status = await Api.GetImportStatusAsync(jobId); + _importStage = status.Stage; + + if (status.Stage == ReferenceDataImportStage.Completed) + { + _importResult = status.Result; + break; + } + + if (status.Stage == ReferenceDataImportStage.Failed) + { + _importExportError = status.ErrorMessage ?? "The import failed."; + break; + } + + StateHasChanged(); + await Task.Delay(ImportPollInterval); + } } catch (Exception ex) { @@ -434,18 +720,242 @@ } } + private static int ImportProgressPercent(ReferenceDataImportStage stage) => stage switch + { + ReferenceDataImportStage.ImportingPeople => 15, + ReferenceDataImportStage.ImportingTvShows => 55, + ReferenceDataImportStage.ImportingMovies => 70, + ReferenceDataImportStage.ImportingBooks => 80, + ReferenceDataImportStage.ImportingVideoGames => 88, + ReferenceDataImportStage.ImportingAlbums => 95, + ReferenceDataImportStage.Completed => 100, + _ => 5 + }; + + private static string ImportStageLabel(ReferenceDataImportStage stage) => stage switch + { + ReferenceDataImportStage.Parsing => "Reading the export…", + // people are the bulk of a real export, so this stage holds for most of the run - say so rather than + // leaving a bar that looks stuck + ReferenceDataImportStage.ImportingPeople => "Importing people (the biggest collection - this is most of the wait)…", + ReferenceDataImportStage.ImportingTvShows => "Importing TV shows…", + ReferenceDataImportStage.ImportingMovies => "Importing movies…", + ReferenceDataImportStage.ImportingBooks => "Importing books…", + ReferenceDataImportStage.ImportingVideoGames => "Importing video games…", + ReferenceDataImportStage.ImportingAlbums => "Importing albums…", + ReferenceDataImportStage.Completed => "Done", + _ => "" + }; + private bool _loadingSystemStatus; private SystemStatusDto? _systemStatus; private string? _systemStatusError; + private List _ratingSources = []; + private bool _recomputing; + private RecomputeRatingsResultDto? _recomputeResult; + private ReferenceItemType? _recomputedDomain; + private string? _ratingSourceError; + private bool _exploreUseTmdb; + protected override async Task OnInitializedAsync() { - _bookProviders = await Api.GetBookProvidersAsync(); - _selectedProvider = _bookProviders.FirstOrDefault()?.Key; + await LoadProvidersAsync(); + _ratingSources = await Api.GetRatingSourcesAsync(); + _exploreUseTmdb = (await Api.GetExploreSettingsAsync()).UseTmdbRanking; await LoadUnresolvedAsync(); + await LoadReconciliationAsync(); await LoadSystemStatusAsync(); } + private ProviderReconciliationDto? _reconciliation; + private bool _loadingReconciliation; + private string? _selectedGapId; + private List _candidates = []; + private string _candidateQuery = string.Empty; + private bool _loadingCandidates; + private bool _adopting; + private bool _merging; + private string? _reconciliationMessage; + private string? _reconciliationError; + + private async Task LoadReconciliationAsync() + { + _loadingReconciliation = true; + try + { + _reconciliation = await Api.GetProviderReconciliationAsync(); + _reconciliationError = null; + } + catch (Exception ex) + { + _reconciliationError = ex.Message; + } + finally + { + _loadingReconciliation = false; + } + } + + @* Candidates cost a live provider call, so they are fetched when a row is opened - never for the whole + queue, which routinely runs to three figures. *@ + private async Task SelectGapAsync(ProviderGapDto gap) + { + if (_selectedGapId == gap.ReferenceId) + { + _selectedGapId = null; + return; + } + + _selectedGapId = gap.ReferenceId; + _candidateQuery = string.Empty; + await LoadCandidatesAsync(gap, null); + } + + @* The manual half: a provider's search has genuine dead ends - IGDB returns three LEGO expansion packs + for every spelling of "Marvel's Avengers" and never the game - so an admin can ask with their own text, + or paste the game's page URL when no text works at all. *@ + private async Task SearchCandidatesAsync(ProviderGapDto gap) => await LoadCandidatesAsync(gap, _candidateQuery); + + private async Task LoadCandidatesAsync(ProviderGapDto gap, string? query) + { + _candidates = []; + _loadingCandidates = true; + _reconciliationError = null; + try + { + _candidates = await Api.GetAdoptionCandidatesAsync(gap.ReferenceId, query); + } + catch (Exception ex) + { + _reconciliationError = ex.Message; + } + finally + { + _loadingCandidates = false; + } + } + + private async Task AdoptAsync(ProviderGapDto gap, ReferenceSearchResultDto candidate) + { + _adopting = true; + _reconciliationError = null; + try + { + await Api.AdoptProviderIdAsync(gap.ReferenceId, candidate.ExternalId); + _reconciliationMessage = $"\"{gap.Title}\" now carries {_reconciliation?.ProviderDisplayName} id {candidate.ExternalId}."; + _selectedGapId = null; + await LoadReconciliationAsync(); + } + catch (Exception ex) + { + _reconciliationError = ex.Message; + } + finally + { + _adopting = false; + } + } + + private async Task MergeAsync(DuplicateReferenceGroupDto group, DuplicateReferenceDto keep) + { + _merging = true; + _reconciliationError = null; + try + { + var repointed = 0L; + // a group can hold more than two documents; every one that isn't the keeper is folded in, in turn, + // so the admin's one click means "this is the work's document" rather than "merge this pair" + foreach (var absorbed in group.References.Where(r => r.ReferenceId != keep.ReferenceId)) + { + repointed += (await Api.MergeReferencesAsync(keep.ReferenceId, absorbed.ReferenceId)).ItemsRepointed; + } + + _reconciliationMessage = $"Merged into \"{keep.Title}\"; {repointed} item(s) re-pointed."; + await LoadReconciliationAsync(); + } + catch (Exception ex) + { + _reconciliationError = ex.Message; + } + finally + { + _merging = false; + } + } + + private async Task OnExploreUseTmdbChangedAsync(ChangeEventArgs e) + { + _exploreUseTmdb = e.Value as bool? ?? false; + try + { + await Api.SetExploreSettingsAsync(_exploreUseTmdb); + _ratingSourceError = null; + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + } + + private static string SourceLabel(string source) => source switch + { + "tmdb" => "TMDB", + "imdb" => "IMDb", + "rawg" => "RAWG", + "metacritic" => "Metacritic", + "igdb" => "IGDB", + "igdbcritic" => "IGDB Critics", + _ => source + }; + + private static string DomainLabel(ReferenceItemType domain) => domain switch + { + ReferenceItemType.TvShow => "TV shows", + ReferenceItemType.Movie => "Movies", + ReferenceItemType.Book => "Books", + ReferenceItemType.VideoGame => "Video games", + ReferenceItemType.Album => "Albums", + _ => domain.ToString() + }; + + private async Task SelectRatingSourceAsync(RatingSourceOptionDto option, string source) + { + if (option.SelectedSource == source) return; + + _ratingSourceError = null; + _recomputeResult = null; + try + { + await Api.SetRatingSourceAsync(option.Domain, source); + option.SelectedSource = source; + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + } + + private async Task RecomputeRatingsAsync(ReferenceItemType domain) + { + _recomputing = true; + _ratingSourceError = null; + _recomputeResult = null; + _recomputedDomain = domain; + try + { + _recomputeResult = await Api.RecomputeRatingsAsync(domain); + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + finally + { + _recomputing = false; + } + } + private async Task LoadSystemStatusAsync() { _loadingSystemStatus = true; @@ -478,9 +988,20 @@ private async Task SelectTypeAsync(ReferenceItemType type) { _type = type; + await LoadProvidersAsync(); await LoadUnresolvedAsync(); } + /// + /// Reloads the provider list for the selected domain, defaulting the selection to the deployment default + /// (registration order is display order, not necessarily the configured default). + /// + private async Task LoadProvidersAsync() + { + _providers = await Api.GetProvidersAsync(_type); + _selectedProvider = _providers.FirstOrDefault(p => p.IsDefault)?.Key ?? _providers.FirstOrDefault()?.Key; + } + private async Task SelectItemAsync(UnresolvedReferenceDto item) { if (item == _selected) @@ -511,7 +1032,7 @@ _error = null; try { - _searchResults = await Api.SearchAsync(_type, _queryTitle ?? _selected.Title, _queryYear, _creator, _type == ReferenceItemType.Book ? _selectedProvider : null, + _searchResults = await Api.SearchAsync(_type, _queryTitle ?? _selected.Title, _queryYear, _creator, _selectedProvider, _type == ReferenceItemType.Book ? _isbn : null); } catch (Exception ex) @@ -546,8 +1067,9 @@ Title = _selected.Title, Year = _selected.Year, ExternalId = candidate.ExternalId, - Provider = _type == ReferenceItemType.Book ? _selectedProvider : null, - Isbn = _type == ReferenceItemType.Book ? _isbn : null + Provider = _selectedProvider, + Isbn = _type == ReferenceItemType.Book ? _isbn : null, + Creator = _type == ReferenceItemType.Album ? _selected.Creator : null }); await LoadUnresolvedAsync(); } diff --git a/src/BlazorApp/Components/Routes.razor b/src/BlazorApp/Components/Routes.razor index c1529b7e..f2207175 100644 --- a/src/BlazorApp/Components/Routes.razor +++ b/src/BlazorApp/Components/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/src/BlazorApp/Components/Shared/ApiResponseExtensions.cs b/src/BlazorApp/Components/Shared/ApiResponseExtensions.cs new file mode 100644 index 00000000..6473c3b2 --- /dev/null +++ b/src/BlazorApp/Components/Shared/ApiResponseExtensions.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text.Json.Serialization; + +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// A failed API call, carrying the message the API itself reported rather than a framework-generated one. +/// +/// +/// throws with only the status line ("Response +/// status code does not indicate success: 502 (Bad Gateway).") and discards the response body - so the +/// { error } the API deliberately writes (see ApiExceptionFilterAttribute) never reached a +/// single user, and every upstream provider outage surfaced as an unexplained 502. +/// +public sealed class ApiRequestException(string message, HttpStatusCode statusCode) : Exception(message) +{ + /// The status the API answered with - not the upstream provider's own, when there was one. + public HttpStatusCode StatusCode { get; } = statusCode; + + /// + /// True when the API reported that ITS upstream call failed rather than failing itself, which is the + /// distinction that decides whether retrying the same thing is worth the user's time or whether they + /// should reach for another provider. See ApiExceptionFilterAttribute for why that is a 502. + /// + public bool IsUpstreamProviderFailure => StatusCode == HttpStatusCode.BadGateway; +} + +/// +/// Reads the API's own error payload off a failed response, so a caller can show what actually went wrong. +/// +public static class ApiResponseExtensions +{ + /// + /// Drop-in replacement for that preserves the + /// API's reported message. + /// + public static async Task EnsureSuccessOrThrowAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + if (response.IsSuccessStatusCode) return; + + throw new ApiRequestException(await ReadErrorMessageAsync(response, cancellationToken), response.StatusCode); + } + + /// + /// The success body, or an carrying the API's own message. Use instead + /// of GetFromJsonAsync wherever the failure is shown to a user. + /// + public static async Task ReadJsonOrThrowAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + { + await response.EnsureSuccessOrThrowAsync(cancellationToken); + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + + /// + /// Falls back to the status line for a response with no { error } body at all - an error page from + /// a proxy in front of the API, an empty 500, a non-JSON body. A failure while reading the explanation of + /// a failure must never replace it with an exception of its own. + /// + private static async Task ReadErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadFromJsonAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(body?.Error)) return body.Error; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // not JSON, or not this shape - the status line below is still better than nothing + } + + return $"The request failed ({(int)response.StatusCode} {response.ReasonPhrase})."; + } + + private sealed class ApiErrorBody + { + [JsonPropertyName("error")] + public string? Error { get; set; } + } +} diff --git a/src/BlazorApp/Components/Shared/Breadcrumb.razor b/src/BlazorApp/Components/Shared/Breadcrumb.razor index 154e8ab1..a3fd036e 100644 --- a/src/BlazorApp/Components/Shared/Breadcrumb.razor +++ b/src/BlazorApp/Components/Shared/Breadcrumb.razor @@ -3,6 +3,11 @@ the list from a detail page. *@ @code { + /// Optional grandparent crumb rendered before (e.g. "Profile › …"). + [Parameter] public string? ParentRoute { get; set; } + + [Parameter] public string? ParentLabel { get; set; } + [Parameter] public required string ListRoute { get; set; } [Parameter] public required string ListLabel { get; set; } diff --git a/src/BlazorApp/Components/Shared/ChartAxes.razor b/src/BlazorApp/Components/Shared/ChartAxes.razor new file mode 100644 index 00000000..7f95c7be --- /dev/null +++ b/src/BlazorApp/Components/Shared/ChartAxes.razor @@ -0,0 +1,56 @@ +@using static Keeptrack.BlazorApp.Components.Shared.SvgChartHelpers +@* + A graduated X/Y axis pair drawn inside a chart's : arrowheads, tick marks, tick labels and axis titles. + The ticks come from the chart, since an evenly spaced value differs between a continuous line chart and a categorical bar chart. + The Y-axis title is a horizontal caption above the axis rather than rotated along it, because a rotated single glyph such as "€" reads as a different character. +*@ + + + + + + +@* Drawn from the origin outward, so each arrowhead (marker-end) points away from it. *@ + + +@foreach (var (y, label) in YTicks) +{ + + + @label + +} +@foreach (var (x, label) in XTicks) +{ + + + @label + +} + + @YAxisLabel + @XAxisLabel + + +@code { + private const string AxisColor = "var(--kt-text-muted)"; + + [Parameter, EditorRequired] public ChartGeometry Geometry { get; set; } + + /// The arrowhead marker's element id, unique per chart on a page since SVG ids are document-wide. + [Parameter, EditorRequired] public string MarkerId { get; set; } = ""; + + [Parameter, EditorRequired] public string XAxisLabel { get; set; } = ""; + + [Parameter, EditorRequired] public string YAxisLabel { get; set; } = ""; + + [Parameter, EditorRequired] public IReadOnlyList<(double Y, string Label)> YTicks { get; set; } = []; + + [Parameter, EditorRequired] public IReadOnlyList<(double X, string Label)> XTicks { get; set; } = []; + + private string MarkerUrl => $"url(#{MarkerId})"; +} diff --git a/src/BlazorApp/Components/Shared/CoverPlaceholder.razor b/src/BlazorApp/Components/Shared/CoverPlaceholder.razor new file mode 100644 index 00000000..795bafaa --- /dev/null +++ b/src/BlazorApp/Components/Shared/CoverPlaceholder.razor @@ -0,0 +1,44 @@ +@* What a row or card shows when an item has no cover art yet. + + It used to be a faint ◈ on a flat grey block, which reads as a failed image load rather than as "nothing + linked here yet" - and in a six-up grid two of them dominate the row. The established answer in media + libraries (Plex, Jellyfin, Spotify) is a text fallback derived from the title: it can never look broken, + it gives each placeholder a different face so a column of them stays scannable, and it carries a little + real information instead of none. + + A title that starts with something other than a letter or digit (punctuation, an emoji) renders nothing + at all rather than that character blown up to 2.4rem - an empty tile is better than a loud meaningless + one. Same for an item with no title yet. *@ + +@if (Initial is not null) +{ + +} + +@code { + [Parameter] public string? Title { get; set; } + + private string? Initial + { + get + { + if (string.IsNullOrWhiteSpace(Title)) return null; + + // Rune, not char: a surrogate pair (an emoji, a rarer CJK glyph) is two chars, and taking + // Title[0] there yields half a code point - which renders as U+FFFD. + foreach (var rune in Title.Trim().EnumerateRunes()) + { + if (System.Text.Rune.IsLetterOrDigit(rune)) + { + return System.Text.Rune.ToUpperInvariant(rune).ToString(); + } + + // Only the *first* character decides: "…And Justice for All" is deliberately blank rather + // than showing the A of its second word, which would misrepresent how it sorts and reads. + return null; + } + + return null; + } + } +} diff --git a/src/BlazorApp/Components/Shared/DateTimeFields.razor b/src/BlazorApp/Components/Shared/DateTimeFields.razor index 20718fbb..377ebd25 100644 --- a/src/BlazorApp/Components/Shared/DateTimeFields.razor +++ b/src/BlazorApp/Components/Shared/DateTimeFields.razor @@ -13,9 +13,12 @@ @* A plain text "HH:mm" field rather than - the native time picker's 12h/24h display is decided by the browser from the OS region format, not anything this page controls (Chrome and Firefox both ignore the page's own language/culture for it), so it can't be forced to - 24h that way. A free-text field sidesteps the native widget entirely and always reads/writes 24h. *@ + 24h that way. A free-text field sidesteps the native widget entirely and always reads/writes 24h. + The colon is optional on input: a phone's numeric keypad has no ":" key, so bare digits like "1430" + (or "930") are accepted too and reformatted to "HH:mm" on blur. *@ + title="Type HH:mm, or just the digits (e.g. 1430) - the colon is optional." + pattern="[0-2]?[0-9]:?[0-5][0-9]" @bind:get="TimeText" @bind:set="SetTimeTextAsync" @bind:event="onchange"/>
@code { @@ -29,9 +32,9 @@ private Task SetDateAsync(DateOnly value) => UpdateAsync(value.ToDateTime(TimeOnly.FromDateTime(Value))); - private Task SetTimeTextAsync(string value) + private Task SetTimeTextAsync(string? value) { - if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + if (TryParseTime(value, out var time)) { return UpdateAsync(DateOnly.FromDateTime(Value).ToDateTime(time)); } @@ -39,6 +42,26 @@ return Task.CompletedTask; } + // Accept "HH:mm" (typed on a full keyboard) but also bare digits ("1430", "930") so a phone's + // numeric keypad - which has no ":" key - can still enter a time. An invalid entry is ignored, + // leaving the previous value untouched, same as before. + private static bool TryParseTime(string? value, out TimeOnly time) + { + if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out time)) + { + return true; + } + + var digits = new string((value ?? "").Where(char.IsDigit).ToArray()); + if (digits.Length is 3 or 4) + { + return TimeOnly.TryParseExact(digits.PadLeft(4, '0'), "HHmm", CultureInfo.InvariantCulture, DateTimeStyles.None, out time); + } + + time = default; + return false; + } + private Task UpdateAsync(DateTime value) { Value = value; diff --git a/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor b/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor deleted file mode 100644 index 500a7def..00000000 --- a/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor +++ /dev/null @@ -1,14 +0,0 @@ -@* A monochrome inline SVG "open external link" glyph, same shape as TrashIcon - used for actions that - leave the app (e.g. the chasse-aux-livres.fr shortcut on BookDetail.razor). Wrap it in the action's own - button/anchor; this is just the glyph. *@ - - - -@code { - [Parameter] public int Size { get; set; } = 15; -} diff --git a/src/BlazorApp/Components/Shared/Icon.razor b/src/BlazorApp/Components/Shared/Icon.razor new file mode 100644 index 00000000..4d5e88ff --- /dev/null +++ b/src/BlazorApp/Components/Shared/Icon.razor @@ -0,0 +1,261 @@ +@* The app's one icon set: a single monochrome line vocabulary drawn as inline SVG. + + It replaces three near-identical things - TrashIcon.razor, ExternalLinkIcon.razor and the sidebar's own + NavIcon.razor - plus the loose Unicode glyphs that were doing icon duty in buttons and toggles. Those + glyphs are the reason this exists: a set assembled from whatever codepoint looked closest (◼ for Movies, + ▭ for TV shows, ● for "owned", □ for "unseen") is indistinguishable at 16px and carries no meaning, and + half of it renders at a different optical weight to the other half because it comes from whichever font + happens to have that codepoint. + + This does NOT relax the no-colour-emoji rule in CLAUDE.md's Theme section - that rule bans codepoints + whose default presentation is a colour glyph, and these are paths. stroke="currentColor" means every + icon inherits its context's own colour and hover/active states, so there is nothing extra to theme, no + webfont, and no request a CSP or an offline deployment could fail. + + Deliberately NOT converted, and they should stay text: + - `★` inside .kt-stars / StarRating - that is a rating *value*, not an icon, and its partial-fill + renders by clipping an overlaid text glyph to a fraction of its width (see .star-fill). + - the `◈` brandmark in .navbar-brand / .kt-login-logo - a logotype, not an affordance. + - `›` in the breadcrumb and `‹` / `→` inside text links - typography inside a sentence. *@ + + + +@code { + /// + /// Which glyph to draw. An unknown name renders an empty (invisible) svg rather than throwing: + /// a missing icon is a cosmetic gap, never a reason to fail a render. + /// + [Parameter] public required string Name { get; set; } + + /// Rendered size in px, square. The sidebar overrides this from CSS instead. + [Parameter] public int Size { get; set; } = 16; + + /// + /// Line weight. The default suits 15-18px; a large decorative instance (an empty-state glyph at 40px) + /// wants a thinner stroke, since stroke width does not scale with the viewBox. + /// + [Parameter] public double StrokeWidth { get; set; } = 1.7; +} diff --git a/src/BlazorApp/Components/Shared/ItemGridCard.razor b/src/BlazorApp/Components/Shared/ItemGridCard.razor new file mode 100644 index 00000000..e43088b3 --- /dev/null +++ b/src/BlazorApp/Components/Shared/ItemGridCard.razor @@ -0,0 +1,64 @@ +@* One poster card for the thumbnail/grid view - the cover art is the hero, with a title + optional meta + captioned underneath. Shared by the inventory list pages, Wishlist and Watch Next so the card markup + (and its stretched-link-opens-detail / actions-above-via-z-index model) exists exactly once. *@ + +
+ @* No Href (e.g. a read-only shared card the recipient can't open) → no stretched link, so the card + isn't a dead link and its own action overlay stays the only clickable thing. *@ + @if (!string.IsNullOrEmpty(Href)) + { + @* rel is set alongside target rather than unconditionally: it only means anything on a new-tab link, + and every in-app card is a same-tab navigation. *@ + + } +
+ @if (!string.IsNullOrEmpty(ImageUrl)) + { + + } + else + { + + } +
+ @* Actions (e.g. delete) sit as a direct child of the card, not inside .kt-grid-cover: the cover gets a + transform on hover, which would create a stacking context trapping the button's z-index below the + card-level stretched-link and making it unclickable. Kept a sibling of the link, its z-index wins. *@ + @Actions +
+
@Title
+ @if (MetaContent is not null) + { +
@MetaContent
+ } +
+
+ +@code { + /// Detail-page URL the whole card links to. + [Parameter] public string? Href { get; set; } + + /// + /// Anchor target for . Unset (same tab) for the in-app detail pages every inventory list + /// opens; "_blank" for a card that leaves the app, e.g. an Explore suggestion opening its provider page - + /// there the point is to read up on a title *without* losing the list being worked through. + /// + [Parameter] public string? Target { get; set; } + + /// Accessible name of the card link when the destination isn't simply "the item" (e.g. "Titanic on IMDb"). + [Parameter] public string? LinkLabel { get; set; } + + [Parameter] public string? Title { get; set; } + + [Parameter] public string? ImageUrl { get; set; } + + /// See : unset = portrait, "square" (albums), "wide" (games). + [Parameter] public string? Shape { get; set; } + + /// The caption's second line (year/creator text, flag pills, badges). Named to avoid the HTML <meta> element. + [Parameter] public RenderFragment? MetaContent { get; set; } + + /// Optional overlay(s) on the cover, e.g. a delete button - raised above the stretched link via z-index. + [Parameter] public RenderFragment? Actions { get; set; } +} diff --git a/src/BlazorApp/Components/Shared/ItemThumb.razor b/src/BlazorApp/Components/Shared/ItemThumb.razor index ed5e5fb8..a87b7871 100644 --- a/src/BlazorApp/Components/Shared/ItemThumb.razor +++ b/src/BlazorApp/Components/Shared/ItemThumb.razor @@ -9,13 +9,16 @@ } else { - ◈ + }
@code { [Parameter] public string? ImageUrl { get; set; } + /// The item's title - only used to derive the no-cover placeholder's initial. + [Parameter] public string? Title { get; set; } + /// null/empty = portrait (default), "square" (albums), "wide" (video games). [Parameter] public string? Shape { get; set; } } diff --git a/src/BlazorApp/Components/Shared/ListViewToggle.razor b/src/BlazorApp/Components/Shared/ListViewToggle.razor new file mode 100644 index 00000000..64e30ad5 --- /dev/null +++ b/src/BlazorApp/Components/Shared/ListViewToggle.razor @@ -0,0 +1,65 @@ +@using Keeptrack.BlazorApp.Components.Inventory +@inject ListViewPreference Preference +@inject IJSRuntime JS + +@* The list/thumbnail segmented control, shared by every list page plus Wishlist and Watch Next. It owns the + single copy of the view-preference plumbing: seed once per circuit from localStorage, persist on toggle, + and notify the parent (which owns rendering list vs grid) via ViewChanged. *@ + +
+ + +
+ +@code { + /// Current view ("" = detailed list, "grid" = poster thumbnails) - the parent owns this value. + [Parameter] public string View { get; set; } = ""; + + [Parameter] public EventCallback ViewChanged { get; set; } + + // Seeds the shared preference from localStorage once per circuit. localStorage isn't reachable during the + // server-side prerender, so this runs on the first interactive render; every later page reads the + // already-seeded value synchronously, so only the very first page of a session can briefly show the + // default view before the saved one applies. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || Preference.Seeded) + { + return; + } + + Preference.Seeded = true; + try + { + var saved = await JS.InvokeAsync("localStorage.getItem", ListViewPreference.StorageKey); + Preference.View = saved ?? ""; + } + catch (JSException) + { + // localStorage unavailable (e.g. private-mode restrictions) - keep the default list view. + } + + if (View != Preference.View) + { + await ViewChanged.InvokeAsync(Preference.View); + } + } + + private async Task SetAsync(string value) + { + Preference.View = value; + Preference.Seeded = true; + try + { + await JS.InvokeVoidAsync("localStorage.setItem", ListViewPreference.StorageKey, value); + } + catch (JSException) + { + // localStorage unavailable - the in-memory preference still carries the choice for the session. + } + + await ViewChanged.InvokeAsync(value); + } +} diff --git a/src/BlazorApp/Components/Shared/PendingReferenceLink.cs b/src/BlazorApp/Components/Shared/PendingReferenceLink.cs new file mode 100644 index 00000000..38c14891 --- /dev/null +++ b/src/BlazorApp/Components/Shared/PendingReferenceLink.cs @@ -0,0 +1,59 @@ +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Shows a freshly created item's reference link once the server's background resolution lands. +/// +/// +/// +/// Creating an item returns as soon as it is stored, and its reference is resolved afterwards on a detached background task (DataCrudControllerBase.OnCreatedAsync), so a slow provider never holds up a create or a bulk import. +/// The Add form navigates straight to the detail page, whose first read can precede the link, so without this a perfectly matched item renders unmatched until something re-reads it. +/// +/// +/// It polls the item, not the provider, and gives up after a few reads: an item with no match settles as unmatched, which is a real answer. +/// It stops the moment the page has changes of its own, since a re-read replaces the page's whole model and would undo them (see ReferenceLinkedDetailPageBase). +/// +/// +public static class PendingReferenceLink +{ + /// How long to keep watching before accepting that there is no match to show. + /// + /// Comfortably past a normal resolve (two IGDB searches plus a details call) without approaching the provider clients' own 30s resilience ceiling, where the answer would be "the provider is down" rather than "no match". + /// + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1.5); + + private const int MaxAttempts = 6; + + /// + /// Re-reads the item until it reports a reference link, then renders it. + /// + /// + /// Whether the item currently held by the page is linked - checked before the first wait, so an already-linked item costs nothing. + /// + /// Re-reads the item; the same fetch the page does on load. + /// + /// The page's InvokeAsync(StateHasChanged) - the poll runs off the render loop, so it may not touch component state directly. + /// + /// Whether the page still holds exactly what it loaded - false once the user has saved anything, at which point a re-read would discard their change rather than reveal a link. + public static async Task WatchAsync(Func isLinked, Func reloadAsync, Func renderAsync, Func isUnedited) + { + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + if (isLinked() || !isUnedited()) return; + + await Task.Delay(PollInterval); + // re-checked after the wait as well as before it: the whole window this guards against is the one *between* the two, where a save lands while this poll's own read is already in flight + if (!isUnedited()) return; + try + { + await reloadAsync(); + } + catch + { + // A navigation away mid-poll disposes the circuit's scoped HttpClient under us, and there is nothing to report to a user who has already left. + return; + } + + await renderAsync(); + } + } +} diff --git a/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs index d4d5facf..620a19e4 100644 --- a/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs +++ b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs @@ -12,12 +12,12 @@ public static (string Message, string Style) Compute(string? previousReferenceId if (string.IsNullOrEmpty(newReferenceId)) { return string.IsNullOrEmpty(previousReferenceId) - ? ("No match found", "neutral") - : ("Unlinked - no match", "danger"); + ? ("No match", "neutral") + : ("Unlinked", "danger"); } return newReferenceId == previousReferenceId - ? ("Already linked", "neutral") + ? ("No change", "neutral") : ("Linked!", "success"); } } diff --git a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs index 663f11e3..11d454f1 100644 --- a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs +++ b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs @@ -1,19 +1,17 @@ -using Microsoft.AspNetCore.Components.Rendering; +using System.Globalization; namespace Keeptrack.BlazorApp.Components.Shared; /// -/// Shared SVG axis-drawing primitives for the app's hand-rolled charts (no charting library dependency for a handful of small trend/bar charts). -/// Extracted from CarDetail.razor so HouseDetail.razor's own yearly cost chart doesn't duplicate the same axis geometry/arrow-marker/tick-label algorithm. -/// Deliberately limited to just axis/geometry, not the per-chart series-drawing code (line vs. bar, single vs. stacked series) - -/// that part differs enough between consumers that it stays with each page rather than being forced into one over-generalized shared renderer. +/// Geometry and formatting shared by the hand-rolled SVG charts, which draw their axes through ChartAxes.razor. +/// There is no charting library for a handful of small charts. +/// Each chart keeps its own series drawing, which differs too much between a line, a bar and a stacked bar to share one renderer. /// public static class SvgChartHelpers { /// /// Plot geometry for one chart. - /// Not every chart shares a single fixed viewBox: a chart rendered at full row width needs a proportionally wider viewBox than a half-width one - - /// matching ViewWidth to actual on-screen width keeps the rendered scale (and therefore axis text/arrow/tick size) the same across every chart instead of the wider ones blowing up. + /// A full-width chart has a proportionally wider viewBox than a half-width one, so axis text, arrows and ticks render at the same size in both. /// public readonly record struct ChartGeometry( double ViewWidth, @@ -29,132 +27,17 @@ public readonly record struct ChartGeometry( public static readonly ChartGeometry FullWidthGeometry = new(ViewWidth: 600, ViewHeight: 170, PlotLeft: 40, PlotRight: 588, PlotTop: 14, PlotBottom: 132); - private const string AttrStroke = "stroke"; - private const string AttrStrokeWidth = "stroke-width"; - private const string AttrVectorEffect = "vector-effect"; - private const string NonScalingStroke = "non-scaling-stroke"; - private const string AttrTextAnchor = "text-anchor"; - private const string AttrClass = "class"; - /// - /// Draws a graduated X/Y axis pair (arrowhead, tick marks, tick labels, axis title). - /// Ticks are computed by the caller, since what counts as an evenly-spaced value differs between a continuous line chart and a per-bar categorical one. - /// The Y-axis title is a plain horizontal caption above the axis rather than rotated sideways along it - - /// fine for a multi-word label like "L/100km", but a rotated single glyph like "€" reads as a completely different, garbled character, not a sideways euro sign. + /// Formats an SVG coordinate or length. + /// Always the invariant culture, since a host running under a culture with a decimal comma would otherwise write "40,0" and break every chart. /// -#pragma warning disable ASP0006 - public static void RenderAxes( - RenderTreeBuilder builder, ref int seq, ChartGeometry geometry, string markerId, string xAxisLabel, string yAxisLabel, - IReadOnlyList<(double Y, string Label)> yTicks, IReadOnlyList<(double X, string Label)> xTicks) - { - const string AxisColor = "var(--kt-text-muted)"; - var (_, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; - - builder.OpenElement(seq++, "defs"); - builder.OpenElement(seq++, "marker"); - builder.AddAttribute(seq++, "id", markerId); - builder.AddAttribute(seq++, "viewBox", "0 0 8 8"); - builder.AddAttribute(seq++, "refX", "6"); - builder.AddAttribute(seq++, "refY", "4"); - builder.AddAttribute(seq++, "markerWidth", "6"); - builder.AddAttribute(seq++, "markerHeight", "6"); - builder.AddAttribute(seq++, "orient", "auto-start-reverse"); - builder.OpenElement(seq++, "path"); - builder.AddAttribute(seq++, "d", "M0,0 L8,4 L0,8 Z"); - builder.AddAttribute(seq++, "fill", AxisColor); - builder.CloseElement(); - builder.CloseElement(); - builder.CloseElement(); - - // Y-axis: drawn bottom-to-top so the arrowhead (marker-end) points up. - builder.OpenElement(seq++, "line"); - builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1")); - builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); - builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); - builder.AddAttribute(seq++, "y2", plotTop.ToString("F1")); - builder.AddAttribute(seq++, AttrStroke, AxisColor); - builder.AddAttribute(seq++, AttrStrokeWidth, "1"); - builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); - builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); - builder.CloseElement(); - - // X-axis: drawn left-to-right so the arrowhead points right. - builder.OpenElement(seq++, "line"); - builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1")); - builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); - builder.AddAttribute(seq++, "x2", plotRight.ToString("F1")); - builder.AddAttribute(seq++, "y2", plotBottom.ToString("F1")); - builder.AddAttribute(seq++, AttrStroke, AxisColor); - builder.AddAttribute(seq++, AttrStrokeWidth, "1"); - builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); - builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); - builder.CloseElement(); - - foreach (var (y, label) in yTicks) - { - builder.OpenElement(seq++, "line"); - builder.AddAttribute(seq++, "x1", (plotLeft - 3).ToString("F1")); - builder.AddAttribute(seq++, "y1", y.ToString("F1")); - builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); - builder.AddAttribute(seq++, "y2", y.ToString("F1")); - builder.AddAttribute(seq++, AttrStroke, AxisColor); - builder.AddAttribute(seq++, AttrStrokeWidth, "1"); - builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); - builder.CloseElement(); + public static string ToSvg(double value) => value.ToString("F1", CultureInfo.InvariantCulture); - builder.OpenElement(seq++, "text"); - builder.AddAttribute(seq++, "x", (plotLeft - 5).ToString("F1")); - builder.AddAttribute(seq++, "y", (y + 2.5).ToString("F1")); - builder.AddAttribute(seq++, AttrTextAnchor, "end"); - builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-text"); - builder.AddContent(seq++, label); - builder.CloseElement(); - } - - foreach (var (x, label) in xTicks) - { - builder.OpenElement(seq++, "line"); - builder.AddAttribute(seq++, "x1", x.ToString("F1")); - builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); - builder.AddAttribute(seq++, "x2", x.ToString("F1")); - builder.AddAttribute(seq++, "y2", (plotBottom + 3).ToString("F1")); - builder.AddAttribute(seq++, AttrStroke, AxisColor); - builder.AddAttribute(seq++, AttrStrokeWidth, "1"); - builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); - builder.CloseElement(); - - builder.OpenElement(seq++, "text"); - builder.AddAttribute(seq++, "x", x.ToString("F1")); - builder.AddAttribute(seq++, "y", (plotBottom + 12).ToString("F1")); - builder.AddAttribute(seq++, AttrTextAnchor, "middle"); - builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-text"); - builder.AddContent(seq++, label); - builder.CloseElement(); - } - - // Y-axis title: a plain horizontal caption in the top-left corner, naming the axis unit. - builder.OpenElement(seq++, "text"); - builder.AddAttribute(seq++, "x", "2"); - builder.AddAttribute(seq++, "y", (plotTop - 4).ToString("F1")); - builder.AddAttribute(seq++, AttrTextAnchor, "start"); - builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-title"); - builder.AddContent(seq++, yAxisLabel); - builder.CloseElement(); - - var xTitleCenter = (plotLeft + plotRight) / 2; - builder.OpenElement(seq++, "text"); - builder.AddAttribute(seq++, "x", xTitleCenter.ToString("F1")); - builder.AddAttribute(seq++, "y", (viewHeight - 4).ToString("F1")); - builder.AddAttribute(seq++, AttrTextAnchor, "middle"); - builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-title"); - builder.AddContent(seq++, xAxisLabel); - builder.CloseElement(); - } -#pragma warning restore ASP0006 + /// Formats a viewBox dimension, which carries no fixed decimal places. + public static string ToSvgDimension(double value) => value.ToString(CultureInfo.InvariantCulture); /// - /// Picks up to evenly-spaced indices from a 0-based range, always including the first and last - - /// shared by every chart's X-axis tick placement. + /// Picks up to evenly spaced indices from a 0-based range, always including the first and last, for X-axis ticks. /// public static List EvenlySpacedIndices(int total, int count) { diff --git a/src/BlazorApp/Components/Shared/TrashIcon.razor b/src/BlazorApp/Components/Shared/TrashIcon.razor deleted file mode 100644 index 81d285a3..00000000 --- a/src/BlazorApp/Components/Shared/TrashIcon.razor +++ /dev/null @@ -1,16 +0,0 @@ -@* A monochrome inline SVG bin, shared by every delete affordance: there is no bin codepoint with a - reliable text presentation (U+1F5D1 renders as a color emoji on several platforms - see the theme - section's emoji gotcha in CLAUDE.md). Wrap it in the action's own button; this is just the glyph. *@ - - - -@code { - [Parameter] public int Size { get; set; } = 15; -} diff --git a/src/BlazorApp/Components/Sharing/ShareApiClient.cs b/src/BlazorApp/Components/Sharing/ShareApiClient.cs new file mode 100644 index 00000000..c162d7cd --- /dev/null +++ b/src/BlazorApp/Components/Sharing/ShareApiClient.cs @@ -0,0 +1,26 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Sharing; + +/// +/// The owner side of sharing - issue, list and revoke directed share grants (/api/shares). +/// Registered with AuthenticationTokenHandler like every other authenticated API client. +/// +public sealed class ShareApiClient(HttpClient http) +{ + public async Task> GetSharesAsync() + { + var result = await http.GetFromJsonAsync>("/api/shares"); + return result ?? []; + } + + public async Task CreateShareAsync(CreateShareRequestDto request) + { + var response = await http.PostAsJsonAsync("/api/shares", request); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + public async Task DeleteShareAsync(string id) => + (await http.DeleteAsync($"/api/shares/{id}")).EnsureSuccessStatusCode(); +} diff --git a/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor new file mode 100644 index 00000000..bed4cf48 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor @@ -0,0 +1,13 @@ +@page "/account/manage/shared/{ShareId}/cars/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared car. Reuses the owner's own CarDetail page in read-only mode (ShareId set), + which loads through the ownership-scoped /api/shared-with-me endpoints instead of the caller-scoped /api/cars. *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedCategoryList.razor b/src/BlazorApp/Components/Sharing/SharedCategoryList.razor new file mode 100644 index 00000000..1648a758 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedCategoryList.razor @@ -0,0 +1,228 @@ +@using Keeptrack.BlazorApp.Components.Inventory +@typeparam TDto where TDto : class, IHasId + +@* A read-only, full-featured list of one shared category: the same InventoryList the owner's own pages use + (search / sort / favourites+owned filters / thumbnails / grid), but with an "add to my collection" icon per + row instead of edit/delete, and a badge for items the recipient already owns. *@ + + + + @if (ShowFavoriteFilter) + { + + } + @if (ShowOwnedFilter) + { + + } + + + @MetaTemplate(item) + + + @{ + var id = item.Id!; + var inCollection = _added.Contains(id) || _alreadyInCollection.Contains(id); + } + @if (Copy is null) + { + @* View-only category (collectibles/gear): a read-only list with no "add to my collection" action. *@ + } + else if (inCollection) + { + ✓ In collection + } + else if (_copyingId == id) + { + + } + else + { + + } + + + +@code { + private const int PageSize = 24; + + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + + /// The category label shown as the list heading (e.g. "Movies"). + [Parameter] public required string Title { get; set; } + + /// Fetches one page for the current query. + [Parameter] public required Func?>> Fetch { get; set; } + + /// + /// Copies the item with the given id into the caller's own collection. Null for view-only categories + /// (collectibles/gear) that have no shared reference to copy - the list then renders no per-row action. + /// + [Parameter] public Func?>>? Copy { get; set; } + + [Parameter] public required Func ItemTitle { get; set; } + [Parameter] public Func? ItemImageUrl { get; set; } + [Parameter] public string? ItemImageShape { get; set; } + [Parameter] public required RenderFragment MetaTemplate { get; set; } + [Parameter] public bool HasRatingSort { get; set; } + [Parameter] public string? ExtraSortValue { get; set; } + [Parameter] public string? ExtraSortLabel { get; set; } + [Parameter] public bool ShowFavoriteFilter { get; set; } + [Parameter] public bool ShowOwnedFilter { get; set; } + + private List _items = []; + private readonly HashSet _alreadyInCollection = []; + private readonly HashSet _added = []; + private string? _copyingId; + + private string _search = ""; + private string _sort = ""; + private int _page = 1; + private long _totalCount; + private bool _favorite; + private bool _owned; + private string _view = ""; + private bool _loading; + private bool _loaded; + private string? _error; + + private int TotalPages => (int)Math.Ceiling(_totalCount / (double)PageSize); + + protected override async Task OnInitializedAsync() + { + _view = ViewPreference.View; + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + _error = null; + try + { + var filters = new Dictionary(); + if (_favorite) filters["IsFavorite"] = "true"; + if (_owned) filters["IsOwned"] = "true"; + + var page = await Fetch(new SharedListQuery(_search, _page, PageSize, _sort, filters)); + _items = page?.Items ?? []; + _totalCount = page?.TotalCount ?? 0; + _alreadyInCollection.Clear(); + if (page is not null) + { + foreach (var id in page.AlreadyInCollectionIds) + { + _alreadyInCollection.Add(id); + } + } + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + _loaded = true; + } + } + + private async Task OnSearchKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + _page = 1; + await LoadAsync(); + } + } + + private async Task OnClearSearch() + { + _search = ""; + _page = 1; + await LoadAsync(); + } + + private async Task OnSortChanged(string sort) + { + _sort = sort; + _page = 1; + await LoadAsync(); + } + + private async Task OnGoToPage(int page) + { + _page = page; + await LoadAsync(); + } + + private async Task ToggleFavorite() + { + _favorite = !_favorite; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleOwned() + { + _owned = !_owned; + _page = 1; + await LoadAsync(); + } + + private async Task AddAsync(string id) + { + if (Copy is null) + { + return; + } + + _copyingId = id; + try + { + var result = await Copy(id); + if (result is not null) + { + (result.AlreadyInCollection ? _alreadyInCollection : _added).Add(id); + } + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _copyingId = null; + } + } +} diff --git a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor new file mode 100644 index 00000000..8e7d829e --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor @@ -0,0 +1,193 @@ +@page "/account/manage/shared/{ShareId}" +@attribute [Authorize(Policy = "MemberOnly")] + +@if (!_loaded) +{ +
+
+ Loading… +
+} +else if (_summary is null) +{ + +
+
+

This collection isn't shared with you (any more). Ask its owner for access.

+
+} +else +{ + var owner = _summary.OwnerDisplayName ?? "A Keeptrack user"; + + +
+

@owner's collection

+
+ +
+ @foreach (var category in SharingLabels.Ordered(_summary.IncludedCategories)) + { + + } +
+ + @switch (_category) + { + case ShareCategory.Movies: + + + + break; + + case ShareCategory.TvShows: + + + + break; + + case ShareCategory.Books: + + + + break; + + case ShareCategory.Albums: + + + + break; + + case ShareCategory.VideoGames: + + + + break; + + case ShareCategory.Collectibles: + + + + break; + + case ShareCategory.Gears: + + + + break; + + case ShareCategory.Cars: + + + + break; + + case ShareCategory.Houses: + + + + break; + + case ShareCategory.Health: + + + + break; + } +} + +@code { + [Parameter] public required string ShareId { get; set; } + + // The selected category lives in the URL (?tab=), same as Wishlist / Watch Next, so back/forward and a + // bookmarked link land on the right tab. + [SupplyParameterFromQuery(Name = "tab")] + public string? TabQuery { get; set; } + + [Inject] private SharedWithMeApiClient Api { get; set; } = null!; + + [Inject] private NavigationManager Navigation { get; set; } = null!; + + private SharedCollectionSummaryDto? _summary; + private ShareCategory? _category; + private bool _loaded; + + protected override async Task OnInitializedAsync() + { + var collections = await Api.GetSharedWithMeAsync(); + _summary = collections.FirstOrDefault(c => c.ShareId == ShareId); + _loaded = true; + } + + // Runs after OnInitializedAsync (so _summary is loaded) and on every ?tab= change (button click, + // browser back/forward), keeping the active tab in sync with the URL. + protected override void OnParametersSet() + { + if (_summary is null) + { + _category = null; + return; + } + + _category = Enum.TryParse(TabQuery, out var tab) && _summary.IncludedCategories.Contains(tab) + ? tab + : SharingLabels.Ordered(_summary.IncludedCategories).FirstOrDefault(); + } + + private void SelectTab(ShareCategory category) => + Navigation.NavigateTo(Navigation.GetUriWithQueryParameters(new Dictionary { ["tab"] = category.ToString() })); + + // Personal-list detail-route projections. Kept as methods (not inline lambdas in the markup) because a $"" + // interpolation inside a razor attribute value can't be delimited by the same double quotes the attribute uses. + private string CarHref(CarDto car) => $"/account/manage/shared/{ShareId}/cars/{car.Id}"; + + private string HouseHref(HouseDto house) => $"/account/manage/shared/{ShareId}/houses/{house.Id}"; + + private string HealthHref(HealthProfileDto profile) => $"/account/manage/shared/{ShareId}/health/{profile.Id}"; +} diff --git a/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor new file mode 100644 index 00000000..a2cd0f4a --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor @@ -0,0 +1,13 @@ +@page "/account/manage/shared/{ShareId}/health/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared health profile. Reuses the owner's HealthProfileDetail page in read-only + mode (ShareId set). *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor new file mode 100644 index 00000000..d9af8f8b --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor @@ -0,0 +1,12 @@ +@page "/account/manage/shared/{ShareId}/houses/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared house. Reuses the owner's HouseDetail page in read-only mode (ShareId set). *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedPersonalList.razor b/src/BlazorApp/Components/Sharing/SharedPersonalList.razor new file mode 100644 index 00000000..0f664e69 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedPersonalList.razor @@ -0,0 +1,121 @@ +@using Keeptrack.BlazorApp.Components.Inventory +@typeparam TDto where TDto : class, IHasId + +@* A read-only list of one shared personal category (cars/houses/health). Reuses the exact InventoryList the + owner's own Cars/Houses/Health pages use - cover thumbnails, grid/list toggle, search, sort and per-type + meta - so the shared view matches the in-app item lists instead of a bare list of links. Unlike media, each + row opens a full read-only detail page (via DetailHref) rather than offering a copy action, and there is no + server paging: a person has only a handful of cars/houses/profiles, so the whole set is fetched once and + search/sort run client-side over it. *@ + + + +@code { + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + + /// The category label shown as the list heading (e.g. "Cars"). + [Parameter] public required string Title { get; set; } + + /// Fetches the sharer's items of this category (read-only, unpaged). + [Parameter] public required Func>> Fetch { get; set; } + + [Parameter] public required Func ItemTitle { get; set; } + + /// Cover thumbnail selector (each personal type carries an optional tenant-owned ImageUrl). + [Parameter] public required Func ItemImageUrl { get; set; } + + /// The per-type second line (same component the owner's own list page uses). + [Parameter] public required RenderFragment MetaTemplate { get; set; } + + /// Builds the read-only detail route for one item (e.g. /account/manage/shared/{id}/cars/{itemId}). + [Parameter] public required Func DetailHref { get; set; } + + private List _all = []; + private List _visible = []; + private bool _loaded; + private string? _error; + private string _search = ""; + private string _sort = ""; + private string _view = ""; + + protected override void OnInitialized() => _view = ViewPreference.View; + + protected override async Task OnParametersSetAsync() + { + _loaded = false; + try + { + _all = await Fetch(); + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loaded = true; + ApplyView(); + } + } + + private void OnSearchChanged(string search) + { + _search = search; + ApplyView(); + } + + private void OnClearSearch() + { + _search = ""; + ApplyView(); + } + + private void OnSortChanged(string sort) + { + _sort = sort; + ApplyView(); + } + + // Client-side search + sort over the already-fetched small set. Mirrors the server-side defaults: a + // case-insensitive title Contains, and the same "" (owner order) / title A-Z sort keys the picker offers + // for these types (personal categories have no rating field, so no Rating option is shown). + private void ApplyView() + { + IEnumerable query = _all; + if (!string.IsNullOrWhiteSpace(_search)) + { + query = query.Where(i => (ItemTitle(i) ?? "").Contains(_search, StringComparison.OrdinalIgnoreCase)); + } + if (_sort == ListSort.Title) + { + query = query.OrderBy(i => ItemTitle(i), StringComparer.OrdinalIgnoreCase); + } + _visible = query.ToList(); + } +} diff --git a/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs b/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs new file mode 100644 index 00000000..2ba32030 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs @@ -0,0 +1,82 @@ +using System.Text; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Sharing; + +/// A search/sort/filter query for one page of a shared category. +public sealed record SharedListQuery(string Search, int Page, int PageSize, string Sort, IReadOnlyDictionary Filters); + +/// +/// The recipient side of sharing - browse the collections others have shared with the caller (with the same +/// search/filter/sort the caller's own lists have) and copy media items into the caller's own collection +/// (/api/shared-with-me). Authenticated like every other client: the server matches the caller's own +/// email against each grant, so this is not an anonymous read. +/// +public sealed class SharedWithMeApiClient(HttpClient http) +{ + public async Task> GetSharedWithMeAsync() + { + var result = await http.GetFromJsonAsync>("/api/shared-with-me"); + return result ?? []; + } + + public Task?> GetMoviesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "movies", query); + public Task?> GetTvShowsAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "tv-shows", query); + public Task?> GetBooksAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "books", query); + public Task?> GetAlbumsAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "albums", query); + public Task?> GetVideoGamesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "video-games", query); + + // Collection categories (collectibles/gear): same read-only list as media, but view-only (never copyable). + public Task?> GetCollectiblesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "collectibles", query); + public Task?> GetGearAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "gear", query); + + // Personal categories (cars/houses/health): list + full read-only detail, never copyable. + public async Task> GetCarsAsync(string shareId) => await GetListAsync(shareId, "cars"); + public async Task> GetHousesAsync(string shareId) => await GetListAsync(shareId, "houses"); + public async Task> GetHealthProfilesAsync(string shareId) => await GetListAsync(shareId, "health-profiles"); + + public Task?> GetCarAsync(string shareId, string carId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/cars/{carId}"); + public Task?> GetHouseAsync(string shareId, string houseId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/houses/{houseId}"); + public Task?> GetHealthProfileAsync(string shareId, string profileId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/health-profiles/{profileId}"); + + public Task?> CopyMovieAsync(string shareId, string itemId) => CopyAsync(shareId, "movies", itemId); + public Task?> CopyTvShowAsync(string shareId, string itemId) => CopyAsync(shareId, "tv-shows", itemId); + public Task?> CopyBookAsync(string shareId, string itemId) => CopyAsync(shareId, "books", itemId); + public Task?> CopyAlbumAsync(string shareId, string itemId) => CopyAsync(shareId, "albums", itemId); + public Task?> CopyVideoGameAsync(string shareId, string itemId) => CopyAsync(shareId, "video-games", itemId); + + private async Task> GetListAsync(string shareId, string segment) + { + var result = await http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/{segment}"); + return result ?? []; + } + + private Task?> GetPageAsync(string shareId, string segment, SharedListQuery query) + { + var url = new StringBuilder($"/api/shared-with-me/{shareId}/{segment}?page={query.Page}&pageSize={query.PageSize}"); + if (!string.IsNullOrEmpty(query.Search)) + { + url.Append("&search=").Append(Uri.EscapeDataString(query.Search)); + } + if (!string.IsNullOrEmpty(query.Sort)) + { + url.Append("&sort=").Append(Uri.EscapeDataString(query.Sort)); + } + foreach (var (key, value) in query.Filters) + { + url.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(value)); + } + + return http.GetFromJsonAsync>(url.ToString()); + } + + private async Task?> CopyAsync(string shareId, string segment, string itemId) + { + var response = await http.PostAsync($"/api/shared-with-me/{shareId}/{segment}/{itemId}/copy", null); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(); + } +} diff --git a/src/BlazorApp/Components/Sharing/SharedWithMeListPage.razor b/src/BlazorApp/Components/Sharing/SharedWithMeListPage.razor new file mode 100644 index 00000000..8e0f4c57 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedWithMeListPage.razor @@ -0,0 +1,68 @@ +@page "/account/manage/shared-with-me" +@attribute [Authorize(Policy = "MemberOnly")] + + + +
+

Shared with me

+
+ +@if (!_loaded) +{ +
+
+ Loading… +
+} +else if (_error is not null) +{ +
@_error
+} +else if (_collections.Count == 0) +{ +
+
+

Nobody has shared a collection with you yet. Shares are matched to your account email.

+
+} +else +{ +

Pick a person to browse what they shared with you.

+ +} + +@code { + [Inject] private SharedWithMeApiClient Api { get; set; } = null!; + + private List _collections = []; + private bool _loaded; + private string? _error; + + protected override async Task OnInitializedAsync() + { + try + { + _collections = await Api.GetSharedWithMeAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loaded = true; + } + } +} diff --git a/src/BlazorApp/Components/Sharing/SharingLabels.cs b/src/BlazorApp/Components/Sharing/SharingLabels.cs new file mode 100644 index 00000000..a40a0d24 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharingLabels.cs @@ -0,0 +1,46 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Sharing; + +/// +/// Human-readable labels for the sharing categories, in one place so the owner and recipient pages never +/// drift on how a category is named. +/// +public static class SharingLabels +{ + public static string Category(ShareCategory category) => category switch + { + ShareCategory.TvShows => "TV shows", + ShareCategory.VideoGames => "Video games", + ShareCategory.Gears => "Gear", + _ => category.ToString() + }; + + // The categories' canonical display order, matching the left nav menu (NavMenu.razor), so tabs and + // summaries read the same way everywhere regardless of the order the owner happened to click them when + // creating the share (which is what a raw stored order reflects). Any category missing here sorts last. + private static readonly ShareCategory[] s_displayOrder = + [ + ShareCategory.Movies, + ShareCategory.TvShows, + ShareCategory.Books, + ShareCategory.Albums, + ShareCategory.VideoGames, + ShareCategory.Cars, + ShareCategory.Houses, + ShareCategory.Health, + ShareCategory.Collectibles, + ShareCategory.Gears + ]; + + /// The given categories in the left-menu display order, not their stored (click) order. + public static IEnumerable Ordered(IEnumerable categories) + { + var order = s_displayOrder; + return categories.OrderBy(c => + { + var index = Array.IndexOf(order, c); + return index < 0 ? int.MaxValue : index; + }); + } +} diff --git a/src/BlazorApp/Components/Sharing/SharingPage.razor b/src/BlazorApp/Components/Sharing/SharingPage.razor new file mode 100644 index 00000000..84f0bef5 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharingPage.razor @@ -0,0 +1,261 @@ +@page "/account/manage/sharing" +@attribute [Authorize(Policy = "MemberOnly")] + + + +
+

Sharing

+
+ +

+ Share whole categories of your collection with someone who has a Keeptrack account. They get a private, + read-only view - matched by their account email, so nothing sensitive travels in a link. Revoke any time. +

+ +
+

New share

+
+
+ + +
+
+ + +
+
+ + +
+ @foreach (var (category, text) in s_mediaCategories) + { + + } +
+ + +
+ @foreach (var (category, text) in s_collectionCategories) + { + + } +
+ + +
+ @foreach (var (category, text) in s_personalCategories) + { + + } +
+ +
+ +
+ @if (_error is not null) + { +
@_error
+ } +
+ +

Active shares

+@if (_shares.Count == 0) +{ +
+
+

You haven't shared anything yet.

+
+} +else +{ +
+
+ @foreach (var share in _shares) + { +
+
+ + @(string.IsNullOrEmpty(share.Label) ? share.RecipientEmail : $"{share.Label} · {share.RecipientEmail}") + +
+ @string.Join(", ", SharingLabels.Ordered(share.IncludedCategories).Select(SharingLabels.Category)) + @share.CreatedAt.ToString("yyyy-MM-dd") +
+
+ +
+ } +
+
+} + + + + + +@code { + [Inject] private ShareApiClient ShareApi { get; set; } = null!; + + private static readonly (ShareCategory Category, string Text)[] s_mediaCategories = + [ + (ShareCategory.Movies, "Movies"), + (ShareCategory.TvShows, "TV shows"), + (ShareCategory.Books, "Books"), + (ShareCategory.Albums, "Albums"), + (ShareCategory.VideoGames, "Video games") + ]; + + // Collection categories are view-only for the recipient (never copyable, no shared reference to copy) but + // render as an ordinary list, unlike the personal categories' read-only detail views. + private static readonly (ShareCategory Category, string Text)[] s_collectionCategories = + [ + (ShareCategory.Collectibles, "Collectibles"), + (ShareCategory.Gears, "Gear") + ]; + + // Personal categories are view-only for the recipient (never copyable) - the classifier enforces that + // server-side. Health is the strictest and is never bundled: it only toggles on after an explicit confirm. + private static readonly (ShareCategory Category, string Text)[] s_personalCategories = + [ + (ShareCategory.Cars, "Cars"), + (ShareCategory.Houses, "Houses"), + (ShareCategory.Health, "Health") + ]; + + private List _shares = []; + private readonly HashSet _selected = []; + private string? _recipientEmail; + private string? _label; + private string? _error; + private bool _saving; + private ShareDto? _pendingRevoke; + private bool _pendingHealthConfirm; + + private string RevokeMessage => _pendingRevoke is null + ? "" + : $"{_pendingRevoke.RecipientEmail} will immediately lose access to everything you shared with them. You can share again later."; + + private string HealthConfirmMessage + { + get + { + var who = string.IsNullOrWhiteSpace(_recipientEmail) ? "this person" : _recipientEmail!.Trim(); + return $"You are about to share your full health journal - appointments, sicknesses and reimbursement amounts - with {who}, read-only. Only do this for someone you trust with that information."; + } + } + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + try + { + _shares = await ShareApi.GetSharesAsync(); + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + private void ToggleCategory(ShareCategory category) + { + if (!_selected.Add(category)) + { + _selected.Remove(category); + } + } + + /// Health is never enabled without an explicit confirmation; the others toggle like media. + private void TogglePersonalCategory(ShareCategory category) + { + if (category == ShareCategory.Health && !_selected.Contains(ShareCategory.Health)) + { + _pendingHealthConfirm = true; + return; + } + + ToggleCategory(category); + } + + private void ConfirmHealth() + { + _pendingHealthConfirm = false; + _selected.Add(ShareCategory.Health); + } + + private async Task CreateShareAsync() + { + if (string.IsNullOrWhiteSpace(_recipientEmail)) + { + _error = "Enter the recipient's email."; + return; + } + + if (_selected.Count == 0) + { + _error = "Pick at least one category to share."; + return; + } + + _saving = true; + try + { + var created = await ShareApi.CreateShareAsync(new CreateShareRequestDto + { + RecipientEmail = _recipientEmail.Trim(), + IncludedCategories = [.. _selected], + Label = string.IsNullOrWhiteSpace(_label) ? null : _label.Trim() + }); + _shares.Add(created); + _recipientEmail = null; + _label = null; + _selected.Clear(); + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _saving = false; + } + } + + private async Task RevokeAsync() + { + var share = _pendingRevoke; + _pendingRevoke = null; + if (share is null) return; + + try + { + await ShareApi.DeleteShareAsync(share.Id); + _shares.Remove(share); + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + } +} diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor index 4b32c84e..3945e364 100644 --- a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -1,8 +1,10 @@ @page "/watch-next" @attribute [Authorize] +@using Keeptrack.BlazorApp.Components.Inventory

Watch next

+
@if (!_loaded) @@ -34,29 +36,46 @@ else if (Data is not null) @if (Data.InProgressShows.Count == 0) {
-
◈
+

Nothing new to watch. Shows need a "Current" status and a linked reference to appear here.

} else {
- } } @@ -65,30 +84,49 @@ else if (Data is not null) @if (Data.MoviesToWatch.Count == 0) {
-
◈
+

Your movie watchlist is empty. Add a movie to your watchlist to see it here.

} else {
- } } @@ -101,6 +139,8 @@ else if (Data is not null) [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's // query params, changing this one never triggers a reload: Data isn't query-dependent here (WatchNextDto @@ -118,16 +158,26 @@ else if (Data is not null) // Deliberately NOT [PersistentState], unlike the detail pages: WatchNextDto embeds every to-watch // movie, an unbounded payload that can exceed the Blazor hub's 32 KB MaximumReceiveMessageSize and // kill the circuit on the prerender-to-interactive handoff (dotnet/aspnetcore#65101, see - // docs/prerender-flash-fix.md). The interactive circuit re-fetches instead; the delay-gated + // docs/archived/prerender-flash-fix.md). The interactive circuit re-fetches instead; the delay-gated // spinner above keeps that re-fetch flash-free when it's fast. private WatchNextDto? Data { get; set; } private Tab _tab = Tab.TvShows; - protected override Task OnInitializedAsync() => LoadAsync(); + // The shared list/thumbnail preference (see ListViewToggle). Both tabs (shows, movies) use portrait + // covers, so the grid needs no per-tab column-shape override here. + private string _view = ""; + + protected override Task OnInitializedAsync() + { + _view = ViewPreference.View; + return LoadAsync(); + } protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.TvShows; + private void OnViewChanged(string view) => _view = view; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); diff --git a/src/BlazorApp/Components/Wishlist/SharedWishlistPage.razor b/src/BlazorApp/Components/Wishlist/SharedWishlistPage.razor index c048a422..981ffc80 100644 --- a/src/BlazorApp/Components/Wishlist/SharedWishlistPage.razor +++ b/src/BlazorApp/Components/Wishlist/SharedWishlistPage.razor @@ -29,7 +29,7 @@ else if (_data is null) {
-
◈
+

This share link is no longer valid - its owner may have stopped sharing. Ask them for a fresh link.

} @@ -47,7 +47,7 @@ else @foreach (var row in rows) {
- +
@row.Title
@@ -71,7 +71,7 @@ else @if (IsEmpty) {
-
◈
+

Nothing on this wishlist yet - check back later.

} diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor index a03d8f7b..cb28064c 100644 --- a/src/BlazorApp/Components/Wishlist/WishlistPage.razor +++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor @@ -1,9 +1,11 @@ @page "/wishlist" @attribute [Authorize] +@using Keeptrack.BlazorApp.Components.Inventory

Wishlist

-
+
+
@@ -69,21 +71,20 @@ else if (Data is not null) @if (rows.Count == 0) {
-
◈
+

@EmptyMessage

} else {
- } } @@ -110,6 +135,8 @@ else if (Data is not null) [Inject] private IJSRuntime JsRuntime { get; set; } = null!; + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's // query params, changing this one never triggers a reload: Data isn't query-dependent here (WishlistDto @@ -127,12 +154,18 @@ else if (Data is not null) // Deliberately NOT [PersistentState], unlike the detail pages: WishlistDto embeds every wishlisted // item across four collections, an unbounded payload that can exceed the Blazor hub's 32 KB // MaximumReceiveMessageSize and kill the circuit on the prerender-to-interactive handoff - // (dotnet/aspnetcore#65101, see docs/prerender-flash-fix.md). The interactive circuit re-fetches + // (dotnet/aspnetcore#65101, see docs/archived/prerender-flash-fix.md). The interactive circuit re-fetches // instead; the delay-gated spinner above keeps that re-fetch flash-free when it's fast. private WishlistDto? Data { get; set; } private Tab _tab = Tab.Movies; + // The shared list/thumbnail preference (see ListViewToggle). Video games are the only wide-cover tab, so + // the grid uses that column shape there and the default portrait shape for movies/TV shows/books. + private string _view = ""; + + private string CurrentShape => _tab == Tab.VideoGames ? "wide" : ""; + private bool _showSharePanel; private List _shares = []; private string? _shareError; @@ -141,10 +174,16 @@ else if (Data is not null) private string ShareUrl(WishlistShareDto share) => Navigation.ToAbsoluteUri($"shared/wishlist/{share.Token}").ToString(); - protected override Task OnInitializedAsync() => LoadAsync(); + protected override Task OnInitializedAsync() + { + _view = ViewPreference.View; + return LoadAsync(); + } protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.Movies; + private void OnViewChanged(string view) => _view = view; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); diff --git a/src/BlazorApp/Components/Wishlist/WishlistRow.cs b/src/BlazorApp/Components/Wishlist/WishlistRow.cs index dc9f0c3e..3744fe6d 100644 --- a/src/BlazorApp/Components/Wishlist/WishlistRow.cs +++ b/src/BlazorApp/Components/Wishlist/WishlistRow.cs @@ -21,4 +21,7 @@ public static List FromBooks(List books) => public static List FromVideoGames(List videoGames) => videoGames.ConvertAll(x => new WishlistRow($"/video-games/{x.Id}", x.ImageUrl, "wide", x.Title, x.Year)); + + public static List FromAlbums(List albums) => + albums.ConvertAll(x => new WishlistRow($"/albums/{x.Id}", x.ImageUrl, "square", x.Title, x.Year, x.Artist)); } diff --git a/src/BlazorApp/Components/_Imports.razor b/src/BlazorApp/Components/_Imports.razor index 03a2303c..2ff26c4f 100644 --- a/src/BlazorApp/Components/_Imports.razor +++ b/src/BlazorApp/Components/_Imports.razor @@ -1,13 +1,15 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Keeptrack.BlazorApp @using Keeptrack.BlazorApp.Components @using Keeptrack.BlazorApp.Components.Account @using Keeptrack.BlazorApp.Components.Account.Shared @using Keeptrack.BlazorApp.Components.Inventory.Clients +@using Keeptrack.BlazorApp.Components.Inventory.Meta @using Keeptrack.BlazorApp.Components.Inventory.Shared @using Keeptrack.BlazorApp.Components.Layout @using Keeptrack.BlazorApp.Components.Shared +@using Keeptrack.BlazorApp.Components.Sharing @using Keeptrack.Common.System @using Keeptrack.WebApi.Contracts.Dto @using Microsoft.AspNetCore.Authorization diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 7eab9b56..7df3f72a 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Keeptrack.BlazorApp.Components.Account; +using Keeptrack.BlazorApp.Components.Account; namespace Keeptrack.BlazorApp.DependencyInjection; @@ -15,6 +15,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) @@ -41,8 +43,14 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) @@ -53,6 +61,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/BlazorApp/Dockerfile b/src/BlazorApp/Dockerfile index 5b13f7a7..e6a1a5c7 100644 --- a/src/BlazorApp/Dockerfile +++ b/src/BlazorApp/Dockerfile @@ -1,10 +1,10 @@ -FROM registry.suse.com/bci/dotnet-aspnet:10.0.10 AS base +FROM registry.suse.com/bci/dotnet-aspnet:10.0.12 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM registry.suse.com/bci/dotnet-sdk:10.0.10 AS build +FROM registry.suse.com/bci/dotnet-sdk:10.0.12 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ./Directory.*.props . diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index 3a3c5f4b..9d4ff691 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -12,14 +12,12 @@ options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")) // mirrors WebApi's policy (the cookie principal carries the same Firebase "role" claim): members and // admins see the whole app; everyone else is the free preview tier (movies + TV shows). This only // drives what the UI shows - the API enforces the same rule on every request. - options.AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); -}); + .AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); // opt-in shared Data Protection key ring (see MongoDbXmlRepository) - required before running more than // one replica of this app, since the auth cookie and antiforgery tokens must decrypt on every replica. // Left unset (the default), the framework keeps its usual per-instance ephemeral keys. @@ -44,6 +42,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddWebApiHttpClient(builder.Configuration.TryGetSection("WebApi:BaseUrl")); builder.Services.AddHealthChecks(); @@ -69,7 +68,7 @@ app.MapGet("/shared/wishlist/{token}", (string token) => new RazorComponentResult(new { Token = token })); app.MapControllers(); -app.MapHealthChecks("/health"); +app.MapHealthChecks("/healthz"); await app.RunAsync(); diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 399b7aea..ba21569a 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -1,5 +1,5 @@ /* ═══════════════════════════════════════════════════════════════════ - KEEPTRACK — theme layer (dark only) + KEEPTRACK: theme layer (dark only) Bootstrap 5.3 override layer. data-bs-theme="dark" is set statically on in App.razor - the app has no light theme and no toggle. ═══════════════════════════════════════════════════════════════════ */ @@ -195,11 +195,30 @@ div.content, button.nav-link:hover { color: var(--kt-danger) !important; background: rgba(199, 58, 82, 0.08) !important; } .nav-icon { - width: 16px; - text-align: center; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; flex-shrink: 0; font-style: normal; - opacity: 0.8; + opacity: 0.85; +} +/* NavIcon renders a bare ; sizing it here (rather than per-icon) keeps every glyph on one optical + scale, and stroke:currentColor on the svg means the nav link's own colour states drive it. */ +.nav-icon svg { width: 18px; height: 18px; display: block; } +.nav-link.active .nav-icon { opacity: 1; } + +/* Section label between nav groups - the sidebar's only structure, so it stays very quiet: it must read + as a divider with a name on it, never as another clickable row. */ +.kt-nav-group { + padding: 1rem 0.75rem 0.35rem; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--kt-text-subtle); + user-select: none; } /* the signed-in user's nav row has no plain-text glyph that reads as "profile" (a person silhouette is a @@ -278,7 +297,11 @@ button.nav-link:hover { color: var(--kt-danger) !important; background: rgba(199 .form-control::placeholder { color: var(--kt-text-subtle) !important; } .form-select option { background: var(--kt-surface-3); color: var(--kt-text); } .form-control-sm, .form-select-sm { padding: 0.3rem 0.65rem !important; font-size: 0.82rem !important; border-radius: 6px !important; } -.form-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--kt-text-muted); margin-bottom: 0.35rem; } +/* Field labels are sentence case; uppercase is reserved for the things that structure a page - section + headings (.kt-section-title, .kt-form-card h5) and table column headers. A detail page otherwise stacks + a dozen shouted micro-labels down one screen, at which point the treatment stops marking hierarchy + because everything has it. Labels are written sentence-case in the markup, so this is CSS-only. */ +.form-label { font-size: 0.78rem; font-weight: 500; letter-spacing: 0; color: var(--kt-text-muted); margin-bottom: 0.3rem; } .form-check-input:checked { background-color: var(--kt-accent) !important; border-color: var(--kt-accent) !important; } /* ── TABLE ─────────────────────────────────────────────────────────── */ @@ -332,6 +355,13 @@ button.nav-link:hover { color: var(--kt-danger) !important; background: rgba(199 width: 100%; } +/* Inventory rows in *list* view only (InventoryList adds this; the grid deliberately doesn't get it). + A .kt-item-row is a flex line whose content clusters left and whose delete button is pushed to the far + edge, so across the full 1100px content column the title and its own delete icon ended up ~900px apart + with nothing in between. Capping the measure keeps a row readable as one object; the grid, the admin + queues and the dense car/health journals all still take the full width. */ +.kt-table-wrap-list { max-width: 920px; margin-inline: auto; } + .kt-table-header { display: flex; align-items: center; @@ -380,20 +410,39 @@ a.kt-item-row { color: inherit; text-decoration: none; } color: var(--kt-text-subtle); } .kt-item-thumb img { width: 100%; height: 100%; object-fit: cover; } + +/* No-cover fallback (CoverPlaceholder.razor): the item's initial, set low-contrast so it recedes instead + of announcing a broken image. Sized per container rather than by a shared value - a 44px row thumb and + a 300px grid cover want very different letters. */ +.kt-cover-initial { + font-weight: 600; + line-height: 1; + color: var(--kt-text-subtle); + opacity: 0.5; + user-select: none; +} +.kt-item-thumb .kt-cover-initial { font-size: 1.1rem; } +.kt-grid-cover .kt-cover-initial { font-size: 2.6rem; } /* per-provider aspect ratios: Discogs album art is square, RAWG game imagery is wide - cropping either into the default 2:3 poster shape cuts the artwork badly */ .kt-item-thumb.square { width: 56px; height: 56px; } .kt-item-thumb.wide { width: 96px; height: 54px; } .kt-item-main { min-width: 0; flex: 1; } +/* the title is body text, not a link colour: the row is one big click target whose hover state carries + the affordance, and a column of accent-blue titles is the loudest thing on a list page. This is what + .kt-grid-title (a plain div, so it never inherited the anchor colour) has always looked like - the two + views now agree. */ .kt-item-title { font-weight: 500; + color: var(--kt-text); text-decoration: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kt-item-row:hover .kt-item-title { color: var(--kt-accent); } .kt-item-meta { display: flex; align-items: center; @@ -421,6 +470,165 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-flag-badge.done { background: var(--kt-success-bg); color: var(--kt-success); } .kt-flag-badge.wishlist { background: var(--kt-accent-glow); color: var(--kt-accent); } +/* reference (provider) rating pill on list/grid rows - the linked reference's own score, a bare number + with no scale. Distinct from the user's personal star rating (.kt-stars) and the flag pills. */ +.kt-ref-rating { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.05rem 0.45rem; + font-size: 0.72rem; + font-weight: 700; + border-radius: 20px; + white-space: nowrap; + background: var(--kt-surface-3); + color: var(--kt-text); +} +.kt-ref-rating .kt-ref-rating-star { color: var(--kt-accent); font-weight: 400; } + +/* per-source rating breakdown on the detail page (ReferenceRatings component) */ +.kt-ref-ratings { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; align-items: baseline; } +.kt-ref-rating-detail { display: inline-flex; align-items: baseline; gap: 0.28rem; white-space: nowrap; } +.kt-ref-rating-detail .kt-ref-rating-star { color: var(--kt-accent); } +.kt-ref-rating-detail .kt-ref-rating-value { font-weight: 700; font-size: 1.05rem; } +.kt-ref-rating-detail .kt-ref-rating-scale { color: var(--kt-text-subtle); font-size: 0.8rem; } +.kt-ref-rating-detail .kt-ref-rating-source { color: var(--kt-text-muted); font-size: 0.8rem; } +.kt-ref-rating-detail .kt-ref-rating-count { color: var(--kt-text-subtle); font-size: 0.75rem; } + +/* ── List/thumbnail view toggle ────────────────────────────────────── */ +/* segmented control in the search bar, only rendered for image-bearing types (see InventoryList) */ +.kt-view-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--kt-border); + border-radius: 8px; + overflow: hidden; +} +.kt-view-btn { + border: 0; + background: transparent; + color: var(--kt-text-muted); + padding: 0.35rem 0.6rem; + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: background var(--kt-transition), color var(--kt-transition); +} +.kt-view-btn + .kt-view-btn { border-left: 1px solid var(--kt-border); } +.kt-view-btn:hover { background: var(--kt-surface-2); color: var(--kt-text); } +.kt-view-btn.active { background: var(--kt-accent); color: #fff; } + +/* ── Inventory thumbnail (grid) view ───────────────────────────────── */ +/* poster cards: the cover art is the hero, title + meta captioned underneath. The whole card opens the + detail page via a stretched-link over the cover; the delete button is raised above it via z-index. */ +.kt-item-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1.25rem 1rem; + padding: 0.5rem 1rem 1.5rem; +} +/* per-shape column sizing: square art reads fine a touch wider than portrait, and wide (16:9) imagery + needs a much larger min so the tiles don't shrink to unreadable strips (video games, cars, houses, ...) */ +.kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } +.kt-item-grid.wide { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } +.kt-grid-card { position: relative; min-width: 0; } +.kt-grid-cover { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 10px; + overflow: hidden; + background: var(--kt-surface-3); + display: flex; + align-items: center; + justify-content: center; + color: var(--kt-text-subtle); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: transform var(--kt-transition), box-shadow var(--kt-transition); +} +.kt-grid-cover.square { aspect-ratio: 1 / 1; } +.kt-grid-cover.wide { aspect-ratio: 16 / 9; } +.kt-grid-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.kt-grid-card:hover .kt-grid-cover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45); +} +/* delete floats top-right over the cover, hidden until hover so it never obscures the art at rest */ +.kt-grid-delete { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 2; + opacity: 0; + background: rgba(0, 0, 0, 0.55); + border-radius: 6px; + transition: opacity var(--kt-transition); +} +.kt-grid-card:hover .kt-grid-delete, +.kt-grid-delete:focus-visible { opacity: 1; } +/* Explore card actions (add / dismiss). Unlike the delete button they are the card's whole purpose, so + they stay visible at rest rather than appearing on hover. Add sits left, dismiss right, across the top. */ +.kt-explore-actions { + position: absolute; + top: 0.35rem; + left: 0.35rem; + right: 0.35rem; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 0.35rem; +} +.kt-explore-actions .kt-icon-btn { background: rgba(0, 0, 0, 0.55); border-radius: 6px; } +/* list-row variant: actions on the right, always visible */ +.kt-explore-row-actions { position: relative; z-index: 2; display: flex; align-items: center; gap: 0.4rem; } +/* "↗ IMDb" in a suggestion's meta line: the card/row leaves the app when clicked, so it says where to. */ +.kt-explore-source { display: inline-flex; align-items: center; gap: 0.25rem; } + +.kt-grid-caption { padding: 0.5rem 0.15rem 0; } +/* Two lines, clamped - a single nowrap line truncated most real titles mid-word ("Project Hail M…"), + and the cards are narrow enough that a second line costs less than the lost words did. + Same clamp mechanism as .kt-ref-synopsis. */ +.kt-grid-title { + font-weight: 500; + font-size: 0.9rem; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.kt-grid-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.2rem 0.45rem; + margin-top: 0.2rem; + font-size: 0.75rem; + color: var(--kt-text-muted); +} +/* A caption is a caption, not a second detail page: at full size the stars alone were taller than the + text they sat under, and every flag pill claimed its own line, so cards in one row ended at wildly + different heights. Smaller stars and pills keep the whole caption to about three lines. */ +.kt-grid-meta .kt-stars { font-size: 0.72rem; } +.kt-grid-meta .star { font-size: 0.95rem; } +.kt-grid-meta .kt-flag-badge { font-size: 0.62rem; padding: 0.02rem 0.4rem; } +/* Watch Next thumbnails: stack the "Next: SxxExx" badge and the episode title in a centered column so a + short episode title drops below the badge instead of sitting beside it, and the whole caption stays + evenly spaced and horizontally centered under the cover. */ +.kt-watchnext-grid .kt-grid-caption { text-align: center; } +.kt-watchnext-grid .kt-grid-meta { + flex-direction: column; + align-items: center; + gap: 0.3rem; + margin-top: 0.3rem; +} +.kt-watchnext-grid .kt-card-badge { margin-bottom: 0; } +@media (max-width: 575.98px) { + .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; padding: 0.5rem 0.75rem 1.25rem; } + .kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } + .kt-item-grid.wide { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); } +} + /* candidate synopsis in the admin linking queue - TMDB synopses can run very long, pushing the Link action below the fold; clamp to a teaser so the card stays scannable */ .kt-ref-synopsis { @@ -480,10 +688,8 @@ a.kt-item-row { color: inherit; text-decoration: none; } being squashed unreadable, which is why this isn't a plain CSS grid instead. */ .kt-btn-row-even > button { flex: 1 1 0; } -/* editable "title" input on a detail page, styled to read as a heading until you interact with it. - flex/min-width let it fill its row (rather than a fixed browser-default input width), so a button placed - right after it (e.g. the reference-refresh icon) sits at a predictable spot instead of trailing right - after wherever the visible text happens to end. */ +/* The detail page's editable title reads as a heading. + `flex` and `min-width` make it fill its row, so the button after it sits at a fixed spot rather than wherever the text ends. */ .kt-title-input { font-size: 2rem; font-weight: 700; @@ -497,6 +703,10 @@ a.kt-item-row { color: inherit; text-decoration: none; } flex: 1 1 auto; min-width: 0; font-family: var(--kt-font); + /* A text cursor and a faint underline mark it as editable without making it look like a button. + A pencil icon would read as one more of the row's icon buttons. */ + cursor: text; + border-bottom-color: var(--kt-border); } .kt-title-input:hover:not(:disabled) { border-color: var(--kt-border); } .kt-title-input:focus { border-color: var(--kt-accent); outline: none; background: var(--kt-surface); } @@ -550,6 +760,20 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-inline-toast.danger { background: var(--kt-danger-bg); color: var(--kt-danger); } @keyframes kt-toast-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } } +/* fixed-width reserved slot for the transient refresh-reference toast, sitting to the left of the + refresh/unlink icons. The width is reserved permanently (even with no message) so the responsive + kt-title-input never resizes - and the icon buttons never shift - as a message appears and then + auto-dismisses a few seconds later. A longer-than-usual message overflows leftward over the title's + tail rather than widening the slot, so the geometry stays constant regardless of message length. */ +.kt-title-toast-slot { + flex: 0 0 auto; + width: 6rem; + display: flex; + align-items: center; + justify-content: flex-end; + overflow: visible; +} + /* small "more options" popup anchored under a kt-icon-btn (e.g. a track row's "⋮" menu) - the row it's anchored to needs position:relative so this absolute box lands directly under the button, not the page. */ .kt-menu { @@ -578,30 +802,45 @@ a.kt-item-row { color: inherit; text-decoration: none; } } .kt-menu-item:hover { background: var(--kt-surface-3); } -/* Album/Book/VideoGame detail hero: cover + fields side by side. A fixed grid (not flex-wrap) so the - layout never depends on how much horizontal space the field values happen to need - relying on - flex-wrap's intrinsic-sizing heuristics caused the cover to intermittently land alone on its own row - for some albums (long genre text, etc.) while others stayed side-by-side, with no clear pattern from - the markup alone. */ -.kt-album-hero { +/* Detail hero (DetailHero.razor): cover + fields side by side, used by Book/Movie/TvShow/Album. + A fixed grid, not flex-wrap, so the layout never depends on how much horizontal space the field values + happen to need - relying on flex-wrap's intrinsic-sizing heuristics used to drop the cover onto its own + row for some albums (long genre text) but not others, with no clear pattern from the markup. + The cover column is a real size rather than a fraction of the row: a 1/6th Bootstrap column rendered the + artwork at thumbnail scale on a wide card and squeezed the fields into a strip beside it. */ +.kt-detail-hero { display: grid; - grid-template-columns: 300px minmax(0, 480px); - gap: 1.5rem; + grid-template-columns: var(--kt-hero-cover-width, 230px) minmax(0, 1fr); + gap: 1.75rem; align-items: start; + /* same measure as .kt-form-card > .row, so a hero card and a plain field card line up */ + max-width: 900px; } -.kt-album-hero.kt-album-hero-no-cover { grid-template-columns: minmax(0, 480px); } -.kt-album-cover { - width: 300px; - height: 300px; +.kt-detail-hero.square { --kt-hero-cover-width: 260px; } +.kt-detail-hero.kt-detail-hero-no-cover { grid-template-columns: minmax(0, 1fr); } + +/* aspect-ratio + object-fit rather than a fixed height: provider artwork is not reliably the ratio it + claims, and cropping to the shape keeps a row of covers optically equal. */ +.kt-detail-cover { + width: 100%; + display: block; + aspect-ratio: 2 / 3; object-fit: cover; border-radius: var(--kt-radius-lg); + background: var(--kt-surface-3); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); } +.kt-detail-cover.square { aspect-ratio: 1 / 1; } + @media (max-width: 767px) { - .kt-album-hero, .kt-album-hero.kt-album-hero-no-cover { grid-template-columns: 1fr; } - .kt-album-cover { width: 200px; height: 200px; } + .kt-detail-hero, + .kt-detail-hero.kt-detail-hero-no-cover { grid-template-columns: 1fr; gap: 1.25rem; } + /* capped, not full-bleed: a phone-width poster pushes every field below the fold on its own */ + .kt-detail-cover { max-width: 170px; } + .kt-detail-cover.square { max-width: 200px; } } -/* Gear/Collectible detail cover: full-width, on top of the fields (unlike .kt-album-hero's side-by-side +/* Gear/Collectible detail cover: full-width, on top of the fields (unlike .kt-detail-hero's side-by-side layout) - sized and fitted for e-shop-style product photography (e.g. an Amazon listing photo), which is usually a product shot on a plain background rather than a poster/album-art image with a fixed aspect ratio. "contain" (not "cover") so the product is never cropped. No background/border - just the image, @@ -623,6 +862,102 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-product-cover-box { height: 320px; } } +/* The video game detail page's own column. + .content is 1100px wide because the list pages need it, which on this page leaves every card, and every line of synopsis in them, far wider than the artwork at the top. + Narrowing the page rather than each card is what keeps the banner, the fields and the platform cards on one shared width. */ +.kt-game-page { + max-width: 920px; + margin-inline: auto; +} + +/* Video game banner (VideoGameDetail.razor): the game's artwork, full width, at the top of the page. + + Measured against the real collection rather than assumed. + 329 of 345 stored covers are RAWG key art (1536x864 and 1438x810, exactly 16:9), 16 are IGDB box art (810x1080 portrait). + The wide banner is therefore the shape this page is built around, which is why it never used .kt-detail-hero's side-by-side poster column. + Shrinking the banner into a fixed side column to suit the portrait minority makes the page worse for 95% of the collection. + + The box is exactly 16:9 and the artwork fills its whole width, with no letterboxing at the sides in any case. + A random sample of the stored RAWG images measured 1920x1080, 4000x2250, 2560x1440, 1536x864 and 1000x562, all exactly 16:9, so `object-fit: cover` crops nothing at all for the ordinary case. + The few that are not (2048x1280, 1763x932, 3761x2158) lose a few percent of one edge, which is the accepted trade for never showing empty space beside the artwork. + A portrait cover is cropped hard by the same rule, which is deliberate: a zoom into the middle of the box art is preferred over bands either side of it, and IGDB is no longer the default provider. + + The artwork is not wrapped in a .kt-form-card: it needs no surface, no border and no padding of its own, and a card around it only inset it and added a gap the rest of the page does not have. + It takes the same width as the cards below it instead, so its height follows from the ratio and the page column. */ +.kt-game-banner { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + /* the same radius as a .kt-form-card, since the banner sits in the same column and reads as the first block of it */ + border-radius: var(--kt-radius-lg); + /* tighter than a card's own 1.75rem: the artwork and the fields under it are one unit, not two sections */ + margin-bottom: 1rem; + background: var(--kt-surface-2); +} +.kt-game-banner-art { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + /* Slightly above centre, which only has an effect on a portrait cover since 16:9 art has no vertical overflow to position. + Box art carries its title at the top, so a dead-centre crop is the one that loses it. */ + object-position: center 40%; +} +/* No artwork: a short strip carrying the item's initial, rather than a blank block that reads as a failed image load. + It drops the 16:9 ratio deliberately: there is no artwork to give room to, and a full-height empty banner would be the largest thing on the page for the items with the least to show. */ +.kt-game-banner-none { aspect-ratio: auto; height: 150px; } +.kt-game-banner-empty { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} +.kt-game-banner-empty .kt-cover-initial { font-size: 4rem; } + +/* A set of short read-only facts (a game's genres, the platforms it released on) as a wrapping row of pills. + Comma-joined into a sentence they read as a paragraph of metadata and get skipped over. */ +.kt-chip-row { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.kt-chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 20px; + border: 1px solid var(--kt-border); + background: var(--kt-surface-2); + color: var(--kt-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +/* Header row of a copy card (a video game's per-platform entry). + The platform name, then the badges saying what state that copy is in, then the card's own actions pushed to the end. */ +.kt-copy-card-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem; + margin-bottom: 1rem; +} +.kt-copy-card-header h3 { font-size: 1.1rem; margin: 0; } +.kt-copy-card-header .kt-copy-card-actions { margin-left: auto; } + +/* One playthrough per row: label, date, remove. + A grid rather than a flex row, so the date input keeps a real width instead of being squeezed by a growing label input, and so the columns line up down the list. */ +.kt-playthrough-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 170px auto; + gap: 0.5rem; + align-items: center; +} +@media (max-width: 575px) { + /* The label takes its own full-width line and the date sits beside the remove button below it. + Three controls abreast on a phone leaves the date unreadable. */ + .kt-playthrough-row { grid-template-columns: minmax(0, 1fr) auto; } + .kt-playthrough-row > :first-child { grid-column: 1 / -1; } +} + /* compact aligned row for a list of tracks/songs inside a .kt-form-card (e.g. a playlist's song list) - a CSS grid so title/artist/album line up in columns across rows without resorting to a full .kt-table-wrap/, which reads as a top-level list page rather than a detail-page sub-section. */ @@ -637,16 +972,28 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-track-row:first-of-type { border-top: none; } /* ── Form card ─────────────────────────────────────────────────────── */ +/* Deliberately no accent top border: a detail page stacks four or more of these, so a highlight every card + carries highlights nothing - and it gave an admin-only notice the same weight as the item's own data. + .kt-modal and .kt-login-card keep theirs, where it reads as "this is the one active surface". + The preceding .kt-section-title plus the card's own border already do the separating. */ .kt-form-card { position: relative; background: var(--kt-surface); border: 1px solid var(--kt-border); - border-top: 2px solid var(--kt-accent); border-radius: var(--kt-radius-lg); padding: 1.5rem 1.75rem; margin-bottom: 1.75rem; box-shadow: var(--kt-shadow); } + +/* A field's width should follow the length of what goes in it, not the width of the monitor: uncapped, + the col-md-* split gave a 120px Year input a ~350px column and left ~250px of void beside Notes. + Direct children only, and the two card kinds whose content genuinely wants the full width opt out - + the car metrics charts, and the copy/platform cards, whose equal-width col-md columns are already + sized to re-share whatever room they have (see OwnedVersionFields). */ +.kt-form-card > .row { max-width: 860px; } +.kt-car-metrics > .row, +.kt-copy-card > .row { max-width: none; } .kt-form-card h5 { color: var(--kt-accent); font-size: 0.85rem; @@ -656,26 +1003,48 @@ a.kt-item-row { color: inherit; text-decoration: none; } letter-spacing: 0.06em; } -/* corner flag: e.g. "watched" status on a movie/show presentation card */ +/* Corner flag: "watched" on a movie, "read" on a book. + This keeps its own place rather than becoming a fourth pill in the header row, deliberately: whether + you have seen a film or read a book is the most important single fact on the page, and the header row's + accent-blue .kt-toggle-btn states all mean "flagged", not "finished" - the success green here is what + carries that distinction, and it is worth more than the consistency of collapsing the two together. + What changed is only what was genuinely wrong with it: + - it is a real