Merge p9-w8-8a-tab-strip into dev (8.A: CMS Release Archive medium tab strip)

This commit is contained in:
daniel-c-harvey
2026-06-13 22:09:19 -04:00
7 changed files with 179 additions and 124 deletions
@@ -0,0 +1,31 @@
@inherits CmsMediumBrowserBase<CmsCutBrowser.CutRow>
@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. *@
<CmsMediumTable TRow="CutRow"
Rows="Rows"
Loading="Loading"
ReleaseAccessor="@(row => row.Release)"
ThumbUrl="@(key => ThumbUrl(key))"
TitleHeader="Cut"
EmptyMessage="No cuts found.">
<ActionContent Context="row">
</ActionContent>
</CmsMediumTable>
@code {
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
protected override string MediumNoun => "cuts";
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
public sealed class CutRow
{
public required ReleaseDto Release { get; set; }
}
}
@@ -5,25 +5,58 @@
@attribute [Authorize]
@inject ILogger<CmsMixBrowser> Logger
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
@* 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. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
<CmsMediumTable TRow="MixRow"
Rows="Rows"
Loading="Loading"
ReleaseAccessor="@(row => row.Release)"
ThumbUrl="@(key => ThumbUrl(key))"
TitleHeader="Mix"
EmptyMessage="No mixes found.">
@GridContent
</MudContainer>
}
@code {
/// <summary>
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
/// chrome (title, container, back button). False (default) renders the full routable page.
/// </summary>
[Parameter] public bool Embedded { get; set; }
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
protected override MixRow ToRow(ReleaseDto release) => new()
{
Release = release,
HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey)
};
// 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 => @<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)
{
@@ -53,18 +86,7 @@
}
</MudButton>
</ActionContent>
</CmsMediumTable>
</MudContainer>
@code {
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
protected override MixRow ToRow(ReleaseDto release) => new()
{
Release = release,
HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey)
};
</CmsMediumTable>;
private async Task GenerateWaveformAsync(MixRow row)
{
@@ -6,25 +6,58 @@
@attribute [Authorize]
@inject ILogger<CmsSessionBrowser> Logger
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
@* 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. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
<CmsMediumTable TRow="SessionRow"
Rows="Rows"
Loading="Loading"
ReleaseAccessor="@(row => row.Release)"
ThumbUrl="@(key => ThumbUrl(key))"
TitleHeader="Session"
EmptyMessage="No sessions found.">
@GridContent
</MudContainer>
}
@code {
/// <summary>
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
/// chrome (title, container, back button). False (default) renders the full routable page.
/// </summary>
[Parameter] public bool Embedded { get; set; }
protected override ReleaseMedium Medium => ReleaseMedium.Session;
protected override string MediumNoun => "sessions";
protected override SessionRow ToRow(ReleaseDto release) => new()
{
Release = release,
HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey
};
// 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 => @<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)
{
@@ -56,18 +89,7 @@
</ActivatorContent>
</MudFileUpload>
</ActionContent>
</CmsMediumTable>
</MudContainer>
@code {
protected override ReleaseMedium Medium => ReleaseMedium.Session;
protected override string MediumNoun => "sessions";
protected override SessionRow ToRow(ReleaseDto release) => new()
{
Release = release,
HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey
};
</CmsMediumTable>;
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
{
@@ -1,37 +0,0 @@
@using DeepDrftModels.Enums
@inject NavigationManager Navigation
@* Release Archive: one card per ReleaseMedium, driven off Enum.GetValues + a display-metadata table.
No hardcoded three-arm switch in markup — adding a medium surfaces a new card automatically and only
needs one new entry in MediumCards below. Card idiom mirrors CmsGenreBrowser (MudCard + swatch). *@
<MudGrid Spacing="3" Class="mt-2">
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
var info = MediumCards[medium];
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="1"
Style="cursor: pointer;"
@onclick="@(() => Navigation.NavigateTo(info.Route))">
<div class="@($"cms-medium-swatch cms-medium-swatch--{info.SwatchModifier}")"></div>
<MudCardContent>
<MudText Typo="Typo.h6">@info.Label</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@info.Descriptor</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@code {
private sealed record MediumCardInfo(string Label, string Descriptor, string SwatchModifier, string Route);
// The one place medium → display + navigation target lives. A future medium adds one entry here;
// the markup above is untouched. The enum→record dictionary is a switch, data-structured (§3.1).
private static readonly IReadOnlyDictionary<ReleaseMedium, MediumCardInfo> MediumCards =
new Dictionary<ReleaseMedium, MediumCardInfo>
{
[ReleaseMedium.Cut] = new("Cuts", "Studio singles, EPs, and albums", "cut", "/tracks/albums"),
[ReleaseMedium.Session] = new("Sessions", "Single-track live recordings", "session", "/tracks/sessions"),
[ReleaseMedium.Mix] = new("Mixes", "Single-track DJ mixes", "mix", "/tracks/mixes"),
};
}
@@ -1,18 +0,0 @@
.cms-medium-swatch {
width: 100%;
height: 80px;
background-color: var(--mud-palette-action-default-hover);
transition: background-color 0.2s ease;
}
.cms-medium-swatch--cut {
background-color: var(--mud-palette-primary-hover);
}
.cms-medium-swatch--session {
background-color: var(--mud-palette-secondary-hover);
}
.cms-medium-swatch--mix {
background-color: var(--mud-palette-tertiary-hover);
}
@@ -3,6 +3,7 @@
@page "/tracks/genres"
@page "/tracks/archive"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@@ -36,6 +37,9 @@
}
</MudStack>
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
<MudToggleGroup T="BrowseMode"
Value="VM.Mode"
ValueChanged="OnModeChanged"
@@ -45,7 +49,6 @@
Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
<MudToggleItem Value="BrowseMode.Archive">Release Archive</MudToggleItem>
</MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks)
@@ -54,15 +57,21 @@
}
else if (VM.Mode == BrowseMode.Albums)
{
@* The all-releases grid is now a self-contained component (Phase 9 §8.B): it owns its own load
and refresh, so the host renders it with no parameters. The 8.A tab strip hosts this same
component as its ALL tab. Genre mode still uses the VM cache below; only album loading moved
into the component, so VM.Albums / VM.AlbumsLoading are no longer read here. *@
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
}
else if (VM.Mode == BrowseMode.Archive)
{
<ReleaseArchiveBrowser />
@* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
tab swaps the grid below in place; no navigation to a separate page occurs. *@
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4">
<MudTabPanel Text="ALL">
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
</MudTabPanel>
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudTabPanel Text="@MediumTabLabels[medium]">
@MediumGrid(medium)
</MudTabPanel>
}
</MudTabs>
}
else
{
@@ -78,6 +87,29 @@
@code {
private CmsTrackGrid? _grid;
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Cut] = "CUTS",
[ReleaseMedium.Session] = "SESSIONS",
[ReleaseMedium.Mix] = "MIXES",
};
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
{
ReleaseMedium.Cut => @<CmsCutBrowser />,
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
};
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged()
@@ -93,10 +125,12 @@
protected override async Task OnInitializedAsync()
{
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
var uri = NavigationManager.Uri;
var initial =
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Archive
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks;
await VM.SwitchModeAsync(initial);
@@ -108,7 +142,6 @@
var path = mode switch
{
BrowseMode.Albums => "/tracks/albums",
BrowseMode.Archive => "/tracks/archive",
BrowseMode.Genres => "/tracks/genres",
_ => "/tracks"
};
@@ -6,8 +6,10 @@ namespace DeepDrftManager.Services;
public enum BrowseMode
{
Tracks,
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
Albums,
Archive,
Genres,
}