Files
deepdrft/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor
T

279 lines
11 KiB
Plaintext

@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@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;
// 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();
}
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string imagePath) =>
$"/api/image/{Uri.EscapeDataString(imagePath)}";
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)
{
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
// cannot be cleared by deleting tracks. Delete the release record directly instead.
await ConfirmAndDeleteEmptyReleaseAsync(row);
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);
await OnReleasesChanged.InvokeAsync();
}
StateHasChanged();
}
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
// parent so the cached VM.Albums stays in sync with what is shown.
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete release",
markupMessage: new MarkupString(
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
row.IsDeleting = true;
StateHasChanged();
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
row.IsDeleting = false;
if (result.Success)
{
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
_rows.Remove(row);
await OnReleasesChanged.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
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;
}
}