31 KiB
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,GenreSummaryDtoalready 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:
-
ICmsTrackService.GetPagedAsyncdoes NOT currently take album/genre filter parameters. Its signature isGetPagedAsync(int page, int pageSize, string? sortColumn, bool sortDescending, CancellationToken ct). The underlyingGET api/track/pageendpoint acceptsalbum=/genre=query filters, andCmsTrackService(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. -
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, twoMudTabPanels inside oneMudTabs: "Tracks" (theMudTable<TrackDto>withServerData=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 asYYYY-MM-DDstring, Release TypeMudSelect, Cover ArtInputFile), then a master list ofBatchTrackRow(WavFile, TrackName, Status) with move-up/down/remove + a detail pane editing the selected row. Submit: optional one-shot cover-art upload (UploadImageAsync) → per-rowUploadTrackAsync→ per-rowUpdateAsyncto link the image (the upload endpoint takes no imagePath).TrackNumberis the 1-based list position (i + 1).ICmsTrackServicealready has:GetPagedAsync,GetByIdAsync,UploadTrackAsync,UpdateAsync(id, trackName, artist, album, genre, releaseDate, imagePath?, releaseType?, trackNumber?),DeleteTrackAsync(id),UploadImageAsync,GetAlbumSummariesAsync,GetGenreSummariesAsync,GetTrackCountAsync, plus the waveform pair.TrackDtofields: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): ifImagePathnon-empty,background-image: url('api/image/{Uri.EscapeDataString(imagePath)}'); else a CSS fallback swatch. Images served unauthenticated atapi/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:
@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, andCmsTrackGridreads it. Then the accordion's expansion is VM state. - Param-as-truth (lighter, recommended):
CmsTrackGridtakesAlbumFilter/GenreFilteras[Parameter]s; the view passes the expanded genre/album down. The VM holds onlyMode, the summary lists, andExpandedGenre(which genre card is open). The grid filter is derived fromExpandedGenre, 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. AddArtist?(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) trackReleaseDatein the album. - Release-type chip: the brief says "infer from TrackCount + ReleaseType, or from first track."
Simplest defensible rule: use the album's tracks'
ReleaseTypeif uniform; else fall back toTrackCountheuristic (1 = Single, 2–5 = EP, 6+ = LP). Recommend: trust the storedReleaseTypeof the first track (it's set at upload for the whole release) and only use the count heuristic ifReleaseTypeis 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 responsiveMudCardgrid (MudItem xs=12 sm=6 md=4or 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 aCmsTrackGridwithGenreFilter=genre, ShowAddButton=falsebelow 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 insideMudExpansionPanel). Recommend a manual@if (VM.ExpandedGenre == genre)panel rendered after the card grid, keyed to the expanded genre. - The grid inside is the same
CmsTrackGridas 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):
- 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.)
- Track # column: plain
@context.TrackNumber. Header sortable onTrackNumberis optional (default sort stays Track Name asc to match today). - Art thumb (40×40): reuse the public
TrackCardfallback pattern.- If
ImagePathnon-empty: adiv40×40 withbackground-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--fallbackclass — a CMS-side equivalent inCmsTrackGrid.razor.css). Do not depend on the public client's CSS; define the CMS fallback locally (the public class lives inDeepDrftPublic.Client, a different host). If the thumb/fallback is genuinely identical to the public one, that's a candidate for a sharedDeepDrftShared.Clientcomponent later — flag, don't force now (§10.6).
- If
- Release Date format: display
@context.ReleaseDate?.ToString("d MMMM, yyyy")(→ "9 June, 2026");"—"when null. Sorting is unchanged — the sort key is the rawDateOnly(SortLabel="ReleaseDate"still maps to the date column server-side); the format is presentation-only and does not touch the sort. - 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 aMudTooltipwhose content shows both values monospaced:(Brief offered row-level tooltip or an info icon; recommend the info icon in Actions — a row-wide tooltip fights the existing per-cell<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>DataLabelhovers and is awkward to trigger precisely. The icon is an explicit, discoverable affordance.) - Unchanged: sort labels, paging (
PageSizeOptions { 10, 20, 50 }), Add Track button (gated byShowAddButton), 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 allUploadTrackAsync. A single component with@if (isEdit)scattered through the master list, the detail pane, andSubmitAsyncbecomes 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 bothBatchUploadandBatchEditcompose.
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 viaapi/image/{key}). - Master list shows existing tracks ordered by
TrackNumber, eachTrackNamepre-filled, each flagged existing (carries itsId,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 (
Idset):UpdateAsync(Id, TrackName, Artist, Album, Genre, ReleaseDate, imagePath, ReleaseType, TrackNumber). NoteimagePathis tri-state inUpdateAsync(null = unchanged, "" = clear, value = set) — pass the (possibly newly uploaded) cover key to every row so the whole album shares one cover, ornullto leave each unchanged if cover wasn't touched. - New row (
Idnull):UploadTrackAsync(...)then the cover-linkUpdateAsync— exactly today'sBatchUploadpath. - 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)
- (§3) Routing mechanism. Three wrapper pages passing
InitialMode, vs. one multi-@pagecomponent parsing the segment. Recommend wrapper pages. Small call. - (§5b/§10.2) Album expand widget. Recommend an expandable
MudTable(parentRowTemplate- 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.MudTreeViewsuits hierarchies of like items, not parent-rows-of-different-shape. Confirm.
- a child-row region toggled per row) over
- (§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. - (§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. - (§4/§10.5) VM as DI-scoped service vs. page-owned
@codeobject. 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. - (§8) Shared thumb component. The 40×40 art+fallback thumb is near-identical to the public
TrackCardthumb but in a different host. Define locally inCmsTrackGridnow; flag a futureDeepDrftShared.Clientextraction (Phase 7 territory) rather than coupling the CMS to the public client's CSS. Confirm "local now, share later." - (§9) Mode URL vs. tab selection. Confirm
/tracks/albumslands on the Tracks tab with Album mode active, and that tab and mode are independent thereafter (Waveform tab untouched). - (§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.
- Batch Edit component boundary (§10). Confirm the extraction approach (shared sub-components,
new
BatchEditpage) over anisEditflag onBatchUpload. Recommend extraction; the flag is the cheaper-diff alternative if Daniel prefers. - 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)
- Extend
GetPagedAsyncwith album/genre filters (unblocks everything). - Extract
CmsTrackGridfrom today'sTrackListtable + apply the §8 layout changes (Track mode reaches parity — shippable on its own). - Add the mode toggle + routing +
CmsGenreBrowser(Genre mode reusesCmsTrackGrid— cheapest second mode). CmsAlbumBrowser+ theAlbumSummaryDtowidening decision (§6) (the heaviest mode).- Extract
BatchUploadsub-components + buildBatchEdit(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.