diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor
index a6b018b..c80127b 100644
--- a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor
+++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor
@@ -33,6 +33,10 @@ else
Release Date
Type
Tracks
+ @foreach (var column in SpecialColumns)
+ {
+ @column.Header
+ }
Actions
@@ -64,11 +68,14 @@ else
@context.TrackCount
+ @foreach (var column in SpecialColumns)
+ {
+ @* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
+ The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
+ Tracks and Actions so the universal Edit/Delete stay rightmost. *@
+ @column.Cell(context.Release)
+ }
- @* 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)
-
+
@if (context.IsLoading)
{
@@ -126,12 +133,19 @@ else
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; }
- // Optional per-row, medium-specific action slot (Session hero upload, Mix waveform generate),
- // rendered in the Actions cell ahead of the shared edit/delete buttons. The ALL tab leaves it
- // unset and renders the grid exactly as before. A per-medium host (CmsCut/Session/MixBrowser)
- // supplies it so the rich grid filtered to one medium keeps that medium's bespoke affordance —
- // the rich expand/delete/Type-chip/edit logic stays here, single-sourced, rather than forked.
- [Parameter] public RenderFragment? RowActions { get; set; }
+ // Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
+ // generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
+ // columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
+ // columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
+ // expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
+ [Parameter] public IReadOnlyList SpecialColumns { get; set; } = Array.Empty();
+
+ // Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
+ private const int BaseColumnCount = 9;
+
+ // Total rendered columns, driving the expanded child-row colspan so it always spans the full table
+ // regardless of how many special-action columns the host declared.
+ private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
private List _rows = new();
diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor
index 4e6ced7..b2c23db 100644
--- a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor
+++ b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor
@@ -4,8 +4,8 @@
@* 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. *@
+ forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
+ its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs b/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs
index 72b15b0..0d93a73 100644
--- a/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs
+++ b/DeepDrftManager/Components/Pages/Tracks/CmsMediumBrowserBase.cs
@@ -13,7 +13,7 @@ namespace DeepDrftManager.Components.Pages.Tracks;
/// 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 .
+/// rich grid's SpecialColumns column model, looking their action-state row up with .
///
/// The subclass's row model wrapping a plus its
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
@@ -42,8 +42,8 @@ public abstract class CmsMediumBrowserBase : ComponentBase where TRow : cl
// 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.
+ // release.Id → action-state row, so a SpecialColumns cell delegate (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();
diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor
index 3faacf9..d291411 100644
--- a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor
+++ b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor
@@ -8,9 +8,10 @@
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
/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. *@
+ its medium-specific special-action column 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
@@ -52,46 +53,59 @@ else
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. 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.
+ // both branches above render the same markup without duplication. The Mix declares one dedicated
+ // "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
+ // each release, and RowFor recovers the matching MixRow's generate state.
private RenderFragment GridContent => @
-
- @{ var row = RowFor(release); }
- @if (row is not null)
+ OnReleasesChanged="ReloadAsync"
+ SpecialColumns="_specialColumns" />;
+
+ // Allocated once per component instance in OnInitialized (field initializers cannot reference
+ // instance members, so initialization is deferred to the first lifecycle hook).
+ private IReadOnlyList _specialColumns = Array.Empty();
+
+ protected override void OnInitialized()
+ {
+ _specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
+ base.OnInitialized();
+ }
+
+ // Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
+ // progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
+ private RenderFragment WaveformCell => release =>@
+ @{ var row = RowFor(release); }
+ @if (row is not null)
+ {
+ @if (row.HasWaveform)
{
- @if (row.HasWaveform)
+
+
+
+ }
+ else
+ {
+
+
+
+ }
+
+ @if (row.IsGenerating)
{
-
-
-
+
+ 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 927a3fe..7e56266 100644
--- a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor
+++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor
@@ -9,9 +9,10 @@
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
/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. *@
+ as its medium-specific special-action column 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
@@ -53,48 +54,61 @@ else
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. 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.
+ // both branches above render the same markup without duplication. The Session declares one dedicated
+ // "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
+ // release, and RowFor recovers the matching SessionRow's upload state.
private RenderFragment GridContent => @
-
- @{ var row = RowFor(release); }
- @if (row is not null)
+ OnReleasesChanged="ReloadAsync"
+ SpecialColumns="_specialColumns" />;
+
+ // Allocated once per component instance in OnInitialized (field initializers cannot reference
+ // instance members, so initialization is deferred to the first lifecycle hook).
+ private IReadOnlyList _specialColumns = Array.Empty();
+
+ protected override void OnInitialized()
+ {
+ _specialColumns = new[] { new SpecialActionColumn("Hero", HeroCell) };
+ base.OnInitialized();
+ }
+
+ // Per-row cell for the dedicated "Hero" column: thumbnail preview plus set/replace upload button with
+ // progress. Recovers the typed SessionRow via RowFor; skips rendering for a release not on the page.
+ private RenderFragment HeroCell => release =>@
+ @{ var row = RowFor(release); }
+ @if (row is not null)
+ {
+ @if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
{
- @if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
- {
-
- }
- else
- {
-
- }
-
-
-
- @if (row.IsUploading)
- {
-
- Uploading…
- }
- else
- {
- @(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")
- }
-
-
-
+
}
-
- ;
+ else
+ {
+
+ }
+
+
+
+ @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/SpecialActionColumn.cs b/DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs
new file mode 100644
index 0000000..689f67c
--- /dev/null
+++ b/DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs
@@ -0,0 +1,15 @@
+using DeepDrftModels.DTOs;
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftManager.Components.Pages.Tracks;
+
+///
+/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
+/// generate, Session hero upload) in . A per-medium host declares zero or
+/// more of these; the grid renders one extra header cell and one extra per-row cell for each, positioned
+/// between the Tracks column and the universal Actions (Edit/Delete) column. The
+/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
+///
+/// Column header label (e.g. "Waveform", "Hero").
+/// Per-row cell content for a given release.
+public sealed record SpecialActionColumn(string Header, RenderFragment Cell);