Merge cms-special-action-columns into dev
Promote CMS release special actions (Mix waveform, Session hero) to dedicated grid columns.
This commit is contained in:
@@ -33,6 +33,10 @@ else
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
|
||||
}
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -64,11 +68,14 @@ else
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
@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. *@
|
||||
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="Actions">
|
||||
@* 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)
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
@@ -88,7 +95,7 @@ else
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
<MudTd colspan="@ColumnCount" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
@@ -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<ReleaseDto>? 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<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
// 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<AlbumRow> _rows = new();
|
||||
|
||||
|
||||
@@ -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. *@
|
||||
<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync" />
|
||||
|
||||
@@ -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 <see cref="Medium"/>,
|
||||
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
|
||||
/// rich grid's <c>RowActions</c> slot, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// rich grid's <c>SpecialColumns</c> column model, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
|
||||
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
|
||||
@@ -42,8 +42,8 @@ public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : cl
|
||||
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
|
||||
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
|
||||
// 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<long, TRow> _rowsById = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
@@ -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 => @<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ 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<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
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<ReleaseDto> WaveformCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HasWaveform)
|
||||
{
|
||||
@if (row.HasWaveform)
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@row.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(row))">
|
||||
@if (row.IsGenerating)
|
||||
{
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@row.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(row))">
|
||||
@if (row.IsGenerating)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
</MudButton>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task GenerateWaveformAsync(MixRow row)
|
||||
{
|
||||
|
||||
@@ -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 => @<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ 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<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
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<ReleaseDto> HeroCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
|
||||
/// generate, Session hero upload) in <see cref="CmsAlbumBrowser"/>. 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 <see cref="Cell"/>
|
||||
/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
|
||||
/// </summary>
|
||||
/// <param name="Header">Column header label (e.g. "Waveform", "Hero").</param>
|
||||
/// <param name="Cell">Per-row cell content for a given release.</param>
|
||||
public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell);
|
||||
Reference in New Issue
Block a user