docs: add Phase 8 (CMS Track Browser) to PLAN; supersede §6.2

This commit is contained in:
daniel-c-harvey
2026-06-11 09:49:19 -04:00
parent 5a345cabea
commit 49e99ff986
2 changed files with 568 additions and 3 deletions
+533
View File
@@ -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<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.
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<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`
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<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: 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<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`) | 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, 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 → 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
<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.
---
## 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, 25=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 12 deliver visible value (cleaner Track grid) before any mode work lands, and each step is
independently mergeable.