refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate

Replace manual album state management (inline AlbumCard loop, own
infinite scroll, own sentinel/observer, _ = ReloadAlbums() discard
lambdas) with <AlbumGrid> using a new FetchAlbums delegate parameter.

- AlbumGrid.razor: add FetchAlbums Func parameter; use it in
  Reload()/LoadMoreAlbums() when set
- CosplayerDetail.razor: replace manual album rendering + state with
  <AlbumGrid FetchAlbums=@FetchPersonAlbums />; remove all album
  state management fields (allAlbumsDict, currentAlbumPage, etc.),
  ReloadAlbums/LoadMoreAlbums/OnAfterRenderAsync methods, and
  IAsyncDisposable; SortFilterBar now uses clean lambdas
- CosplayerDetail.razor.css: remove unused album-grid overrides

Closes #120
This commit is contained in:
2026-07-14 12:00:38 +02:00
parent f7d6348ea9
commit 7a380d7df5
2 changed files with 28 additions and 96 deletions

View File

@@ -4,14 +4,11 @@
@using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization
@implements IAsyncDisposable
@inject PersonService personService
@inject AlbumService albumService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject NavigationManager navigationManager
@inject IJSRuntime jsRuntime
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
@@ -71,15 +68,15 @@
</div>
<SortFilterBar SearchQuery="@albumSearchQuery"
SearchQueryChanged="@((string? v) => { albumSearchQuery = v; _ = ReloadAlbums(); })"
SearchQueryChanged="@((string? v) => albumSearchQuery = v)"
SortBy="@albumSortBy"
SortByChanged="@((string? v) => { albumSortBy = v; _ = ReloadAlbums(); })"
SortByChanged="@((string? v) => albumSortBy = v)"
SortAsc="@albumSortAsc"
SortAscChanged="@((bool v) => { albumSortAsc = v; _ = ReloadAlbums(); })"
SortAscChanged="@((bool v) => albumSortAsc = v)"
SortOptions="@albumSortOptions">
<ActionButtons>
<div class="d-flex gap-2 align-items-start">
@if (allAlbumsDict.Count > 0) {
@if (person.TotalAlbums > 0) {
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
@@ -95,7 +92,7 @@
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
@if (allAlbumsDict.Count > 0) {
@if (person.TotalAlbums > 0) {
if (selectMode) {
<button class="btn btn-warning btn-sm" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
@@ -117,24 +114,15 @@
</ActionButtons>
</SortFilterBar>
@if (allAlbumsDict.Count > 0) {
<div class="@("album-grid " + viewMode)">
@foreach (var album in allAlbumsDict.Values) {
<AlbumCard Album="album"
SelectionMode="selectMode"
Selected="selectedAlbumIds.Contains(album.Id)"
OnToggleSelection="ToggleSelectAlbum" />
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
} else {
<EmptyState Variant="info" Title="No albums for this cosplayer" />
}
<AlbumGrid SearchQuery="@albumSearchQuery"
SortBy="@albumSortBy"
SortAsc="@albumSortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums" />
</div>
}
@@ -212,14 +200,7 @@
["Asset count"] = "assets"
};
// Infinite scroll
Dictionary<Guid, AlbumPreviewDto> allAlbumsDict = [];
int currentAlbumPage;
const int AlbumPageSize = 30;
bool hasMoreAlbums = true;
bool isLoadingMore;
ElementReference sentinelRef;
DotNetObjectReference<CosplayerDetail>? dotNetRef;
int albumLoadVersion;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
@@ -241,70 +222,21 @@
};
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (allAlbumsDict.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"albumObserver.observeBottom", sentinelRef, dotNetRef
);
}
}
async Task LoadPerson() {
isLoading = true;
allAlbumsDict.Clear();
currentAlbumPage = 0;
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize);
if (person?.Albums != null) {
foreach (var a in person.Albums)
allAlbumsDict[a.Id] = a;
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
currentAlbumPage = 1;
}
person = await personService.GetByIdAsync(Id, 0, 30);
bannerCoverUrls = person?.Albums?
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
.ToList() ?? [];
isLoading = false;
}
async Task ReloadAlbums() {
currentAlbumPage = 0;
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
allAlbumsDict.Clear();
if (person?.Albums != null) {
foreach (var a in person.Albums)
allAlbumsDict[a.Id] = a;
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
currentAlbumPage = 1;
}
StateHasChanged();
}
[JSInvokable]
public async Task LoadMoreAlbums() {
if (isLoadingMore || !hasMoreAlbums) return;
isLoadingMore = true;
StateHasChanged();
var fresh = await personService.GetByIdAsync(Id, currentAlbumPage, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
if (fresh?.Albums?.Count > 0) {
foreach (var a in fresh.Albums)
allAlbumsDict[a.Id] = a;
currentAlbumPage++;
}
hasMoreAlbums = fresh?.Albums?.Count >= AlbumPageSize;
isLoadingMore = false;
StateHasChanged();
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
return p?.Albums;
}
void OpenEditForm() => showForm = true;
@@ -347,6 +279,7 @@
showAlbumAssigner = false;
unassignedAlbums = null;
await LoadPerson();
albumLoadVersion++;
}
async Task RemoveSelectedAlbums() {
@@ -356,6 +289,7 @@
selectMode = false;
selectedAlbumIds.Clear();
await LoadPerson();
albumLoadVersion++;
}
async Task ConfirmDelete() {
@@ -365,11 +299,4 @@
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() {
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("albumObserver.dispose");
dotNetRef.Dispose();
}
}
}

View File

@@ -41,6 +41,7 @@
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
List<AlbumPreviewDto> albums = [];
int currentPage = 0;
@@ -87,7 +88,9 @@
isLoading = true;
StateHasChanged();
var items = await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
var items = FetchAlbums != null
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums = items;
currentPage = 1;
@@ -102,7 +105,9 @@
isLoadingMore = true;
StateHasChanged();
var items = await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
var items = FetchAlbums != null
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums.AddRange(items);
currentPage++;