feat(cms): add expandable Album browser to Track Browser
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsAlbumBrowser> Logger
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="AlbumRow"
|
||||
Items="_rows"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
<MudTh Style="width: 1%;">Art</MudTh>
|
||||
<MudTh>Album</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh>Genre</MudTh>
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => ToggleExpand(context))" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@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="Album">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@context.Release.ReleaseType</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete release">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Disabled="@context.IsDeleting"
|
||||
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Typo="Typo.body2">Loading tracks…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (context.Tracks is { Count: 0 })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
|
||||
}
|
||||
else if (context.Tracks is not null)
|
||||
{
|
||||
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
|
||||
Elevation="0" Style="background: transparent;">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudTd>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
|
||||
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is
|
||||
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
|
||||
// back. We only re-project when the parent hands us a genuinely new list.
|
||||
private IReadOnlyList<ReleaseDto>? _projectedReleases;
|
||||
|
||||
// The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated,
|
||||
// so the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||
private Uri? _contentApiBase;
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
|
||||
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
|
||||
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
|
||||
// same cached VM.Albums instance.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_projectedReleases, Releases))
|
||||
{
|
||||
_projectedReleases = Releases;
|
||||
_rows = Releases.Select(r => new AlbumRow { Release = r }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private string? ThumbUrl(string imagePath) =>
|
||||
_contentApiBase is null
|
||||
? null
|
||||
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
if (row.IsExpanded && row.Tracks is null && !row.IsLoading)
|
||||
{
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Albums are small releases; a single page of 100 always covers the full track list (see brief).
|
||||
private async Task<List<TrackDto>> LoadTracksAsync(string albumTitle)
|
||||
{
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: albumTitle);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
return result.Value.Items.ToList();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error);
|
||||
return new List<TrackDto>();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDeleteAlbum(AlbumRow row)
|
||||
{
|
||||
// Need track IDs to delete; load them if the row was never expanded.
|
||||
row.Tracks ??= await LoadTracksAsync(row.Release.Title);
|
||||
var tracks = row.Tracks;
|
||||
var count = tracks.Count;
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
Snackbar.Add($"'{row.Release.Title}' has no tracks to delete.", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"Delete all <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? This removes metadata and audio for every track."),
|
||||
yesText: "Delete all",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var failures = 0;
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var del = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (!del.Success) failures++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class AlbumRow
|
||||
{
|
||||
public required ReleaseDto Release { get; init; }
|
||||
public List<TrackDto>? Tracks { get; set; } // null = not yet loaded
|
||||
public bool IsExpanded { get; set; }
|
||||
public bool IsLoading { get; set; }
|
||||
public bool IsDeleting { get; set; }
|
||||
|
||||
// Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load.
|
||||
public int TrackCount => Release.TrackCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.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);
|
||||
}
|
||||
@@ -53,7 +53,9 @@
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Albums)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">Album browser — coming in the next wave.</MudAlert>
|
||||
<CmsAlbumBrowser Releases="VM.Albums"
|
||||
IsLoading="VM.AlbumsLoading"
|
||||
OnReleasesChanged="OnAlbumsChanged" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -64,6 +66,10 @@
|
||||
@code {
|
||||
private CmsTrackGrid? _grid;
|
||||
|
||||
// The album browser owns its own row state and removes a deleted release locally. We only need to
|
||||
// re-render the page chrome; VM.Albums is intentionally not re-fetched (cached for the circuit).
|
||||
private void OnAlbumsChanged() => StateHasChanged();
|
||||
|
||||
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
|
||||
Reference in New Issue
Block a user