From 49e99ff9864b0934158b5141a980bd50aa79fbef Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 09:49:19 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20add=20Phase=208=20(CMS=20Track=20Browse?= =?UTF-8?q?r)=20to=20PLAN;=20supersede=20=C2=A76.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 38 +- product-notes/phase-8-cms-track-browser.md | 533 +++++++++++++++++++++ 2 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 product-notes/phase-8-cms-track-browser.md diff --git a/PLAN.md b/PLAN.md index 5c04c51..c1a0383 100644 --- a/PLAN.md +++ b/PLAN.md @@ -77,12 +77,12 @@ See `COMPLETED.md` for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer --- -### 6.2 Card-contextual filtering of the Tracks page — `[deferred]` +### 6.2 Card-contextual filtering of the Tracks page — `[superseded by §8]` - **What:** Make the Album and Genre dashboard cards navigate into a *filtered* `/tracks` view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table. - **Why:** Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist. - **Why deferred:** The dashboard cards aggregate *across all* albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public `AlbumsView`/`GenresView`, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The `GET api/track/page` endpoint already accepts `album=` and `genre=` query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in `TrackList`. -- **Shape (sketch, not committed):** CMS album/genre browse pages (or tabs on `/tracks`) backed by the existing `albums`/`genres` endpoints; rows link to `/tracks?album=…` / `/tracks?genre=…`; `TrackList.LoadServerData` reads the query param and passes it to `GetPagedAsync`. Revisit as its own item when Daniel wants it. +- **Superseded:** **§8 (CMS Track Browser)** builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode *are* the CMS analogue of `AlbumsView`/`GenresView`, and the filter plumbing into `GetPagedAsync` is part of §8's data contract. This item folds into §8; do not implement it separately. --- @@ -130,7 +130,39 @@ Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed --- -## Cross-cutting / not yet themed +## Phase 8 — CMS Track Browser + +Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (component decomposition, VM design, URL scheme, data contracts, open questions). Several decisions are still open there (notably whether to widen `AlbumSummaryDto`) — needs Daniel sign-off before build. + +### 8.1 URL scheme + mode toggle + +- **What:** `/tracks` (Track mode, default), `/tracks/albums`, `/tracks/genres` as route segments; a toggle inside the existing "Tracks" tab switches mode and pushes the matching URL. The Waveform Pre-Processing tab is untouched. +- **Why:** The public home page hard-codes these as cross-host deep-links; a route segment reads as a stable address and matches the app's existing segment-based routing (`/tracks/upload`, `/tracks/{id}`). Query-param mode (`?mode=`) was the alternative — rejected as transient-looking view state, optionally tolerated as an alias. +- **Shape:** One `TrackList` component carrying three `@page` directives (or three thin wrappers passing an `InitialMode`); the toggle drives `Mode` + `NavigationManager.NavigateTo`. See notes §3, §9. + +### 8.2 `CmsTrackGrid` — the reusable flat track table (DRY core) + +- **What:** Extract today's `MudTable` into a standalone `CmsTrackGrid.razor` taking `AlbumFilter`/`GenreFilter` params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (`d MMMM, yyyy`) → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public `TrackCard` fallback pattern, defined locally CMS-side. +- **Why:** Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. +- **Shape:** Owns its own `MudTable` + `LoadServerData` + delete-confirm (lifted from `TrackList`). `GetPagedAsync` gains optional `album`/`genre` filter params — the one real data-contract change (the endpoint already supports the filters). Display date format is presentation-only; sort key stays the raw `DateOnly`. See notes §8, §11. + +### 8.3 Album mode + +- **What:** `CmsAlbumBrowser` — parent album rows (art, name, artist, track count, genre, earliest release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track. +- **Why:** A scannable album catalogue is the CMS analogue of the public `AlbumsView`, and the natural place to manage a release as a unit. +- **Shape:** Parent rows from `GetAlbumSummariesAsync` (eager, once); child tracks lazy via `GetPagedAsync(album:)` on first expand, cached per row — no new endpoint. Expandable `MudTable` over `MudTreeView` (parent rows are multi-column, not tree-shaped). **Open:** the parent row needs artist/genre/date/type, which aren't on `AlbumSummaryDto` — either widen the DTO + summary query (recommended; fully-populated resting row) or derive lazily on expand (thin resting row, no contract change). Daniel's call. See notes §6, §10, §12(3). + +### 8.4 Genre mode + +- **What:** `CmsGenreBrowser` — a responsive `MudCard` grid (one card per genre: name + track count); clicking a card expands it (accordion, one open at a time) to reveal a `CmsTrackGrid` filtered to that genre. +- **Why:** CMS analogue of the public `GenresView`; the card-to-grid expand is the cheapest second mode because the grid is already built (§8.2). +- **Shape:** `GetGenreSummariesAsync` once; the expanded panel renders `CmsTrackGrid` with `GenreFilter` set and the Add button suppressed — zero duplicated table markup. See notes §7. + +### 8.5 Batch Edit page + +- **What:** New page `/tracks/album/{albumName}/edit`, reached from an Album-mode row's Edit action. `BatchUpload`'s master-detail mechanics with the album's data preloaded; submit swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks (new tracks still upload). Distinct from the existing single-track edit at `/tracks/{id}`. +- **Why:** Editing a release as a unit (rename tracks, reorder, swap cover, add tracks) without round-tripping the single-track editor per track. +- **Shape:** Recommend a *new* `BatchEdit.razor` sharing extracted sub-components with `BatchUpload` (album-header block, master list, detail pane) over growing `BatchUpload` with an `isEdit` flag — the flag breeds conditional soup across preload/detail/submit. Cover art uses the established upload-once-then-link-via-`UpdateAsync` two-step. **Open:** does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8,9). A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now. diff --git a/product-notes/phase-8-cms-track-browser.md b/product-notes/phase-8-cms-track-browser.md new file mode 100644 index 0000000..95b2b9d --- /dev/null +++ b/product-notes/phase-8-cms-track-browser.md @@ -0,0 +1,533 @@ +# Phase 8 — CMS Track Browser + +Status: spec / one VM, three views. Several open decisions flagged in §10 — needs Daniel sign-off +before implementation. Author: product-designer. Date: 2026-06-11. +**Plan only — no code edits made by this doc.** + +Cross-references: `PLAN.md §8` (the concise phase entry), `PLAN.md §6.2` (the deferred +"card-contextual filtering" item this phase supersedes), memory *One source, multiple views*. + +--- + +## 1. Summary + +The CMS `/tracks` page today is a single `MudTable` (the "Tracks" tab) plus a +"Waveform Pre-Processing" tab, both inside one `MudTabs`. This phase keeps that page and adds +**three browse modes** for the catalogue — **Track**, **Album**, **Genre** — selected by a +toggle, each addressable by a distinct URL so the public home page can deep-link into a given +mode. + +The architectural spine is **one view-model feeding three renderings**. This matches Daniel's +standing preference (memory: *One source, multiple views* — "same data, different uses and +ergonomics"): the divergence lives in layout, not in data paths. All three modes read from the +**same `ICmsTrackService.GetPagedAsync` path**; Album and Genre modes add summary calls +(`GetAlbumSummariesAsync` / `GetGenreSummariesAsync`) for their *parent* rows but reuse the same +paged-tracks call for their *detail* rows. No new API endpoint is introduced. + +This phase also **supersedes the deferred §6.2** ("card-contextual filtering"). §6.2 was deferred +precisely because filtering needed an intermediate browse surface (a list of albums, a list of +genres) for the admin to pick from — "a CMS analogue of the public `AlbumsView`/`GenresView`." +Album Mode and Genre Mode *are* that surface. §6.2 should be marked superseded-by-§8, not left +dangling. + +It also introduces one new page — **Batch Edit** (`/tracks/album/{albumName}/edit`) — reached +from an Album-Mode row's Edit action. This is `BatchUpload.razor`'s master-detail mechanics with +existing data preloaded and the submit path swapped from upload to metadata update. + +### What this is NOT + +- Not a public-site feature. This is CMS-only (`DeepDrftManager`, InteractiveServer). The public + home page only *links in*; it renders nothing from this phase. +- Not a new data model. `TrackDto`, `AlbumSummaryDto`, `GenreSummaryDto` already exist. +- Not a replacement for the existing single-track edit page (`/tracks/{id}`) — that survives, + reached from Track Mode's per-row Edit. Batch Edit is a *second*, album-scoped editor. +- Not a change to the Waveform Pre-Processing tab (§9 covers how the modes coexist with it). + +--- + +## 2. Grounding — what exists today (verified, not paraphrased) + +Read against the live source on 2026-06-11. Two facts diverge from the brief and are +load-bearing: + +1. **`ICmsTrackService.GetPagedAsync` does NOT currently take album/genre filter parameters.** + Its signature is `GetPagedAsync(int page, int pageSize, string? sortColumn, bool sortDescending, CancellationToken ct)`. + The *underlying* `GET api/track/page` endpoint accepts `album=` / `genre=` query filters, and + `CmsTrackService` (the impl) could pass them — but the interface does not expose them today. + **This phase requires extending that signature** (see §6, data contracts). This is the one real + contract change. Flag it as the first implementation prerequisite. + +2. **An existing single-track edit page already lives at `/tracks/{id}`.** Track Mode's per-row + Edit button (`Href="/tracks/{context.Id}"`) goes there. Album Mode's batch Edit is a *separate* + page. Do not conflate them; do not route the album edit through `/tracks/{id}`. + +Other verified facts: + +- `/tracks` (`TrackList.razor`) is `@attribute [Authorize]`, InteractiveServer, two + `MudTabPanel`s inside one `MudTabs`: "Tracks" (the `MudTable` with `ServerData=LoadServerData`, + `RowsPerPage=20`) and "Waveform Pre-Processing". +- Current Track table columns: Track Name, Artist, Album, Genre, Release Date (`yyyy-MM-dd`), + Entry Key (monospace caption), File Name (monospace caption), Actions (Edit→`/tracks/{id}`, + Delete→confirm dialog → `DeleteTrackAsync`). +- `BatchUpload.razor` (`/tracks/upload`): album-header fields (Album Name, Artist, Genre, + Release Date as `YYYY-MM-DD` string, Release Type `MudSelect`, Cover Art `InputFile`), then a + master list of `BatchTrackRow` (WavFile, TrackName, Status) with move-up/down/remove + a + detail pane editing the selected row. Submit: optional one-shot cover-art upload + (`UploadImageAsync`) → per-row `UploadTrackAsync` → per-row `UpdateAsync` to link the image + (the upload endpoint takes no imagePath). `TrackNumber` is the 1-based list position (`i + 1`). +- `ICmsTrackService` already has: `GetPagedAsync`, `GetByIdAsync`, `UploadTrackAsync`, + `UpdateAsync(id, trackName, artist, album, genre, releaseDate, imagePath?, releaseType?, trackNumber?)`, + `DeleteTrackAsync(id)`, `UploadImageAsync`, `GetAlbumSummariesAsync`, `GetGenreSummariesAsync`, + `GetTrackCountAsync`, plus the waveform pair. +- `TrackDto` fields: `Id, EntryKey, TrackName, Artist, Album?, Genre?, ReleaseDate? (DateOnly), + ImagePath?, OriginalFileName?, ReleaseType, TrackNumber (int, 1-based)`. +- `AlbumSummaryDto`: `Album (string), TrackCount (int), CoverImageKey (string?)`. +- `GenreSummaryDto`: `Genre (string), TrackCount (int)`. +- Image URL pattern (from public `TrackCard.razor`): if `ImagePath` non-empty, + `background-image: url('api/image/{Uri.EscapeDataString(imagePath)}')`; else a CSS fallback + swatch. Images served unauthenticated at `api/image/{entryKey}`. + +--- + +## 3. URL scheme — RECOMMENDATION: route segments, not query params + +**Recommend: `/tracks` (Track, default), `/tracks/albums`, `/tracks/genres`** as route segments, +with `/tracks?mode=…` accepted as a tolerated alias if cheap. + +Three options considered: + +| Option | Form | Verdict | +|---|---|---| +| A. Query param | `/tracks?mode=albums` | Single `@page "/tracks"`, read `mode` from query. Simplest routing. | +| B. Route segment | `/tracks/albums` | Multiple `@page` directives on one component, or sub-routes. Cleanest URL, best deep-link target. | +| C. Separate pages | `/tracks`, `/albums`, `/genres` | Three components. Defeats the one-VM spine. Rejected. | + +**Why B over A.** The brief's load-bearing requirement is that the *public home page hard-codes* +these URLs. A hard-coded link wants to be a stable, clean, semantically-obvious path. A route +segment (`/tracks/albums`) reads as a permanent address; a query param (`?mode=albums`) reads as +transient view state. Route segments are also the convention already in this app — `/tracks`, +`/tracks/upload`, `/tracks/{id}` are all segment-based, none use query params for navigation +state. Matching that convention keeps the URL space coherent. + +**Why tolerate A as an alias.** Blazor lets one component carry multiple `@page` directives. +Adding `@page "/tracks"`, `@page "/tracks/albums"`, `@page "/tracks/genres"` to the one +`TrackList` component (the toggle just sets which mode renders) costs nothing and lets the +existing `?mode=` form (if any external link already uses it) keep working. If no such link +exists, drop the alias — don't carry dead surface. + +**Concrete routing:** + +```razor +@page "/tracks" +@page "/tracks/albums" +@page "/tracks/genres" +``` + +The component reads which segment routed it (via `NavigationManager.Uri` parse, or three thin +wrapper pages each setting an initial-mode parameter — see §10.1 for which). The toggle switches +mode *and* pushes the corresponding URL (`NavigationManager.NavigateTo("/tracks/albums")`) so the +address bar always reflects the current mode and deep-links round-trip. + +**Decision needed (§10.1):** one multi-`@page` component reading the segment, vs. three trivial +wrapper pages passing an `InitialMode` enum into a shared body component. Recommend the latter +for clarity (each wrapper is two lines; the body owns the VM) — but it's a small call. + +--- + +## 4. View-model design — `CmsTrackBrowserViewModel` + +One scoped VM, injected into the page (or `@code`-owned if DI-scoping a VM is awkward in +InteractiveServer — see §10.5). It owns mode state and the load logic for all three modes. The +*views* are dumb; the VM is the single source of truth. + +### 4a. State + +``` +enum BrowseMode { Tracks, Albums, Genres } + +class CmsTrackBrowserViewModel +{ + BrowseMode Mode { get; private set; } // current mode (drives which view renders) + + // Track mode: the MudTable owns its own ServerData paging; the VM only holds the + // active filter (null for plain Track mode). + string? ActiveAlbumFilter { get; private set; } // set by Album-mode child expansion? No — + string? ActiveGenreFilter { get; private set; } // filters belong to CmsTrackGrid params, not VM state. See note. + + // Album mode: + IReadOnlyList Albums { get; } // parent rows, loaded once on entering Albums mode + bool AlbumsLoading { get; } + // child tracks are NOT held in VM; each expanded album row owns its own lazy GetPagedAsync(album:) call. + + // Genre mode: + IReadOnlyList Genres { get; } // cards, loaded once on entering Genres mode + bool GenresLoading { get; } + string? ExpandedGenre { get; } // accordion: at most one expanded at a time +} +``` + +**Note on filters as VM state vs. component params.** There is a real design choice here. Two +readings: + +- **VM-as-truth (heavier):** the VM holds `ActiveGenreFilter`, and `CmsTrackGrid` reads it. Then + the accordion's expansion is VM state. +- **Param-as-truth (lighter, recommended):** `CmsTrackGrid` takes `AlbumFilter`/`GenreFilter` as + `[Parameter]`s; the *view* passes the expanded genre/album down. The VM holds only `Mode`, the + summary lists, and `ExpandedGenre` (which genre card is open). The grid filter is derived from + `ExpandedGenre`, not stored separately. + +**Recommend param-as-truth.** It keeps the VM small and keeps `CmsTrackGrid` genuinely reusable +(its filter is an input, not a read of ambient VM state). The VM owns *mode + summaries + which +thing is expanded*; the grid owns its own paging given a filter. This is the cleanest expression +of "one source, multiple views" — the *source* is `GetPagedAsync`, and the grid is the one view +component, parameterised. + +### 4b. Which service calls happen where + +| Trigger | Call | Owner | +|---|---|---| +| Enter Track mode | none up-front; `MudTable.ServerData` pages on demand | `CmsTrackGrid` | +| Track-mode page/sort | `GetPagedAsync(page, size, sort, desc)` | `CmsTrackGrid.LoadServerData` | +| Enter Album mode | `GetAlbumSummariesAsync()` once | VM | +| Expand an album row | `GetPagedAsync(album: name, …)` lazily, first expansion only | the album row (cached after first load) | +| Album row Edit | navigate to `/tracks/album/{name}/edit` | view | +| Album row Delete | confirm → N× `DeleteTrackAsync` (the album's track ids) | VM/view | +| Enter Genre mode | `GetGenreSummariesAsync()` once | VM | +| Expand a genre card | `CmsTrackGrid` mounts with `GenreFilter=name` → `GetPagedAsync(genre: name, …)` | `CmsTrackGrid` | + +State transitions: mode change → push URL → if entering Albums/Genres and the summary list is +unloaded, fire the summary call; collapse any expanded child/genre. Re-entering a mode reuses the +already-loaded summaries (cache for the page lifetime; a fresh page nav reloads). + +--- + +## 5. Component tree + +``` +TrackList.razor (@page "/tracks" + "/tracks/albums" + "/tracks/genres") +│ owns: CmsTrackBrowserViewModel, the mode toggle, the existing MudTabs shell +│ +├── MudTabs +│ ├── MudTabPanel "Tracks" +│ │ ├── mode toggle (Track | Album | Genre) — MudToggleGroup +│ │ ├── [Track mode] CmsTrackGrid (no filter) ← DEFAULT +│ │ ├── [Album mode] CmsAlbumBrowser (parent rows + lazy children) +│ │ └── [Genre mode] CmsGenreBrowser (card grid + accordion) +│ │ +│ └── MudTabPanel "Waveform Pre-Processing" ← UNCHANGED (§9) +│ +└── (navigates to) BatchEdit.razor (/tracks/album/{albumName}/edit) +``` + +New components: + +### 5a. `CmsTrackGrid.razor` — the single reusable flat track table (THE DRY core) + +The extracted Track-mode `MudTable`. Single source of truth for the track-table layout. + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `AlbumFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(album:)`. | +| `GenreFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(genre:)`. | +| `ShowAddButton` | `bool` | `true` | The "Add Track" button. Off when embedded under a genre. | +| `PageSize` | `int` | `20` | Mirrors today's `RowsPerPage`. | +| `OnTracksChanged` | `EventCallback` | — | Raised after a delete, so a parent can refresh a count. | + +Owns its own `MudTable` + `LoadServerData` + delete-confirm dialog (lifted verbatim from today's +`TrackList`). When `AlbumFilter`/`GenreFilter` is set, `LoadServerData` passes it through. This is +the component consumed by **both** Track mode (no filter) and Genre mode (`GenreFilter` set) — no +duplicated table markup, exactly the brief's DRY requirement. + +**Columns (new layout, §8 for detail):** Track # | Art (40×40) | Track Name | Artist | Album | +Genre | Release Date (`d MMMM, yyyy`) | Actions (Edit, Delete, **Info** tooltip carrying Entry Key ++ File Name). Entry Key and File Name columns are *removed* from the grid and moved into the Info +tooltip. + +### 5b. `CmsAlbumBrowser.razor` — Album mode + +Parent album rows (from `AlbumSummaryDto`) that expand to child track rows (lazy `GetPagedAsync`). +See §6 for the data flow. Uses an **expandable `MudTable` with a `RowTemplate` + per-row child +content**, not `MudTreeView` (see §10.2 recommendation). Each parent row: art thumb, album name, +artist (derived), track count, genre (derived), earliest release date, release-type chip, Edit + +Delete actions. Child rows: track # + track name only (a minimal inline template, not a full +`CmsTrackGrid` — the brief is explicit that album children are simpler). + +### 5c. `CmsGenreBrowser.razor` — Genre mode + +Responsive `MudCard` grid, one card per `GenreSummaryDto` (genre name + track count). Accordion: +clicking a card expands it (and collapses any other) to reveal a `CmsTrackGrid` with +`GenreFilter=genre, ShowAddButton=false` beneath/within. See §7. + +### 5d. `BatchEdit.razor` — new page (§ batch edit below) + +Reached from `CmsAlbumBrowser` row Edit. See dedicated section. + +--- + +## 6. Album mode data flow — parent eager, children lazy + +**Parent rows (eager):** on entering Album mode, the VM calls `GetAlbumSummariesAsync()` once. +Each `AlbumSummaryDto` renders one parent row. The cover thumb uses `CoverImageKey` with the same +`api/image/{Uri.EscapeDataString(key)}` fallback pattern. + +**Derived parent-row fields.** `AlbumSummaryDto` gives only `Album`, `TrackCount`, `CoverImageKey`. +The brief asks the parent row to also show artist, genre, earliest release date, and a +release-type chip — **none of which are on the summary DTO.** Two ways to get them: + +- **(i) Derive from children on expand only.** Parent row shows album name, track count, cover at + rest; artist/genre/date/type fill in *after* the row is expanded and the children load. Cheap, + but the parent row is informationally thin until expanded. +- **(ii) Widen `AlbumSummaryDto`.** Add `Artist?` (or "Various"), `Genre?` (or null→"—"), + `EarliestReleaseDate?`, `ReleaseType?` computed server-side in the albums-summary query. The + parent row is fully populated without expansion. + +**Recommend (ii) — widen the summary DTO.** The parent row is meant to be a scannable album +catalogue; showing artist/genre/date there is the point of Album mode, and lazy-filling them on +expand makes the resting view feel broken. Computing them in the existing albums-summary SQL is a +modest server change (a `GROUP BY album` with `MIN(release_date)`, a uniform-artist check, etc.), +and it keeps the parent render synchronous. **This is a data-contract change to flag (§ open +questions / §10.3).** If Daniel would rather not touch the DTO, fall back to (i) and accept the +thin resting row. + +**"Various" / "—" derivation rules** (wherever they're computed): + +- Artist: if all tracks in the album share one artist → that artist; else `"Various"`. +- Genre: if uniform across the album → that genre; else `"—"`. +- Release date: earliest (`MIN`) track `ReleaseDate` in the album. +- Release-type chip: the brief says "infer from TrackCount + ReleaseType, or from first track." + Simplest defensible rule: use the album's tracks' `ReleaseType` if uniform; else fall back to + `TrackCount` heuristic (1 = Single, 2–5 = EP, 6+ = LP). Recommend: trust the stored + `ReleaseType` of the first track (it's set at upload for the whole release) and only use the + count heuristic if `ReleaseType` is absent. Flag as a small decision (§10.4). + +**Child rows (lazy).** On first expansion of an album row, call +`GetPagedAsync(album: albumName, page: 1, pageSize: , sort: "TrackNumber")`. +Albums are small (a release is a handful of tracks), so a single page suffices — pick a page size +that comfortably exceeds any real album (e.g. 100) rather than paging within an album. Cache the +result on the row so re-expanding doesn't re-fetch. Child rows render **track number + track +name only** — no per-track Edit/Delete (the brief: editing is via album-level Batch Edit). + +**This reuses the same `GetPagedAsync` path as every other mode** — no new endpoint, honouring the +one-source spine. The only new seam is the lazy-on-expand trigger. + +--- + +## 7. Genre mode accordion + +- `GetGenreSummariesAsync()` once on entering Genre mode → a responsive `MudCard` grid + (`MudItem xs=12 sm=6 md=4` or similar), one card per genre: genre name (`Typo.h6`), track count + (`Typo.body2`), and a fallback visual (genres have no cover art — use a neutral swatch or a + genre glyph). +- **Accordion, one open at a time.** Clicking a card sets `VM.ExpandedGenre = genre` (clicking the + open card again clears it). When a genre is expanded, render a `CmsTrackGrid` with + `GenreFilter=genre, ShowAddButton=false` **below the card grid** (a full-width panel under the + expanded card's row), not inside the card (a track table doesn't fit a card cell). The + expanded-card visual state (elevation/border) signals which genre's tracks are showing. +- Layout pattern borrowed from a **master-detail / expanding-panel accordion** (cf. macOS Finder + column expand, or MudBlazor's own `MudExpansionPanels` — though here the panel hosts a grid, so a + manual expand region under the card row reads better than nesting a table inside + `MudExpansionPanel`). Recommend a manual `@if (VM.ExpandedGenre == genre)` panel rendered after + the card grid, keyed to the expanded genre. +- The grid inside is the *same* `CmsTrackGrid` as Track mode — DRY satisfied: zero duplicated table + markup, the genre filter is just a parameter. + +--- + +## 8. Track-mode grid changes (the `CmsTrackGrid` layout) + +Applied in `CmsTrackGrid` (so Genre mode's embedded grid gets them for free): + +1. **Column order (left to right):** Track # → Art thumb → Track Name → Artist → Album → Genre → + Release Date → Actions. (Track # is the very first column; thumb second; per the brief.) +2. **Track # column:** plain `@context.TrackNumber`. Header sortable on `TrackNumber` is optional + (default sort stays Track Name asc to match today). +3. **Art thumb (40×40):** reuse the public `TrackCard` fallback pattern. + - If `ImagePath` non-empty: a `div` 40×40 with + `background-image: url('api/image/@(Uri.EscapeDataString(context.ImagePath))'); background-size: cover; border-radius: 4px;`. + - Else: a neutral fallback swatch (mirror the public `deepdrft-track-row-thumb--fallback` + class — a CMS-side equivalent in `CmsTrackGrid.razor.css`). Do **not** depend on the public + client's CSS; define the CMS fallback locally (the public class lives in `DeepDrftPublic.Client`, + a different host). If the thumb/fallback is genuinely identical to the public one, that's a + candidate for a shared `DeepDrftShared.Client` component later — flag, don't force now (§10.6). +4. **Release Date format:** display `@context.ReleaseDate?.ToString("d MMMM, yyyy")` (→ "9 June, + 2026"); `"—"` when null. Sorting is unchanged — the sort key is the raw `DateOnly` + (`SortLabel="ReleaseDate"` still maps to the date column server-side); the format is + presentation-only and does not touch the sort. +5. **Entry Key + File Name → Info tooltip.** Remove both columns. Add an **Info icon** in the + Actions cell (`Icons.Material.Filled.InfoOutlined`, `Size.Small`) wrapped in a `MudTooltip` + whose content shows both values monospaced: + ```razor + + +
+
Entry: @context.EntryKey
+
File: @(context.OriginalFileName ?? "—")
+
+
+ + + +
+ ``` + (Brief offered row-level tooltip *or* an info icon; recommend the **info icon in Actions** — + a row-wide tooltip fights the existing per-cell `DataLabel` hovers and is awkward to trigger + precisely. The icon is an explicit, discoverable affordance.) +6. **Unchanged:** sort labels, paging (`PageSizeOptions { 10, 20, 50 }`), Add Track button (gated + by `ShowAddButton`), Edit (→`/tracks/{id}`) and Delete (confirm dialog) actions. + +--- + +## 9. Coexistence with the Waveform tab (integration detail the brief understates) + +The mode toggle lives **inside the "Tracks" `MudTabPanel`**, above the rendered mode. The +"Waveform Pre-Processing" tab is a sibling `MudTabPanel` and is **untouched** — switching browse +modes never affects it; switching to the Waveform tab never affects browse mode. + +One wrinkle: the three browse-mode URLs (`/tracks/albums`, `/tracks/genres`) should land the user +on the **Tracks tab** with that mode active. They must not disturb tab selection if the user then +clicks over to Waveform. Simplest: the URL drives initial `Mode` and selects the Tracks tab on +load; tab and mode are independent state thereafter. Confirm this is the intended behaviour +(§10.7) — alternatively the modes could live *outside* the tabs entirely, but that's a bigger +restructure and the brief says keep the Waveform tab as-is. + +--- + +## 10. Batch Edit page + +`@page "/tracks/album/{AlbumName}/edit"` (URL-encoded album name as the route param). + +**Recommendation on the component boundary: a new `BatchEdit.razor` page that shares extracted +sub-components with `BatchUpload.razor`, NOT a `BatchUpload` grown with an `isEdit` flag.** + +Why not an `isEdit` parameter on `BatchUpload`: + +- The two pages diverge in *enough* places that a flag breeds conditionals: edit preloads header + + rows; edit's track rows have no WAV slot for *existing* tracks but *do* allow adding new ones; + edit's submit is per-row `UpdateAsync` (existing) + `UploadTrackAsync` (newly added), where + upload is all `UploadTrackAsync`. A single component with `@if (isEdit)` scattered through the + master list, the detail pane, and `SubmitAsync` becomes hard to read and easy to break. +- But the *mechanics* are highly shareable: the album-header field block, the master list (rows + with move-up/down/remove + status chip), the detail pane, the cover-art picker, `FormatBytes`, + the row model. Those should be **extracted into shared sub-components / a shared partial** that + both `BatchUpload` and `BatchEdit` compose. + +So: extract the reusable pieces (e.g. `AlbumHeaderFields.razor`, `BatchTrackList.razor`, +`BatchTrackDetail.razor`), and have both pages compose them with their own submit logic. This is +the same DRY-by-extraction move as `CmsTrackGrid`, applied to the editor. **Trade-off:** more files +and an upfront extraction cost on `BatchUpload` (which works today). If Daniel wants the smaller +diff, the `isEdit`-flag route is faster to ship but accrues the conditional-soup debt — call it +out, recommend extraction. + +**Preload (edit mode):** + +- Load the album's tracks via `GetPagedAsync(album: albumName, sort: "TrackNumber")` (same lazy + call Album mode uses). +- Header fields pre-filled from the first track: Album, Artist, Genre, Release Date, ReleaseType, + and cover art (`ImagePath` → preview via `api/image/{key}`). +- Master list shows existing tracks ordered by `TrackNumber`, each `TrackName` pre-filled, each + flagged **existing** (carries its `Id`, `EntryKey`; no WAV slot, shows current file name read-only). +- Newly added rows (via the same WAV `InputFile`) are flagged **new** and carry a WAV file like + today. + +**Row model (extended):** + +``` +class BatchEditRow { + long? Id; // null = new track (upload), set = existing (update) + string? EntryKey; // existing only + string? OriginalFileName; // existing only, read-only display + IBrowserFile? WavFile; // new only + string TrackName; + int TrackNumber; // 1-based, reorderable + Status; ErrorMessage; +} +``` + +**Submit (edit mode):** for each row in order, `TrackNumber = position`: + +- **Existing row (`Id` set):** `UpdateAsync(Id, TrackName, Artist, Album, Genre, ReleaseDate, + imagePath, ReleaseType, TrackNumber)`. Note `imagePath` is **tri-state** in `UpdateAsync` (null = + unchanged, "" = clear, value = set) — pass the (possibly newly uploaded) cover key to every row + so the whole album shares one cover, or `null` to leave each unchanged if cover wasn't touched. +- **New row (`Id` null):** `UploadTrackAsync(...)` then the cover-link `UpdateAsync` — exactly + today's `BatchUpload` path. +- If a new cover image was picked, upload it once first (`UploadImageAsync`), then apply its key + across all rows' `UpdateAsync`. Mirrors the existing one-shot-image pattern. + +**Reorder + delete within the album** are natural extensions (the master list already has +move-up/down/remove). Removing an *existing* row should delete the track (`DeleteTrackAsync`) — +flag whether remove-in-edit deletes or just detaches (§10.8); recommend an explicit confirm before +deleting an existing track from within the editor. + +**Album-row Delete (from `CmsAlbumBrowser`, not the editor):** confirm dialog scoped to the album +("Delete all N tracks in '{album}'? This removes metadata and audio for every track."), then +`DeleteTrackAsync` for each track id in the album. The brief specifies album-scoped delete; make +the confirm copy unambiguous about the blast radius. + +--- + +## 11. Data contracts — summary of what changes + +| Contract | Change | Why | Risk | +|---|---|---|---| +| `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters | Low — additive, default-null keeps callers compiling | +| `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low | +| `AlbumSummaryDto` | **Recommended:** add `Artist?`, `Genre?`, `EarliestReleaseDate?`, `ReleaseType?` (§6 option ii) | Fully-populated parent rows without lazy expansion | Medium — touches the albums-summary query + DTO; alternatively defer (§6 option i) | +| albums-summary query (server) | compute the widened fields | as above | Medium — `GROUP BY` aggregation + uniform-value checks | +| New row models (`BatchEditRow`) | new, CMS-internal | edit mode | None (internal) | +| Routes | `/tracks/albums`, `/tracks/genres`, `/tracks/album/{albumName}/edit` | mode deep-links + batch edit | Low | + +No new API endpoints. No change to `TrackDto`. No public-site code (it only links in). + +--- + +## 12. Open questions (need Daniel before build) + +1. **(§3) Routing mechanism.** Three wrapper pages passing `InitialMode`, vs. one multi-`@page` + component parsing the segment. Recommend wrapper pages. *Small call.* +2. **(§5b/§10.2) Album expand widget.** Recommend an expandable `MudTable` (parent `RowTemplate` + + a child-row region toggled per row) over `MudTreeView` — the parent rows are tabular + (multi-column: thumb, name, artist, count, genre, date, type, actions), which a tree node + renders poorly. `MudTreeView` suits hierarchies of like items, not parent-rows-of-different-shape. + Confirm. +3. **(§6/§11) Widen `AlbumSummaryDto`?** Recommend yes (option ii) so Album-mode parent rows show + artist/genre/date/type at rest. Alternative: derive lazily on expand (thin resting row, no DTO + change). **This is the biggest call** — it determines whether Album mode needs a server-side + query change or is pure CMS UI. +4. **(§6) Release-type chip derivation.** Trust first track's stored `ReleaseType`, fall back to a + count heuristic (1=Single, 2–5=EP, 6+=LP) only if absent? Recommend yes. +5. **(§4/§10.5) VM as DI-scoped service vs. page-owned `@code` object.** A scoped DI VM in + InteractiveServer is fine but adds wiring; a plain `@code`-owned object is simpler if the VM is + never shared across components. Recommend page-owned unless there's a reason to inject. *Small.* +6. **(§8) Shared thumb component.** The 40×40 art+fallback thumb is near-identical to the public + `TrackCard` thumb but in a different host. Define locally in `CmsTrackGrid` now; flag a future + `DeepDrftShared.Client` extraction (Phase 7 territory) rather than coupling the CMS to the + public client's CSS. Confirm "local now, share later." +7. **(§9) Mode URL vs. tab selection.** Confirm `/tracks/albums` lands on the Tracks tab with + Album mode active, and that tab and mode are independent thereafter (Waveform tab untouched). +8. **(§10) Remove-in-edit semantics.** Does removing an *existing* track row in Batch Edit delete + the track (with confirm), or only detach it from the editing session? Recommend delete-with- + confirm (a release editor that can't drop a track is half a tool), but it's a destructive + action worth Daniel's explicit nod. +9. **Batch Edit component boundary (§10).** Confirm the extraction approach (shared sub-components, + new `BatchEdit` page) over an `isEdit` flag on `BatchUpload`. Recommend extraction; the flag is + the cheaper-diff alternative if Daniel prefers. +10. **Home-page links.** Out of scope for this CMS phase, but: the public home page will hard-code + `/tracks`, `/tracks/albums`, `/tracks/genres` (cross-host links into the Manager app). Confirm + the Manager base URL is stable/known to the public site at build time (the URL *paths* are + settled here; the *host* resolution is a public-site config question, not this phase's). + +--- + +## 13. Suggested sequencing (within the phase) + +1. Extend `GetPagedAsync` with album/genre filters (unblocks everything). +2. Extract `CmsTrackGrid` from today's `TrackList` table + apply the §8 layout changes (Track + mode reaches parity — shippable on its own). +3. Add the mode toggle + routing + `CmsGenreBrowser` (Genre mode reuses `CmsTrackGrid` — cheapest + second mode). +4. `CmsAlbumBrowser` + the `AlbumSummaryDto` widening decision (§6) (the heaviest mode). +5. Extract `BatchUpload` sub-components + build `BatchEdit` (depends on Album mode's Edit action). + +Steps 1–2 deliver visible value (cleaner Track grid) before any mode work lands, and each step is +independently mergeable.