refactor(public): retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery (P11 W3 §4)

This commit is contained in:
daniel-c-harvey
2026-06-16 11:31:02 -04:00
parent d98ead97c3
commit ef6d21b94e
32 changed files with 70 additions and 1353 deletions
@@ -1,6 +1,6 @@
/* Single space-between row under the waveform: identity on the left, accents on the right.
Colours come from the MudBlazor theme (the dock surface is theme-aware), so unlike the
always-dark TrackCard glass we do not hard-code green-accent overrides here. */
Colours come from the MudBlazor theme (the dock surface is theme-aware), so we do not
hard-code green-accent overrides here. */
.track-meta-row {
display: flex;
align-items: center;
@@ -6,7 +6,7 @@
</p>
<div class="hero-actions @AnimClass">
<StreamNowButton ButtonClass="btn-primary" ButtonLabel="Start Streaming" />
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
<a class="btn-ghost" href="/archive">Browse Tracks</a>
</div>
@code {
@@ -1,7 +0,0 @@
namespace DeepDrftPublic.Client.Controls;
public enum GalleryViewMode
{
Grid,
List
}
@@ -38,7 +38,7 @@ public partial class PlayStateIcon : ComponentBase, IDisposable
{
// The cascade is IsFixed, so the provider's re-render never reaches us; subscribe to the
// multicast side-channel to re-render on every player state change. Reference-guarded so
// re-parametering is idempotent. Mirrors AudioPlayerBar / TracksView.
// re-parametering is idempotent. Mirrors AudioPlayerBar.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
@@ -2,7 +2,7 @@
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
a play/share affordance row wired to the streaming player, and slots for the medium-specific
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
hero visual and metadata block. The Cut/Session/Mix detail pages all compose this;
per-medium variance rides the Hero and MetaContent render fragments. *@
<div class="deepdrft-track-detail-container">
@@ -7,8 +7,8 @@ namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
/// TrackDetail page, which is now a thin consumer of this scaffold.
/// detail page supplies only its data and medium-specific visuals. Each medium's detail page is a
/// thin consumer of this scaffold.
/// </summary>
public partial class ReleaseDetailScaffold : ComponentBase
{
@@ -1,9 +1,14 @@
@namespace DeepDrftPublic.Client.Controls
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
Sessions and Mixes browse pages. Cuts intentionally do not use this they open the track
gallery filtered by album, a different navigation target. Fully controlled by the parent:
loading and item state are passed in. *@
@* The single release-card grid for every browse surface (Sessions, Mixes, Cuts, Archive). Cards
open a detail page; how a card computes its href is the only real divergence across surfaces, so
the parent supplies it one of two ways:
- DetailRoute (the simple default): every card links /{DetailRoute}/{id} (Sessions, Mixes).
- HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by
its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{id}.
HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver
overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item
state are passed in. *@
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
@@ -33,7 +38,7 @@
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
<a href="@CardHref(release)" class="release-card-link">
<div class="release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
@@ -51,7 +56,7 @@
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
@release.Artist
@(SubtitleResolver?.Invoke(release) ?? release.Artist)
</MudText>
</div>
</div>
@@ -68,8 +73,27 @@
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
[Parameter] public bool Loading { get; set; }
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
[Parameter] public required string DetailRoute { get; set; }
/// <summary>
/// Route segment for a card's detail page; a card links to /{DetailRoute}/{id}. The simple
/// fixed-route default used by Sessions/Mixes. Ignored when <see cref="HrefResolver"/> is supplied.
/// </summary>
[Parameter] public string? DetailRoute { get; set; }
/// <summary>
/// Per-card href resolver. When supplied, a card links to its result instead of the
/// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and
/// Cuts route to /cuts/{id} (both via <c>ReleaseRoutes.DetailHref</c>).
/// </summary>
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; }
/// <summary>
/// Optional override for a card's subtitle line (defaults to the release artist). Cuts pass a
/// track-count label here.
/// </summary>
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? SubtitleResolver { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
private string CardHref(DeepDrftModels.DTOs.ReleaseDto release)
=> HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.Id}";
}
@@ -1,213 +0,0 @@
@{
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
}
@if (ViewMode == GalleryViewMode.Grid)
{
<div class="deepdrft-track-card-container @(hasArt ? "deepdrft-track-card-container--art" : "")">
@* Cover and title/artist link to the detail page; the play button (below, outside any
anchor) stays the sole playback entry point. display:contents keeps the grid intact. *@
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-card-link">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-card-fallback"></div>
}
</a>
}
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-card-fallback"></div>
}
<div class="deepdrft-track-card-content">
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-card-link">
<div class="deepdrft-track-info-top">
<MudText Typo="Typo.subtitle1"
Class="deepdrft-track-title text-truncate mb-1">
@TrackModel?.TrackName
</MudText>
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Release?.Artist
</MudText>
</div>
</a>
}
else
{
<div class="deepdrft-track-info-top">
<MudText Typo="Typo.subtitle1"
Class="deepdrft-track-title text-truncate mb-1">
@TrackModel?.TrackName
</MudText>
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Release?.Artist
</MudText>
</div>
}
<div class="deepdrft-track-info-middle">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta text-truncate">
@TrackModel.Release!.Title
</MudText>
}
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
</div>
<div class="deepdrft-track-info-bottom">
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
else
{
<div></div>
}
<MudFab Color="Color.Tertiary"
Size="Size.Medium"
StartIcon="@PlayPauseIcon"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@PlayClick"/>
</div>
</div>
</div>
}
else
{
<div class="deepdrft-track-row @(IsPlaying ? "deepdrft-track-row--playing" : "")">
<MudFab Color="Color.Tertiary"
Size="Size.Medium"
StartIcon="@PlayPauseIcon"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@PlayClick"
Class="deepdrft-track-row-fab"/>
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-row-link">
@* art thumb *@
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
}
@* text block *@
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
</MudText>
</div>
@* right metadata *@
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
</a>
}
else
{
@* same structure without anchor *@
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
}
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
</MudText>
</div>
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
}
</div>
}
@@ -1,34 +0,0 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftPublic.Client.Controls;
public partial class TrackCard : ComponentBase
{
[Parameter] public required TrackDto TrackModel { get; set; }
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
[Parameter] public bool IsPlaying { get; set; } = false;
[Parameter] public bool IsPaused { get; set; } = false;
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
// Pause only when actively playing; every other state (idle, paused) reads as "press to play".
private bool IsActivelyPlaying => IsPlaying && !IsPaused;
private string PlayPauseIcon =>
IsActivelyPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
private async Task PlayClick()
{
if (IsActivelyPlaying)
{
if (OnPause.HasDelegate)
await OnPause.InvokeAsync(TrackModel);
}
else if (OnPlay.HasDelegate)
{
await OnPlay.InvokeAsync(TrackModel);
}
}
}
@@ -1,197 +0,0 @@
/* Container — transparent so the absolute-positioned fallback panel or album art
controls the card's background. Glass edge matches NowPlayingCard vocabulary. */
.deepdrft-track-card-container {
width: 250px;
height: 250px;
min-width: 250px;
position: relative;
overflow: hidden;
background: transparent;
border: 2px solid var(--mud-palette-secondary);
}
.deepdrft-track-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: brightness(0.7);
}
.deepdrft-track-card-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
background: linear-gradient(to top,
rgba(13, 27, 42, 0.75) 0%,
rgba(13, 27, 42, 0.35) 45%,
rgba(13, 27, 42, 0.00) 100%);
}
/* Fallback panel — solid navy, opaque so the card reads correctly on both
light and dark page backgrounds. Semi-transparent + blur washes out on white. */
.deepdrft-track-card-fallback {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: var(--deepdrft-navy-mid, #162437);
border: 1px solid rgba(250, 250, 248, 0.12);
}
/* Title: off-white — matches .np-title.
::deep required: MudText renders its own element, so Blazor isolation
won't stamp b-{hash} on it; ::deep pierces into child component output. */
::deep .deepdrft-track-title { color: var(--deepdrft-white, #FAFAF8); }
/* Artist: muted off-white — green reserved for interactive elements (FAB, chip). ::deep for same reason. */
::deep .deepdrft-track-artist { color: rgba(250, 250, 248, 0.55); }
/* Meta: muted off-white — matches .np-sub. ::deep for same reason. */
::deep .deepdrft-track-meta { color: rgba(250, 250, 248, 0.45); }
/* FAB always green-interactive — card is always dark glass regardless of page theme.
.mud-button-filled-tertiary specificity (0,1,0) in MudBlazor; our (0,1,1) wins. */
::deep .mud-button-filled-tertiary {
background-color: var(--deepdrft-green-interactive, #3aa163);
color: var(--deepdrft-white, #FAFAF8);
}
/* Genre chip always green-accent outline/text on the dark glass card. */
::deep .deepdrft-genre-chip.mud-chip-outlined {
border-color: var(--deepdrft-green-accent, #3D7A68);
color: var(--deepdrft-green-accent, #3D7A68);
}
::deep .deepdrft-genre-chip.mud-chip-color-tertiary {
color: var(--deepdrft-green-accent, #3D7A68);
}
.deepdrft-track-info-middle { margin: 8px 0; }
.deepdrft-track-info-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
@media (max-width: 480px) {
.deepdrft-track-card-container {
min-width: 200px;
width: 200px;
height: 200px;
}
}
/* ── Mode A: hover-reveal overlay (art cards only) ──────────────────────── */
/* Gate the hidden-at-rest rule on (a) art present and (b) a hover-capable pointer.
Fallback cards (no --art modifier) and touch devices always show the overlay. */
@media (hover: hover) and (pointer: fine) {
.deepdrft-track-card-container--art .deepdrft-track-card-content {
opacity: 0;
background: transparent;
transition: opacity 180ms ease, background-color 180ms ease;
}
.deepdrft-track-card-container--art:hover .deepdrft-track-card-content {
opacity: 1;
background: rgba(22, 36, 55, 0.82);
transition: opacity 180ms ease, background-color 180ms ease;
}
}
/* ── Mode B: list row ───────────────────────────────────────────────────── */
.deepdrft-track-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 80px;
padding: 8px 16px;
background: var(--mud-palette-surface);
border: 1px solid var(--mud-palette-divider);
border-radius: 4px;
box-sizing: border-box;
width: 100%;
}
.deepdrft-track-row-link {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
flex: 1 1 auto;
min-width: 0;
text-decoration: none;
color: inherit;
}
::deep .deepdrft-track-row-fab {
flex: 0 0 auto;
}
.deepdrft-track-row-thumb {
flex: 0 0 64px;
width: 64px;
height: 64px;
background-size: cover;
background-position: center;
border-radius: 2px;
}
.deepdrft-track-row-thumb--fallback {
background: var(--deepdrft-navy-mid);
border: 1px solid var(--mud-palette-divider);
}
.deepdrft-track-row-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.deepdrft-track-row-meta {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
@media (max-width: 480px) {
.deepdrft-track-row {
height: auto;
min-height: 72px;
padding: 8px 12px;
gap: 10px;
}
.deepdrft-track-row-thumb {
flex: 0 0 48px;
width: 48px;
height: 48px;
}
}
.deepdrft-track-row--playing {
border-left: 3px solid var(--deepdrft-green-interactive, #3aa163);
}
/* ── Mode B text: theme-aware overrides (navy on light / off-white on dark) ─ */
/* The global ::deep rules above hard-code off-white for the dark glass grid cards.
List rows use --mud-palette-surface as their background, so text must follow
the theme. These selectors have higher specificity (.deepdrft-track-row[b-hash]
vs plain [b-hash]) and win in the cascade. */
.deepdrft-track-row ::deep .deepdrft-track-title,
.deepdrft-track-row ::deep .deepdrft-track-artist,
.deepdrft-track-row ::deep .deepdrft-track-meta {
color: var(--mud-palette-text-primary);
}
@@ -1,36 +0,0 @@
@if (ViewMode == GalleryViewMode.Grid)
{
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var track in Tracks)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="deepdrft-track-gallery-item-center">
<TrackCard TrackModel="@track"
ViewMode="@ViewMode"
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
OnPlay="@HandlePlayClick"
OnPause="@HandlePauseClick"/>
</div>
</MudItem>
}
</MudGrid>
</MudContainer>
}
else
{
<MudContainer MaxWidth="MaxWidth.Large">
<div class="deepdrft-track-list">
@foreach (var track in Tracks)
{
<TrackCard TrackModel="@track"
ViewMode="@ViewMode"
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
OnPlay="@HandlePlayClick"
OnPause="@HandlePauseClick"/>
}
</div>
</MudContainer>
}
@@ -1,26 +0,0 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls;
public partial class TracksGallery : ComponentBase
{
[Parameter] public IEnumerable<TrackDto> Tracks { get; set; } = [];
// Controlled play-state inputs: the parent owns playback truth (the player service)
// and drives these. The gallery is presentational — it only matches by id to decide
// which card reflects the active state.
[Parameter] public TrackDto? ActiveTrack { get; set; }
[Parameter] public bool IsPlaying { get; set; }
[Parameter] public bool IsPaused { get; set; }
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
private Task HandlePlayClick(TrackDto track) =>
OnPlay.HasDelegate ? OnPlay.InvokeAsync(track) : Task.CompletedTask;
private Task HandlePauseClick(TrackDto track) =>
OnPause.HasDelegate ? OnPause.InvokeAsync(track) : Task.CompletedTask;
}
@@ -1,15 +0,0 @@
.tracks-gallery-container {
box-sizing: border-box;
}
.deepdrft-track-gallery-item-center {
display: flex;
justify-content: center;
}
.deepdrft-track-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}