Files
deepdrft/product-notes/phase-8-cms-track-browser.md
T
daniel-c-harvey 16f356a760 docs: resolve TrackDto nesting (§0.3) and add §8.0 wave sequencing
Resolve Phase 8 open question 0.3 — TrackDto gets a nested Release
(ReleaseDto); flat release fields removed, all consumers updated as
part of §8.0 (flat read-model rejected). Add §0.6 implementation
sequencing: five mergeable waves with Waves 1+2 as a single deployment
unit and Waves 3+4 parallelizable. Update PLAN.md §8.0 Shape to match.
2026-06-11 11:09:24 -04:00

825 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 8 — CMS Track Browser
Status: spec / one VM, three views. Review decisions folded in 2026-06-11 (waveform-in-grid,
DI-scoped VM, BatchEdit extraction confirmed). **A data-model normalization (§0) is now a hard
pre-requisite — it must land before any Phase 8 UI work**, sequenced as five mergeable waves
(§0.6). Open Q3 resolved 2026-06-11: `TrackDto` gets a **nested `Release` (`ReleaseDto`)**, flat
release fields removed, all consumers updated as part of §8.0. 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, with §8.0 the normalization gate),
`PLAN.md §6.2` (the deferred "card-contextual filtering" item this phase supersedes), memory
*One source, multiple views*.
---
## 0. Pre-requisite: Phase 8.0 — `TrackEntity` Normalization
**This must land before any of §8.1–§8.5 UI work.** It is a breaking data-model change, not a UI
change, and every UI section below assumes the normalized schema. Daniel required this gate during
review on 2026-06-11.
### 0.1 What
Split the current flat `TrackEntity` into two normalized tables.
**`ReleaseEntity` (new)** — release-cardinal data (the "header" shared across all tracks in a
release):
| Field | Type | Notes |
|---|---|---|
| `Id` | `long` | PK |
| `Title` | `string` | the album/release name (currently `TrackEntity.Album`) |
| `Artist` | `string` | |
| `Genre?` | `string` | |
| `ReleaseDate?` | `DateOnly` | |
| `ImagePath?` | `string` | cover art |
| `ReleaseType` | `ReleaseType` enum | |
| `CreatedByUserId?` | `long` | |
**`TrackEntity` (updated)** — track-cardinal data only:
| Field | Type | Notes |
|---|---|---|
| `Id` | `long` | PK |
| `ReleaseId` | `long` (FK → `ReleaseEntity.Id`) | nullable — see open question (1) |
| `Release` | navigation property | |
| `EntryKey` | `string` | FileDatabase link (immutable) |
| `TrackName` | `string` | |
| `TrackNumber` | `int` | 1-based |
| `OriginalFileName?` | `string` | |
*Removed from `TrackEntity`:* `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId` — these now live on the Release.
### 0.2 Why
The current flat schema duplicates release-level metadata (artist, album, genre, release date,
cover art) on every track row. This was fine at launch but creates consistency hazards: updating an
album's cover art or artist name means rewriting every track row. Phase 8 introduces album-mode
editing (Batch Edit, album-scoped delete), so the data model should match the domain — a Release is
a first-class entity; Tracks belong to a Release.
The normalization also collapses several open questions in the UI spec below:
- `AlbumSummaryDto` becomes a simplified `ReleaseDto` (query the Release table, not `GROUP BY`).
- `CmsAlbumBrowser` parent rows map directly to `ReleaseEntity` rows — no derivation needed.
- The `AlbumSummaryDto` widening question (old §6 option ii / §12.3) **dissolves entirely** — the
Release table just has all the fields.
- Batch Edit's "header" = the Release record; "track list" = the Track records.
### 0.3 Shape (direction, not implementation prescription)
**Migration path (breaking):**
1. Create `releases` table (from `ReleaseEntity`).
2. Populate `releases` from the distinct albums in `tracks` — one release row per distinct
`(album, artist)` identity (see open question 5). Tracks with a null album get a nullable FK or a
synthetic "Uncategorized" release (open question 1).
3. Add `release_id` FK column to `tracks`; populate from the newly created release rows.
4. Drop the now-redundant columns from `tracks` (`artist`, `album`, `genre`, `release_date`,
`image_path`, `release_type`, `created_by_user_id`). **This step is the breaking part.**
**DTO impact:**
- New `ReleaseDto` mirroring `ReleaseEntity`.
- `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and a **nested `Release` (`ReleaseDto`)**
property — populated on reads, ignored on writes where only the FK matters. (Open question 3
**RESOLVED 2026-06-11**: nested `ReleaseDto`, not a flat read model. See §0.3 below.)
- `AlbumSummaryDto` is retired / collapsed into `ReleaseDto` (open question 4).
**Consumer-impact list (the most consumer-visible part of this change):**
The existing `GET api/track/page` response shape changes. Callers that read `TrackDto.Artist` /
`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` / `.ReleaseType` must adjust to read those from
the nested Release: `TrackDto.Release.Artist`, `.Release.Title` (was `.Album`), `.Release.Genre`,
`.Release.ReleaseDate`, `.Release.ImagePath`, `.Release.ReleaseType`. **This is not a "keep the flat
fields populated" no-op — the flat fields are removed and every reader is updated.** Known consumers
(full per-file enumeration in §0.6 below):
- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard`, `TrackDetail`,
`TrackMetaLabel`, `NowPlayingCard`) reads `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`
for display. **This is the highest-risk consumer** — a public-facing surface, a different host,
not part of the CMS phase otherwise. Updated in Wave 3 (§0.6).
- **`DeepDrftManager`** — the CMS (this phase's surface). The existing `TrackEdit` / `TrackNew` /
`BatchUpload` forms read/write the flat fields. Updated in Wave 4 (§0.6).
**Service / repository impact (list, not prescription):**
- `TrackRepository` — queries gain joins / `Include`s to load the Release.
- `TrackManager.GetPaged` — sorting by release fields (artist, album, genre, date) needs a join.
- `UnifiedTrackService.UploadAsync` — must find-or-create the Release before inserting the Track.
- `TrackContentService.AddTrackAsync` / the BatchUpload flow — accepts a Release (or release id)
rather than embedding album/artist/etc. on the track upload form.
- The upload API endpoint form fields split: separate "release" fields from "track" fields, or the
upload auto-creates the Release on first track and links subsequent tracks (open question 2).
### 0.4 Open questions (need Daniel before this lands)
1. **Tracks with no album.** Nullable `ReleaseId` (a "standalone track" concept), or does every
track require a Release (a release with null/empty title)? **Recommend nullable FK** — some tracks
genuinely have no album context.
2. **Upload flow change.** Does uploading now require the caller to first create/identify a Release,
or does the endpoint auto-create a Release if none exists for the given album name?
**Recommend auto-create-or-find** (upsert on `(title, artist)`) — keeps upload-form ergonomics
close to today's.
3. **~~`TrackDto` shape for the public API.~~ — RESOLVED 2026-06-11.** `TrackDto` gets a **nested
`Release` (`ReleaseDto`)** property; the release-cardinal flat fields (`Artist`, `Album`, `Genre`,
`ReleaseDate`, `ImagePath`, `ReleaseType`, `CreatedByUserId`) are **removed** from `TrackDto` and
live only on `ReleaseDto`. The flat read-model alternative is **rejected** — denormalizing the
release fields back onto every `TrackDto` would re-introduce exactly the duplication §0
normalizes away, just at the contract layer instead of the table layer. All consumers that read
the flat fields are updated as part of §8.0 (Waves 3 + 4, §0.6) — this is mandatory, not
follow-on. This is the most consumer-visible impact of the phase; the consumer enumeration is
§0.6.
4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes
`GetReleasesAsync` returning `List<ReleaseDto>`. **Recommend retiring `AlbumSummaryDto`** in favor
of `ReleaseDto` — a direct replacement.
5. **Migration release identity.** Existing tracks are flat. The migration derives Release rows from
them. If two tracks share an album name but different artists, they'd map to two releases (or one
ambiguous one). **Recommend grouping by `(album, artist)`** as the release identity key — flag
this edge case explicitly in the migration.
### 0.5 Impact on the Phase 8 UI sections below
With normalization landed:
- `CmsAlbumBrowser` parent rows **are** Release rows — the Release table has all the fields, so the
old §6 "derive artist/genre/date/type" problem and the §12(3) "widen the DTO" question both
disappear.
- Batch Edit's "header" is a `ReleaseDto` form; its "track list" is `List<TrackDto>` (slim).
- The `GetPagedAsync` album/genre filter still works — it joins through the `releases` table.
The UI sections (§1–§13) below are written against the pre-normalization vocabulary in places;
where they mention `AlbumSummaryDto` widening or deriving release fields from children, **read that
as resolved by §0** — the Release table supplies those fields directly. Net of §0, those UI
sections get *simpler*, not harder.
### 0.6 Implementation sequencing — appropriately sized waves
§8.0 is a breaking data-model change with a wide consumer blast radius, so it is sequenced into
independently-mergeable waves. The unit of mergeability is: *can this wave land on `dev` without
breaking the next wave's prerequisites?* Two waves (1 + 2) are a forced exception — flagged below.
**Wave 1 — Data model layer (no app-code changes yet).** *Touches only `DeepDrftData`.*
- `ReleaseEntity` (new). `TrackEntity` updated: add `ReleaseId` FK + `Release` navigation property,
remove the release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId`).
- `ReleaseConfiguration` (EF Core `IEntityTypeConfiguration`): table name, column names, FK
relationship, indexes. `TrackConfiguration` updated for the FK + removed columns.
- EF Core migration: create `releases` table; add `release_id` FK to `tracks`; **data migration**
populate `releases` from distinct `(album, artist)` groups in existing `tracks` rows, then assign
the FK back to each track; finally drop the now-redundant columns from `tracks`.
- **CRITICAL — Wave 1 + Wave 2 are a single deployment unit.** Removing the entity fields breaks
every DTO mapper, repository projection, and service that reads them — the solution will not
compile at the end of Wave 1 alone. Wave 1 and Wave 2 must be developed and merged to `dev`
**together** (one PR, or a short-lived branch merged as a unit) before anything is built or run.
Do not attempt to land Wave 1 on its own.
**Wave 2 — DTOs, services, repositories, API layer.** *`DeepDrftModels`, `DeepDrftData`,
`DeepDrftContent`, `DeepDrftAPI`.* Deploys together with Wave 1.
- New `ReleaseDto` (mirrors `ReleaseEntity`).
- `TrackDto`: remove flat release fields; add `ReleaseId` (long) + `Release` (`ReleaseDto`,
populated on reads, ignored on writes where only the FK matters).
- `TrackRepository` (`GetPaged` / `GetById` / `Update` / etc.): queries JOIN `releases` and project
into the updated `TrackDto` with nested `Release`. Sort-by-release-field (artist/album/genre/date)
now sorts through the join.
- `TrackManager` / `ITrackService`: signature updates where the changed fields surface.
- `UnifiedTrackService.UploadAsync`: resolve a Release (**find-or-create by title + artist**) before
inserting the Track. The upload form still sends album/artist at the top level; the service layer
resolves the Release FK. (This is open question 2's recommended upsert, now committed.)
- `TrackController` endpoints: request/response shapes updated where they touch the changed fields.
- `AlbumSummaryDto` retired; `GetAlbumSummariesAsync` returns `List<ReleaseDto>` (rename to
`GetReleasesAsync`). `ICmsTrackService.GetAlbumSummariesAsync` replaced (or aliased) accordingly.
**Wave 3 — Consumer updates: `DeepDrftPublic.Client` (public site).** Can run in parallel with
Wave 4. Read off the nested `Release` everywhere the flat fields were read. Files verified against
source 2026-06-11:
| File | Flat fields read today | Change |
|---|---|---|
| `Controls/TrackCard.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (grid + row modes) | → `TrackModel.Release.Artist` / `.Release.Title` / `.Release.Genre` / `.Release.ReleaseDate` / `.Release.ImagePath` |
| `Pages/TrackDetail.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (masthead, cover, meta block, `hasMeta` guard) | same nested re-pointing |
| `Controls/AudioPlayerBar/TrackMetaLabel.razor` | `Artist`, `Genre`, `ReleaseDate` (player-bar label) | → `Track.Release.*` |
| `Controls/NowPlayingCard.razor` | `CurrentTrack.Artist`, `CurrentTrack.Album` | → `CurrentTrack.Release.Artist` / `.Release.Title` |
- `TrackCard.razor.cs`, `TracksGallery.razor.cs`, `TrackDetailViewModel.cs`,
`TrackDetail.razor.cs`: pass `TrackDto` through but **do not read the flat fields** — no change
needed beyond recompiling against the new contract. Listed so the implementer confirms rather than
assumes.
- **Not flat-field consumers (do not confuse):** `Pages/AlbumsView.razor` reads
`AlbumSummaryDto.Album` and `Pages/GenresView.razor` reads `GenreSummaryDto.Genre` — these are
*summary* DTOs, affected by the `AlbumSummaryDto` retirement in Wave 2, not by the `TrackDto`
flat-field removal. Reconcile them when Wave 2 settles the summary-DTO shape.
**Wave 4 — Consumer updates: `DeepDrftManager` (existing CMS surfaces).** Can run in parallel with
Wave 3. These must work against the normalized model *before* the Phase 8 UI work (Wave 5) starts.
| File | Today | Change |
|---|---|---|
| `Components/Pages/Tracks/TrackEdit.razor` | `TrackEditForm.From(track)` reads `track.Artist/.Album/.Genre/.ImagePath/.ReleaseDate/.ReleaseType`; `SaveAsync` calls `UpdateAsync` with the flat args | read from `track.Release.*`; `UpdateAsync` path follows Wave 2's signature (still passes album/artist, service resolves the Release) |
| `Components/Pages/Tracks/TrackNew.razor` | binds `_album`/`_artist`/`_genre`/`_releaseDate`, calls `UploadTrackAsync` + cover-link `UpdateAsync` | form fields can stay; the upload service now auto-resolves the Release (Wave 2). Binding unchanged externally, resolution changes internally |
| `Components/Pages/Tracks/BatchUpload.razor` | album-header flat fields sent on upload | same — service auto-creates/finds the Release; form fields stay, internal binding adjusts |
| `Components/Pages/Tracks/TrackList.razor` | the `MudTable<TrackDto>` columns bind `Artist`/`Album`/`Genre`/`ReleaseDate` | re-point to `.Release.*`. **Note:** this grid is superseded by §8.2 `CmsTrackGrid` in Wave 5 — update it minimally in Wave 4 only to keep `dev` green, since Wave 5 rebuilds it |
- Most CMS surfaces touched here are *rebuilt* in Wave 5 (Phase 8 UI). Wave 4's job is the minimum
to keep the existing CMS compiling and functional on the normalized model — not to polish surfaces
that Phase 8 replaces. Flag in each PR which edits are throwaway-pending-Wave-5 vs. durable.
**Wave 5 — Phase 8 UI (the original §8.1–§8.5).** Begins only after Waves 14 are merged and the
normalized model is stable on `dev`. `CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`,
`BatchEdit`, the mode toggle, and routing — all the UI work specced in §1–§13 below. The internal
sequencing *within* Wave 5 is §13.
**Dependency summary:** `[1 + 2 together] → {3, 4 in parallel} → 5`.
---
## 1. Summary
The CMS `/tracks` page today is a single `MudTable<TrackDto>` (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.
**Pre-requisite gate:** all UI work in this phase sits on top of **§0 — the `TrackEntity`
normalization** (split into `ReleaseEntity` + slimmed `TrackEntity`). §0 is a breaking data-model
change that must land first; it also dissolves several of the open questions this spec originally
carried (notably the `AlbumSummaryDto` widening question — the Release table supplies those fields
directly). Read the UI sections below as written against the post-normalization schema.
The Waveform Pre-Processing tab is **removed** (§9): waveform-profile status becomes an in-grid
status column on `CmsTrackGrid`, with per-row "Generate" actions and a page-level "Generate All
Missing" button replacing the old tab.
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. **Caveat:** §0's normalization
changes the shared `TrackDto` contract, which *does* touch the public client — see §0.3 consumer
impact. The browse-mode UI is CMS-only; the data-model change is not.
- **Is** a data-model change — §0 introduces `ReleaseEntity` and slims `TrackEntity`. (The browse
*modes* introduce no new model; the pre-requisite does.)
- 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.
- The Waveform Pre-Processing tab **is removed** — its function moves into the grid (§9).
---
## 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<TrackDto>` 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`
**Registered in DI as a scoped service and injected into the page** — matching the established
project pattern (`TracksViewModel` in `DeepDrftPublic.Client` is registered scoped in
`Startup.ConfigureDomainServices` and injected into `TracksView.razor`). The reason is dependency
hygiene: backend service dependencies (`ICmsTrackService` and the waveform service) live on the VM,
not in the component's `@inject` chain. The component injects only the VM; the VM holds the
services. It owns mode state and the load logic for all three modes. The *views* are dumb; the VM is
the single source of truth.
(This resolves the earlier open question that floated a page-owned `@code` object — see §12.5. The
DI-scoped VM is the decided pattern, not the alternative.)
### 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<AlbumSummaryDto> 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<GenreSummaryDto> 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: (injects) CmsTrackBrowserViewModel [DI-scoped], the mode toggle, page header
├── page header
│ ├── mode toggle (Track | Album | Genre) — MudToggleGroup
│ └── "Generate All Missing" button (Track mode only) ← §9
├── [Track mode] CmsTrackGrid (no filter) ← DEFAULT
│ └── per-row: status icon + Generate (if no profile) + Edit/Delete/Info ← §9
├── [Album mode] CmsAlbumBrowser (parent rows + lazy children)
└── [Genre mode] CmsGenreBrowser (card grid + accordion → CmsTrackGrid)
(navigates to) BatchEdit.razor (/tracks/album/{albumName}/edit)
```
The old `MudTabs` / "Waveform Pre-Processing" `MudTabPanel` is gone (§9). Whether a `MudTabs` shell
survives at all depends on whether any other panel needs it — likely not.
New components:
### 5a. `CmsTrackGrid.razor` — the single reusable flat track table (THE DRY core)
The extracted Track-mode `MudTable<TrackDto>`. 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`) | **Waveform Status** | Actions (Edit, Delete, per-row
**Generate** when no profile, **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, 25 = 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: <large enough for one album>, 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 → **Waveform Status** → Actions. (Track # is the very first column; thumb second;
per the brief. Waveform Status sits just before Actions — see item 7 and §9.)
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
<MudTooltip>
<TooltipContent>
<div style="font-family: monospace; text-align: left;">
<div>Entry: @context.EntryKey</div>
<div>File: @(context.OriginalFileName ?? "—")</div>
</div>
</TooltipContent>
<ChildContent>
<MudIconButton Icon="@Icons.Material.Filled.InfoOutlined" Size="Size.Small" />
</ChildContent>
</MudTooltip>
```
(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.
7. **Waveform Status column (new — replaces the Waveform tab, see §9).** A status icon showing
whether a waveform profile exists for the track: `Icons.Material.Filled.CheckCircle` colored
`Color.Success` when present, `Icons.Material.Filled.Cancel` colored `Color.Warning` when absent.
This maps to the existing `HasProfile` boolean on `WaveformStatusDto`. **Data-contract decision
(flagged):** the grid binds `TrackDto`, which has no waveform status today. Two options —
(a) add `HasWaveformProfile` (bool) to `TrackDto`, or (b) the grid does a separate waveform-status
lookup and merges by `EntryKey`. **Recommend (a)** — one field, keeps the grid a single data
source rather than fanning out a second call per page. When `HasWaveformProfile` is false, the
Actions cell also shows a per-row **Generate** action button (only rendered when the profile is
missing). See §9.
## 9. Waveform processing — tab removed, folded into the grid
**Decision (review, 2026-06-11): the "Waveform Pre-Processing" `MudTabPanel` is removed entirely.**
Its three responsibilities relocate:
1. **Per-track status** → the **Waveform Status column** in `CmsTrackGrid` (§8 item 7). A
success/warning icon driven by the profile-exists boolean. Every track's status is visible inline
while browsing, instead of behind a separate tab.
2. **Per-track generate** → a per-row **Generate** action in `CmsTrackGrid`, rendered only when the
track has no profile (`!HasWaveformProfile`). Generating refreshes the row's status.
3. **Bulk generate** → a **"Generate All Missing"** button in the **main tracks-page header area**,
visible when the user is in **Track mode**. This replaces the bulk-run button that lived in the
Waveform tab.
With the tab gone, the `MudTabs` shell may collapse to a single surface (the mode toggle + the
rendered mode + the page header). Confirm whether `MudTabs` is retained for any other panel or
removed outright. The mode toggle and the "Generate All Missing" button both live in the Track-mode
page header.
Because the status + generate affordances live in `CmsTrackGrid`, **Genre mode's embedded grid gets
them for free** — a genre's tracks show waveform status and per-row generate identically to Track
mode. The "Generate All Missing" page-level button is Track-mode-scoped (it operates over the whole
catalogue, not a filtered slice); whether it should also appear scoped-to-filter in Genre/Album mode
is a minor follow-up, not required for this phase.
This depends on the §8 item-7 data-contract decision (recommend `HasWaveformProfile` on `TrackDto`)
so the grid has the status without a second per-page lookup — which dovetails cleanly with §0's
`TrackDto` rework (the field can be added as part of the normalization DTO pass).
---
## 10. Batch Edit page
`@page "/tracks/album/{AlbumName}/edit"` (URL-encoded album name as the route param).
**Component boundary — CONFIRMED (review, 2026-06-11): a new `BatchEdit.razor` page that shares
extracted sub-components with `BatchUpload.razor`, NOT a `BatchUpload` grown with an `isEdit`
flag.** Sub-components to extract: the album-header fields block (post-§0, edits the `ReleaseDto`),
the batch track list (master list with move-up/down/remove + status chips), and the track detail
pane. Both pages compose these with their own submit logic.
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 |
|---|---|---|---|
| **`TrackEntity` / `ReleaseEntity`** (§0) | **Split** flat `TrackEntity` into `ReleaseEntity` + slim `TrackEntity` | normalization; pre-requisite gate | **High — breaking migration; see §0** |
| **`TrackDto`** (§0) | Slim down (drop release flat fields, add `ReleaseId` + **nested `Release` (`ReleaseDto`)**) | follows the entity split; nested not flat (open Q3 resolved 2026-06-11) | High — touches public + CMS consumers; all updated in Waves 3 + 4 (§0.3, §0.6) |
| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly; also the nested read object on `TrackDto` | Medium |
| `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. Post-§0 the filter joins through `releases` | Low — additive, default-null keeps callers compiling |
| `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low |
| `TrackDto.HasWaveformProfile` (bool) | **Add** (recommended over a second lookup, §8 item 7) | in-grid waveform status column without per-page fan-out | Low — additive; fold into the §0 DTO pass |
| `AlbumSummaryDto` | **Dissolved by §0** — replaced by `ReleaseDto`; `GetAlbumSummariesAsync` → `GetReleasesAsync`. (The old "widen the DTO" question is moot — the Release table has all the fields.) | normalization | — (replaced, not widened) |
| 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 (existing endpoints' *shapes* change per §0). The data-model change **does**
reach the public client (§0.3) — the browse-mode UI does not, but the `TrackDto` rework does.
---
## 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`?~~ — DISSOLVED by §0.** The normalization gives Album mode a
real `ReleaseEntity`/`ReleaseDto` with artist/genre/date/type on it directly; there is no summary
DTO to widen and no derivation from children. The question is moot once §0 lands.
4. **(§6) Release-type chip derivation.** *Mostly dissolved by §0* — `ReleaseType` now lives on the
Release record, so the chip reads `Release.ReleaseType` directly. The count heuristic is only a
migration-time concern (deriving `ReleaseType` for legacy releases that had it per-track); flag in
the §0 migration, not the UI.
5. **~~(§4/§10.5) VM scoping~~ — RESOLVED.** The VM is **DI-registered as a scoped service** and
injected, matching the `TracksViewModel` pattern in `DeepDrftPublic.Client` (keeps backend service
deps off the component's inject chain). Not page-owned. See §4.
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~~ — DISSOLVED.** The Waveform tab is removed (§9), so there
is no tab/mode independence question left. `/tracks/albums` simply lands the page in Album mode.
If a `MudTabs` shell survives at all, confirm what (if anything) the second panel is — likely
none.
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)~~ — RESOLVED.** Confirmed: a **new `BatchEdit.razor`
page** sharing **extracted sub-components** with `BatchUpload.razor` — *not* an `isEdit` flag on
`BatchUpload`. Sub-components to extract: the **album-header fields block**, the **batch track
list** (master list with move-up/down/remove + status chips), and the **track detail pane**. Both
`BatchUpload` and `BatchEdit` compose these with their own submit logic. (Post-§0, the
"album-header block" edits the `ReleaseDto`.) See §10.
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 12 deliver visible value (cleaner Track grid) before any mode work lands, and each step is
independently mergeable.