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,6 +5,17 @@
@attribute [Authorize] @attribute [Authorize]
@inject ILogger<CmsMixBrowser> Logger @inject ILogger<CmsMixBrowser> 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. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle> <PageTitle>Mixes — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@@ -17,7 +28,29 @@
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText> <MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
<CmsMediumTable TRow="MixRow" @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" Rows="Rows"
Loading="Loading" Loading="Loading"
ReleaseAccessor="@(row => row.Release)" ReleaseAccessor="@(row => row.Release)"
@@ -53,18 +86,7 @@
} }
</MudButton> </MudButton>
</ActionContent> </ActionContent>
</CmsMediumTable> </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)
};
private async Task GenerateWaveformAsync(MixRow row) private async Task GenerateWaveformAsync(MixRow row)
{ {
@@ -6,6 +6,17 @@
@attribute [Authorize] @attribute [Authorize]
@inject ILogger<CmsSessionBrowser> Logger @inject ILogger<CmsSessionBrowser> 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. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle> <PageTitle>Sessions — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@@ -18,7 +29,29 @@
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText> <MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
<CmsMediumTable TRow="SessionRow" @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" Rows="Rows"
Loading="Loading" Loading="Loading"
ReleaseAccessor="@(row => row.Release)" ReleaseAccessor="@(row => row.Release)"
@@ -56,18 +89,7 @@
</ActivatorContent> </ActivatorContent>
</MudFileUpload> </MudFileUpload>
</ActionContent> </ActionContent>
</CmsMediumTable> </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
};
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file) 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/genres"
@page "/tracks/archive" @page "/tracks/archive"
@using DeepDrftManager.Services @using DeepDrftManager.Services
@using DeepDrftModels.Enums
@inject CmsTrackBrowserViewModel VM @inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -36,6 +37,9 @@
} }
</MudStack> </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" <MudToggleGroup T="BrowseMode"
Value="VM.Mode" Value="VM.Mode"
ValueChanged="OnModeChanged" ValueChanged="OnModeChanged"
@@ -45,7 +49,6 @@
Class="mb-4"> Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem> <MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem> <MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
<MudToggleItem Value="BrowseMode.Archive">Release Archive</MudToggleItem>
</MudToggleGroup> </MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks) @if (VM.Mode == BrowseMode.Tracks)
@@ -54,15 +57,21 @@
} }
else if (VM.Mode == BrowseMode.Albums) 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 @* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
and refresh, so the host renders it with no parameters. The 8.A tab strip hosts this same The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
component as its ALL tab. Genre mode still uses the VM cache below; only album loading moved entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
into the component, so VM.Albums / VM.AlbumsLoading are no longer read here. *@ 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" /> <CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
} </MudTabPanel>
else if (VM.Mode == BrowseMode.Archive) @foreach (var medium in Enum.GetValues<ReleaseMedium>())
{ {
<ReleaseArchiveBrowser /> <MudTabPanel Text="@MediumTabLabels[medium]">
@MediumGrid(medium)
</MudTabPanel>
}
</MudTabs>
} }
else else
{ {
@@ -78,6 +87,29 @@
@code { @code {
private CmsTrackGrid? _grid; 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 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. // the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged() private void OnAlbumsChanged()
@@ -93,10 +125,12 @@
protected override async Task OnInitializedAsync() 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 uri = NavigationManager.Uri;
var initial = var initial =
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums 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 : uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks; : BrowseMode.Tracks;
await VM.SwitchModeAsync(initial); await VM.SwitchModeAsync(initial);
@@ -108,7 +142,6 @@
var path = mode switch var path = mode switch
{ {
BrowseMode.Albums => "/tracks/albums", BrowseMode.Albums => "/tracks/albums",
BrowseMode.Archive => "/tracks/archive",
BrowseMode.Genres => "/tracks/genres", BrowseMode.Genres => "/tracks/genres",
_ => "/tracks" _ => "/tracks"
}; };
@@ -6,8 +6,10 @@ namespace DeepDrftManager.Services;
public enum BrowseMode public enum BrowseMode
{ {
Tracks, Tracks,
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
Albums, Albums,
Archive,
Genres, Genres,
} }