From 3ef98aa3ffbc694f583260a9998e573f69756a1f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 13 Jun 2026 22:33:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(cms):=20bring=20per-medium=20tab=20grids?= =?UTF-8?q?=20to=20ALL-tab=20parity=20(=C2=A78.C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the rich CmsAlbumBrowser filtered per medium in the CUTS/SESSIONS/MIXES tabs via an optional RowActions slot; retire the thin CmsMediumTable. Session hero and Mix waveform actions preserved; ALL tab and TrackList unchanged. --- .../Pages/Tracks/CmsAlbumBrowser.razor | 11 +++ .../Pages/Tracks/CmsCutBrowser.razor | 23 ++--- .../Pages/Tracks/CmsMediumBrowserBase.cs | 47 ++++++++-- .../Pages/Tracks/CmsMediumTable.razor | 74 --------------- .../Pages/Tracks/CmsMediumTable.razor.css | 14 --- .../Pages/Tracks/CmsMixBrowser.razor | 77 ++++++++-------- .../Pages/Tracks/CmsSessionBrowser.razor | 89 ++++++++++--------- .../Pages/Tracks/CmsSessionBrowser.razor.css | 7 +- 8 files changed, 151 insertions(+), 191 deletions(-) delete mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor delete mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor.css diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor index 0fec963..a6b018b 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor @@ -65,6 +65,10 @@ else @context.TrackCount + @* Medium-specific row action (Session hero, Mix waveform) when a host supplies one; + the ALL tab supplies none. Rendered before the shared edit/delete so the medium + affordance reads left-to-right ahead of the universal actions. *@ + @RowActions?.Invoke(context.Release) ? RowActions { get; set; } + private List _rows = new(); // Tracks the Releases reference last projected into _rows. Guards against OnParametersSet diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor index f0b0a16..4e6ced7 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor @@ -2,27 +2,20 @@ @using DeepDrftModels.DTOs @using DeepDrftModels.Enums -@* Cut-filtered release grid for the Release Archive's CUTS tab (Phase 9 §8.A). Derived from the same - CmsMediumBrowserBase pattern the Session/Mix browsers use, so a fourth medium would follow the same - shape with no parallel path. Cuts carry no medium-specific row action (no hero, no waveform), so the - ActionContent slot renders nothing — every row still gets the shared Edit affordance from - CmsMediumTable. Embedded as tab content only; it has no standalone @page route. *@ - - - - +@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the + tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no + forked grid. Cuts have no medium-specific row action, so no RowActions slot is supplied; the grid + renders its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@ + @code { protected override ReleaseMedium Medium => ReleaseMedium.Cut; protected override string MediumNoun => "cuts"; protected override CutRow ToRow(ReleaseDto release) => new() { Release = release }; + protected override ReleaseDto ReleaseOf(CutRow row) => row.Release; public sealed class CutRow { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs b/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs index bd77849..72b15b0 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs +++ b/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs @@ -7,14 +7,18 @@ using MudBlazor; namespace DeepDrftManager.Components.Pages.Tracks; /// -/// Shared fetch + state logic for the single-track medium browsers (Sessions, Mixes). Analogous to the -/// public-site MediumBrowseBase: subclasses supply the , the noun used in -/// error text, and a per-row projection from to their own row model; this base -/// owns the loading flag, the row list, the initial load, and the cover-thumbnail URL helper. The shared -/// table structure lives in CmsMediumTable; subclasses render it and fill only the action column. +/// Shared fetch + state logic for the per-medium browsers (Cuts, Sessions, Mixes). Each subclass feeds +/// the rich CmsAlbumBrowser grid a medium-filtered release list, so the per-medium tabs gain the +/// same expand-tracks / delete / Type-chip / edit behaviour as the ALL tab without re-implementing any of +/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the +/// per-release row projection, and a cover-thumbnail helper; subclasses supply the , +/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the +/// rich grid's RowActions slot, looking their action-state row up with . /// -/// The subclass's row model wrapping a . -public abstract class CmsMediumBrowserBase : ComponentBase +/// The subclass's row model wrapping a plus its +/// medium-specific action state (upload/generate flags). The rich grid renders from the bare +/// projection; only carries the action state. +public abstract class CmsMediumBrowserBase : ComponentBase where TRow : class { [Inject] public required ICmsReleaseService CmsReleaseService { get; set; } [Inject] public required ISnackbar Snackbar { get; set; } @@ -28,11 +32,38 @@ public abstract class CmsMediumBrowserBase : ComponentBase /// Projects a fetched release into the subclass's row model. protected abstract TRow ToRow(ReleaseDto release); + /// The release carried by a subclass row, for keying the action-state lookup. + protected abstract ReleaseDto ReleaseOf(TRow row); + protected List Rows { get; private set; } = new(); protected bool Loading { get; private set; } = true; + // Bare release projection handed to the rich grid. The grid does the expand/delete/edit/Type-chip; + // it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference. + protected IReadOnlyList Releases { get; private set; } = Array.Empty(); + + // release.Id → action-state row, so a RowActions fragment (which the grid hands a ReleaseDto) can + // recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind. + private Dictionary _rowsById = new(); + protected override async Task OnInitializedAsync() => await LoadAsync(); + /// Recovers the action-state row for a release the rich grid is rendering. Null if the + /// release is not in the current page (e.g. just deleted), in which case the action is skipped. + protected TRow? RowFor(ReleaseDto release) => + _rowsById.TryGetValue(release.Id, out var row) ? row : null; + + /// + /// Reloads the medium-filtered release list. Wired to the rich grid's OnReleasesChanged so a + /// delete re-fetches the authoritative list (track counts, orphan cleanup) — the same single-load + /// posture CmsAllReleasesGrid uses for the ALL tab. + /// + protected async Task ReloadAsync() + { + await LoadAsync(); + StateHasChanged(); + } + private async Task LoadAsync() { Loading = true; @@ -53,6 +84,8 @@ public abstract class CmsMediumBrowserBase : ComponentBase Rows = new List(); } + Releases = Rows.Select(ReleaseOf).ToList(); + _rowsById = Rows.ToDictionary(r => ReleaseOf(r).Id); Loading = false; } diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor b/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor deleted file mode 100644 index 07e97e4..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor +++ /dev/null @@ -1,74 +0,0 @@ -@namespace DeepDrftManager.Components.Pages.Tracks -@typeparam TRow -@using DeepDrftModels.DTOs - -@* Shared table shell for the single-track medium browsers (Sessions, Mixes). Renders the cover - thumbnail, title, artist, and a shared Edit affordance that every medium gets (9.5.E). The - medium-specific cells (hero / waveform / generate-or-upload action) are supplied per row via the - ActionContent slot, which receives the subclass's typed row. Fully controlled by the parent: - loading and row state are passed in. *@ - -@if (Loading) -{ - -} -else if (Rows.Count == 0) -{ - @EmptyMessage -} -else -{ - - - Cover - @TitleHeader - Artist - Actions - - - - @{ var release = ReleaseAccessor(context); } - @if (!string.IsNullOrEmpty(release.ImagePath)) - { -
- } - else - { -
- } -
- @ReleaseAccessor(context).Title - @ReleaseAccessor(context).Artist - - - @ActionContent(context) - - - - - -
-
-} - -@code { - [Parameter] public required IReadOnlyList Rows { get; set; } - [Parameter] public bool Loading { get; set; } - - /// Projects a row to its underlying release for the cover/title/artist cells. - [Parameter] public required Func ReleaseAccessor { get; set; } - - /// Medium-specific cell content (hero / waveform / generate action) for each row. - [Parameter] public required RenderFragment ActionContent { get; set; } - - /// Relative thumbnail URL builder; the base class supplies its proxy-aware helper. - [Parameter] public required Func ThumbUrl { get; set; } - - /// Column header / data-label for the title column (e.g. "Session", "Mix"). - [Parameter] public string TitleHeader { get; set; } = "Title"; - - [Parameter] public string EmptyMessage { get; set; } = "Nothing here yet."; -} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor.css deleted file mode 100644 index 3b1ba83..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsMediumTable.razor.css +++ /dev/null @@ -1,14 +0,0 @@ -/* Cover-thumbnail idiom shared by the medium browsers' tables. Blazor CSS isolation is per-component, - so this scoped copy of the album-browser thumb classes reaches only this component's own markup. */ -.cms-album-thumb { - width: 40px; - height: 40px; - border-radius: 4px; - background-size: cover; - background-position: center; - flex-shrink: 0; -} - -.cms-album-thumb--fallback { - background-color: var(--mud-palette-action-default-hover); -} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor index 6be116d..3faacf9 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor @@ -6,10 +6,11 @@ @inject ILogger Logger @* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at - /tracks/mixes for direct-URL access. When embedded, the page chrome (title, container, the now- - meaningless "Back to Release Archive" button) is suppressed — the host tab strip owns that frame; only - the grid renders. The standalone route keeps the full page chrome. The per-row waveform affordance - (9.5.E) is preserved in both contexts. *@ + /tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C + parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as + its medium-specific RowActions slot so that affordance survives the move off the thin table. When + embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is + suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved in both. *@ @if (Embedded) { @GridContent @@ -48,45 +49,49 @@ else HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey) }; + protected override ReleaseDto ReleaseOf(MixRow row) => row.Release; + // The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so - // both branches above render the same markup without duplication. - private RenderFragment GridContent => @ - - @if (row.HasWaveform) + // both branches above render the same markup without duplication. The waveform generate is the Mix's + // medium-specific RowActions content; the grid hands it each release, and RowFor recovers the + // matching MixRow's generate state. + private RenderFragment GridContent => @ + + @{ var row = RowFor(release); } + @if (row is not null) { - - - - } - else - { - - - - } - - @if (row.IsGenerating) + @if (row.HasWaveform) { - - Generating… + + + } else { - @(row.HasWaveform ? "Regenerate" : "Generate") + + + } - - - ; + + @if (row.IsGenerating) + { + + Generating… + } + else + { + @(row.HasWaveform ? "Regenerate" : "Generate") + } + + } + +
; private async Task GenerateWaveformAsync(MixRow row) { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor index a6bb8b0..927a3fe 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor @@ -7,10 +7,11 @@ @inject ILogger Logger @* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at - /tracks/sessions for direct-URL access. When embedded, the page chrome (title, container, the now- - meaningless "Back to Release Archive" button) is suppressed — the host tab strip owns that frame; only - the grid renders. The standalone route keeps the full page chrome. The per-row hero affordance (9.5.E) - is preserved in both contexts. *@ + /tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions + (§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied + as its medium-specific RowActions slot so that affordance survives the move off the thin table. When + embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is + suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in both contexts. *@ @if (Embedded) { @GridContent @@ -49,47 +50,51 @@ else HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey }; + protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release; + // The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so - // both branches above render the same markup without duplication. - private RenderFragment GridContent => @ - - @if (row.HeroImageEntryKey is { Length: > 0 } heroKey) + // both branches above render the same markup without duplication. The hero upload is the Session's + // medium-specific RowActions content; the grid hands it each release, and RowFor recovers the + // matching SessionRow's upload state. + private RenderFragment GridContent => @ + + @{ var row = RowFor(release); } + @if (row is not null) { -
- } - else - { -
- } - - - 0 } heroKey) + { +
+ } + else + { +
+ } + - @if (row.IsUploading) - { - - Uploading… - } - else - { - @(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero") - } -
-
-
-
-
; + + + @if (row.IsUploading) + { + + Uploading… + } + else + { + @(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero") + } + + + + } + + ; private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file) { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css index ed71232..95ee2ee 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css +++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css @@ -1,6 +1,7 @@ -/* Hero-thumbnail idiom for the session row's action cell. The cover thumb lives in CmsMediumTable's - own scoped CSS; this scoped copy reaches only the hero
rendered in this component's - ActionContent markup (Blazor CSS isolation is per-component). */ +/* Hero-thumbnail idiom for the session row's action cell. The hero
is authored in this + component's RowActions fragment, so Blazor stamps it with this component's scope attribute even + though CmsAlbumBrowser renders it — this scoped copy reaches it (CSS isolation follows authoring + component, not rendering host). The grid's own cover thumb lives in CmsAlbumBrowser's scoped CSS. */ .cms-album-thumb { width: 40px; height: 40px;