diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor new file mode 100644 index 0000000..2438673 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor @@ -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 Logger + +@if (IsLoading) +{ + +} +else if (_rows.Count == 0) +{ + No releases found. +} +else +{ + + + + Art + Album + Artist + Genre + Release Date + Type + Tracks + Actions + + + + + + + @if (!string.IsNullOrEmpty(context.Release.ImagePath)) + { +
+ } + else + { +
+ } +
+ @context.Release.Title + @context.Release.Artist + @(context.Release.Genre ?? "—") + @(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—") + + @context.Release.ReleaseType + + @context.TrackCount + + + + + + + + +
+ + @if (context.IsExpanded) + { + + + @if (context.IsLoading) + { + + + Loading tracks… + + } + else if (context.Tracks is { Count: 0 }) + { + No tracks found. + } + else if (context.Tracks is not null) + { + + + # + Track Name + + + @track.TrackNumber + @track.TrackName + + + } + + + } + +
+} + +@code { + [Parameter] public IReadOnlyList Releases { get; set; } = Array.Empty(); + [Parameter] public bool IsLoading { get; set; } + [Parameter] public EventCallback OnReleasesChanged { get; set; } + + private List _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? _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> 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(); + } + + 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 {count} track(s) in {WebUtility.HtmlEncode(row.Release.Title)}? 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? 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; + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor.css new file mode 100644 index 0000000..45f4d79 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor.css @@ -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); +} diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 991098a..9db5052 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -53,7 +53,9 @@ } else if (VM.Mode == BrowseMode.Albums) { - Album browser — coming in the next wave. + } 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;