docs: add Phase 8 (CMS Track Browser) to PLAN; supersede §6.2
This commit is contained in:
@@ -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, 2–5 = EP, 6+ = LP). Recommend: trust the stored
|
||||
`ReleaseType` of the first track (it's set at upload for the whole release) and only use the
|
||||
count heuristic if `ReleaseType` is absent. Flag as a small decision (§10.4).
|
||||
|
||||
**Child rows (lazy).** On first expansion of an album row, call
|
||||
`GetPagedAsync(album: albumName, page: 1, pageSize: <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, 2–5=EP, 6+=LP) only if absent? Recommend yes.
|
||||
5. **(§4/§10.5) VM as DI-scoped service vs. page-owned `@code` object.** A scoped DI VM in
|
||||
InteractiveServer is fine but adds wiring; a plain `@code`-owned object is simpler if the VM is
|
||||
never shared across components. Recommend page-owned unless there's a reason to inject. *Small.*
|
||||
6. **(§8) Shared thumb component.** The 40×40 art+fallback thumb is near-identical to the public
|
||||
`TrackCard` thumb but in a different host. Define locally in `CmsTrackGrid` now; flag a future
|
||||
`DeepDrftShared.Client` extraction (Phase 7 territory) rather than coupling the CMS to the
|
||||
public client's CSS. Confirm "local now, share later."
|
||||
7. **(§9) Mode URL vs. tab selection.** Confirm `/tracks/albums` lands on the Tracks tab with
|
||||
Album mode active, and that tab and mode are independent thereafter (Waveform tab untouched).
|
||||
8. **(§10) Remove-in-edit semantics.** Does removing an *existing* track row in Batch Edit delete
|
||||
the track (with confirm), or only detach it from the editing session? Recommend delete-with-
|
||||
confirm (a release editor that can't drop a track is half a tool), but it's a destructive
|
||||
action worth Daniel's explicit nod.
|
||||
9. **Batch Edit component boundary (§10).** Confirm the extraction approach (shared sub-components,
|
||||
new `BatchEdit` page) over an `isEdit` flag on `BatchUpload`. Recommend extraction; the flag is
|
||||
the cheaper-diff alternative if Daniel prefers.
|
||||
10. **Home-page links.** Out of scope for this CMS phase, but: the public home page will hard-code
|
||||
`/tracks`, `/tracks/albums`, `/tracks/genres` (cross-host links into the Manager app). Confirm
|
||||
the Manager base URL is stable/known to the public site at build time (the URL *paths* are
|
||||
settled here; the *host* resolution is a public-site config question, not this phase's).
|
||||
|
||||
---
|
||||
|
||||
## 13. Suggested sequencing (within the phase)
|
||||
|
||||
1. Extend `GetPagedAsync` with album/genre filters (unblocks everything).
|
||||
2. Extract `CmsTrackGrid` from today's `TrackList` table + apply the §8 layout changes (Track
|
||||
mode reaches parity — shippable on its own).
|
||||
3. Add the mode toggle + routing + `CmsGenreBrowser` (Genre mode reuses `CmsTrackGrid` — cheapest
|
||||
second mode).
|
||||
4. `CmsAlbumBrowser` + the `AlbumSummaryDto` widening decision (§6) (the heaviest mode).
|
||||
5. Extract `BatchUpload` sub-components + build `BatchEdit` (depends on Album mode's Edit action).
|
||||
|
||||
Steps 1–2 deliver visible value (cleaner Track grid) before any mode work lands, and each step is
|
||||
independently mergeable.
|
||||
Reference in New Issue
Block a user