Add Edit action to medium browsers; extract CmsMediumBrowserBase + CmsMediumTable

Session/Mix browsers share base (load/state/thumb) and a shared table shell carrying the per-row Edit link to BatchEdit; subclasses supply only their medium action.
This commit is contained in:
daniel-c-harvey
2026-06-13 11:08:43 -04:00
parent ea018beb3e
commit a7e2335c20
7 changed files with 245 additions and 226 deletions
@@ -0,0 +1,62 @@
using DeepDrftManager.Services;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftManager.Components.Pages.Tracks;
/// <summary>
/// Shared fetch + state logic for the single-track medium browsers (Sessions, Mixes). Analogous to the
/// public-site <c>MediumBrowseBase</c>: subclasses supply the <see cref="Medium"/>, the noun used in
/// error text, and a per-row projection from <see cref="ReleaseDto"/> 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 <c>CmsMediumTable</c>; subclasses render it and fill only the action column.
/// </summary>
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/>.</typeparam>
public abstract class CmsMediumBrowserBase<TRow> : ComponentBase
{
[Inject] public required ICmsReleaseService CmsReleaseService { get; set; }
[Inject] public required ISnackbar Snackbar { get; set; }
/// <summary>The medium this browser lists. Subclass-supplied constant.</summary>
protected abstract ReleaseMedium Medium { get; }
/// <summary>Plural noun for this medium used in error text (e.g. "sessions", "mixes").</summary>
protected abstract string MediumNoun { get; }
/// <summary>Projects a fetched release into the subclass's row model.</summary>
protected abstract TRow ToRow(ReleaseDto release);
protected List<TRow> Rows { get; private set; } = new();
protected bool Loading { get; private set; } = true;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
Loading = true;
// Single-track releases; a single generous page covers the CMS catalogue (same small-catalogue
// assumption the album browser makes).
var result = await CmsReleaseService.GetPagedAsync(
Medium, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
Rows = result.Value.Items.Select(ToRow).ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load {MediumNoun}: {error}", Severity.Error);
Rows = new List<TRow>();
}
Loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
protected static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
}
@@ -0,0 +1,74 @@
@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)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (Rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">@EmptyMessage</MudText>
}
else
{
<MudTable T="TRow" Items="Rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;">Cover</MudTh>
<MudTh>@TitleHeader</MudTh>
<MudTh>Artist</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Cover">
@{ var release = ReleaseAccessor(context); }
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(release.ImagePath)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="@TitleHeader">@ReleaseAccessor(context).Title</MudTd>
<MudTd DataLabel="Artist">@ReleaseAccessor(context).Artist</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@ActionContent(context)
<MudTooltip Text="Edit release">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/album/{Uri.EscapeDataString(ReleaseAccessor(context).Title)}/edit")" />
</MudTooltip>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
[Parameter] public required IReadOnlyList<TRow> Rows { get; set; }
[Parameter] public bool Loading { get; set; }
/// <summary>Projects a row to its underlying release for the cover/title/artist cells.</summary>
[Parameter] public required Func<TRow, ReleaseDto> ReleaseAccessor { get; set; }
/// <summary>Medium-specific cell content (hero / waveform / generate action) for each row.</summary>
[Parameter] public required RenderFragment<TRow> ActionContent { get; set; }
/// <summary>Relative thumbnail URL builder; the base class supplies its proxy-aware helper.</summary>
[Parameter] public required Func<string, string> ThumbUrl { get; set; }
/// <summary>Column header / data-label for the title column (e.g. "Session", "Mix").</summary>
[Parameter] public string TitleHeader { get; set; } = "Title";
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet.";
}
@@ -0,0 +1,14 @@
/* 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);
}
@@ -1,10 +1,8 @@
@page "/tracks/mixes"
@using DeepDrftManager.Services
@inherits CmsMediumBrowserBase<CmsMixBrowser.MixRow>
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ICmsReleaseService CmsReleaseService
@inject ISnackbar Snackbar
@inject ILogger<CmsMixBrowser> Logger
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
@@ -19,109 +17,54 @@
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No mixes found.</MudText>
}
else
{
<MudTable T="MixRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;">Cover</MudTh>
<MudTh>Mix</MudTh>
<MudTh>Artist</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Cover">
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Mix">@context.Release.Title</MudTd>
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
<MudTd DataLabel="Waveform">
@if (context.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>
}
</MudTd>
<MudTd DataLabel="Actions">
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@context.IsGenerating"
OnClick="@(() => GenerateWaveformAsync(context))">
@if (context.IsGenerating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>@(context.HasWaveform ? "Regenerate" : "Generate")</span>
}
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
<CmsMediumTable TRow="MixRow"
Rows="Rows"
Loading="Loading"
ReleaseAccessor="@(row => row.Release)"
ThumbUrl="@(key => ThumbUrl(key))"
TitleHeader="Mix"
EmptyMessage="No mixes found.">
<ActionContent Context="row">
@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)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
}
</MudButton>
</ActionContent>
</CmsMediumTable>
</MudContainer>
@code {
private List<MixRow> _rows = new();
private bool _loading = true;
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
protected override MixRow ToRow(ReleaseDto release) => new()
{
_loading = true;
// Mixes are single-track releases; a single generous page covers the CMS catalogue.
var result = await CmsReleaseService.GetPagedAsync(
ReleaseMedium.Mix, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
_rows = result.Value.Items
.Select(r => new MixRow
{
Release = r,
HasWaveform = !string.IsNullOrEmpty(r.MixMetadata?.WaveformEntryKey)
})
.ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load mixes: {error}", Severity.Error);
_rows = new List<MixRow>();
}
_loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
Release = release,
HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey)
};
private async Task GenerateWaveformAsync(MixRow row)
{
@@ -156,7 +99,7 @@
}
}
private sealed class MixRow
public sealed class MixRow
{
public required ReleaseDto Release { get; set; }
public bool HasWaveform { get; set; }
@@ -1,15 +0,0 @@
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
intentional duplication rather than promoting a two-rule block to global app.css. */
.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);
}
@@ -1,13 +1,10 @@
@page "/tracks/sessions"
@using DeepDrftManager.Services
@inherits CmsMediumBrowserBase<CmsSessionBrowser.SessionRow>
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsReleaseService CmsReleaseService
@inject ISnackbar Snackbar
@inject ILogger<CmsSessionBrowser> Logger
@inject NavigationManager Navigation
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
@@ -21,112 +18,56 @@
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No sessions found.</MudText>
}
else
{
<MudTable T="SessionRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;">Cover</MudTh>
<MudTh Style="width: 1%;">Hero</MudTh>
<MudTh>Session</MudTh>
<MudTh>Artist</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Cover">
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Hero">
@if (context.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>
}
</MudTd>
<MudTd DataLabel="Session">@context.Release.Title</MudTd>
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
<MudTd DataLabel="Actions">
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(context, file))"
Disabled="@context.IsUploading">
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Image"
Disabled="@context.IsUploading">
@if (context.IsUploading)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Uploading…</span>
}
else
{
<span>@(context.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
}
</MudButton>
</ActivatorContent>
</MudFileUpload>
</MudTd>
</RowTemplate>
</MudTable>
}
<CmsMediumTable TRow="SessionRow"
Rows="Rows"
Loading="Loading"
ReleaseAccessor="@(row => row.Release)"
ThumbUrl="@(key => ThumbUrl(key))"
TitleHeader="Session"
EmptyMessage="No sessions found.">
<ActionContent Context="row">
@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>
</ActionContent>
</CmsMediumTable>
</MudContainer>
@code {
private List<SessionRow> _rows = new();
private bool _loading = true;
protected override ReleaseMedium Medium => ReleaseMedium.Session;
protected override string MediumNoun => "sessions";
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
protected override SessionRow ToRow(ReleaseDto release) => new()
{
_loading = true;
// Sessions are single-track releases; a single generous page covers the CMS catalogue (same
// small-catalogue assumption the album browser makes).
var result = await CmsReleaseService.GetPagedAsync(
ReleaseMedium.Session, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
_rows = result.Value.Items
.Select(r => new SessionRow
{
Release = r,
HeroImageEntryKey = r.SessionMetadata?.HeroImageEntryKey
})
.ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load sessions: {error}", Severity.Error);
_rows = new List<SessionRow>();
}
_loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
Release = release,
HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey
};
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
{
@@ -169,7 +110,7 @@
}
}
private sealed class SessionRow
public sealed class SessionRow
{
public required ReleaseDto Release { get; set; }
public string? HeroImageEntryKey { get; set; }
@@ -1,6 +1,6 @@
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
intentional duplication rather than promoting a two-rule block to global app.css. */
/* 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 <div> rendered in this component's
ActionContent markup (Blazor CSS isolation is per-component). */
.cms-album-thumb {
width: 40px;
height: 40px;