feat(ui): extract CosplayerGrid to isolate grid re-renders from SortFilterBar

- Create CosplayerGrid.razor child component that manages its own data loading,
  pagination, infinite scroll via cosplayerObserver, and empty state
- Cosplayers.razor now only holds header/SortFilterBar/form state
- Filter param changes trigger reload via OnParametersSetAsync
- LoadVersion parameter allows parent to trigger grid reload
- Remove OnFilterChanged no-op handler - grid handles reloads via params
This commit is contained in:
2026-07-11 16:16:36 +02:00
parent c96488b1eb
commit 12cb8aa15b
2 changed files with 244 additions and 200 deletions

View File

@@ -5,114 +5,58 @@
@using Butter.Types @using Butter.Types
@using MilkStream.Client @using MilkStream.Client
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using Microsoft.JSInterop
@using MilkStream.Client.Components.Layout @using MilkStream.Client.Components.Layout
@using MilkStream.Client.Components.Shared @using MilkStream.Client.Components.Shared
@using MilkStream.Client.Services @using MilkStream.Client.Services
@using System.Globalization @using System.Globalization
@implements IAsyncDisposable @implements IDisposable
@inject PersonService personService @inject PersonService personService
@inject LoginService loginService @inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions @inject IOptions<ServiceOptions> serviceOptions
@inject NavigationManager navigationManager @inject NavigationManager navigationManager
@inject IJSRuntime jsRuntime
<PageTitle>Cosplayers</PageTitle> <PageTitle>Cosplayers</PageTitle>
@if (isLoading) { <div class="d-flex align-items-center justify-content-between mb-3 px-2 flex-wrap gap-2">
<LoadSpinner/> <h4 class="m-0"><i class="bi bi-people"></i> Cosplayers</h4>
} else { <div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
<div class="d-flex align-items-center justify-content-between mb-3 px-2 flex-wrap gap-2"> @if (IsAdminOrCurator) {
<h4 class="m-0"><i class="bi bi-people"></i> Cosplayers</h4> if (cosplayerSelectMode) {
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center"> <button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
@if (IsAdminOrCurator) { <i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
if (cosplayerSelectMode) { </button>
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)"> <button class="btn btn-secondary btn-sm" @onclick="() => { cosplayerSelectMode = false; selectedCosplayerIds.Clear(); }">
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count Done
</button> </button>
<button class="btn btn-secondary btn-sm" @onclick="() => { cosplayerSelectMode = false; selectedCosplayerIds.Clear(); }"> } else {
Done <button class="btn btn-secondary btn-sm" @onclick="() => cosplayerSelectMode = true">
</button> <i class="bi bi-check2-square"></i> Select
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => cosplayerSelectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
}
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> New Cosplayer
</button> </button>
} }
</div> <button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
</div> <i class="bi bi-plus-circle"></i> New Cosplayer
</button>
<SortFilterBar SearchQuery="@searchQuery"
SearchQueryChanged="@((string? v) => searchQuery = v)"
SortBy="@sortBy"
SortByChanged="@((string? v) => sortBy = v)"
SortAsc="@sortAsc"
SortAscChanged="@((bool v) => sortAsc = v)"
SortOptions="@sortOptions"
OnFilterChanged="@OnFilterChanged" />
@if (people.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
} else {
<p>There is no public media available. Logging in <i>might</i> make more content visible.</p>
}
</EmptyState>
} else {
<div class="cosplayers-grid">
@foreach (var person in people) {
<div class="cosplayer-card @(cosplayerSelectMode ? "cosplayer-card-select" : "")" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="true">
@if (person.ProfileAssetId.HasValue) {
var cx1 = person.ProfileCropX ?? 50;
var cy1 = person.ProfileCropY ?? 50;
var zoom1 = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-card-img-wrap">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-card-img"
style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="cosplayer-card-fallback" style="display:none">
<i class="bi bi-person-circle"></i>
</div>
</div>
} else {
<div class="cosplayer-card-img-wrap">
<div class="cosplayer-card-fallback">
<i class="bi bi-person-circle"></i>
</div>
</div>
}
<div class="cosplayer-card-info">
<span class="cosplayer-card-name">@person.Name</span>
<span class="cosplayer-card-count">@person.TotalAlbums albums</span>
</div>
@if (cosplayerSelectMode) {
<div class="cosplayer-card-check">
@if (selectedCosplayerIds.Contains(person.Id)) {
<i class="bi bi-check-circle-fill text-danger"></i>
} else {
<i class="bi bi-circle text-white"></i>
}
</div>
}
</div>
} }
</div> </div>
</div>
@if (isLoadingMore) { <SortFilterBar SearchQuery="@searchQuery"
<LoadSpinner/> SearchQueryChanged="@((string? v) => searchQuery = v)"
} SortBy="@sortBy"
SortByChanged="@((string? v) => sortBy = v)"
SortAsc="@sortAsc"
SortAscChanged="@((bool v) => sortAsc = v)"
SortOptions="@sortOptions" />
<div @ref="sentinelRef" class="masonry-sentinel"></div> <CosplayerGrid SearchQuery="@searchQuery"
} SortBy="@sortBy"
} SortAsc="@sortAsc"
SelectionMode="@cosplayerSelectMode"
SelectedIds="@selectedCosplayerIds"
OnCardClicked="@NavigateToPerson"
OnToggleSelection="@ToggleCosplayerSelection"
LoadVersion="@loadVersion" />
@if (showCreateForm) { @if (showCreateForm) {
<PersonForm Show="true" <PersonForm Show="true"
@@ -122,24 +66,15 @@
} }
@code { @code {
List<PersonPreviewDto> people = [];
bool isLoading = true;
bool isLoadingMore;
bool hasMore = true;
int currentPage = 0;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
bool showCreateForm; bool showCreateForm;
bool cosplayerSelectMode; bool cosplayerSelectMode;
HashSet<Guid> selectedCosplayerIds = []; HashSet<Guid> selectedCosplayerIds = [];
ElementReference sentinelRef; bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
DotNetObjectReference<Cosplayers>? dotNetRef;
string? searchQuery; string? searchQuery;
string? sortBy; string? sortBy;
bool sortAsc = true; bool sortAsc = true;
int loadVersion;
Dictionary<string, string> sortOptions = new() Dictionary<string, string> sortOptions = new()
{ {
["Name"] = "name", ["Name"] = "name",
@@ -149,122 +84,41 @@
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
loginService.LoggedUserChanged += OnLoggedUserChanged; loginService.LoggedUserChanged += OnLoggedUserChanged;
loginService.AuthInfoChanged += OnAuthInfoChanged;
token = loginService.AuthInfo?.Token ?? string.Empty;
if (!loginService.IsLoggedIn) {
isLoading = false;
return;
}
await LoadFirstPage();
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (people.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
);
}
}
async Task LoadFirstPage() {
isLoading = true;
var items = await personService.GetAllAsync(0, 30, searchQuery, sortBy, sortAsc);
if (items?.Count > 0) {
people = items;
currentPage = 1;
}
hasMore = items?.Count == 30;
isLoading = false;
}
[JSInvokable]
public async Task LoadMoreCosplayers() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var items = await personService.GetAllAsync(currentPage, 30, searchQuery, sortBy, sortAsc);
if (items?.Count > 0) {
people.AddRange(items);
currentPage++;
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
async Task OnFilterChanged() {
people.Clear();
currentPage = 0;
hasMore = true;
// Dispose old observer so it re-attaches after re-render
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
dotNetRef.Dispose();
dotNetRef = null;
}
await LoadFirstPage();
StateHasChanged();
} }
void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged(); void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged();
void OnAuthInfoChanged(object? _, AuthInfo? info) {
token = info?.Token ?? string.Empty;
}
public async ValueTask DisposeAsync() {
loginService.LoggedUserChanged -= OnLoggedUserChanged;
loginService.AuthInfoChanged -= OnAuthInfoChanged;
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
dotNetRef.Dispose();
}
}
void NavigateToPerson(Guid id) { void NavigateToPerson(Guid id) {
navigationManager.NavigateTo($"/cosplayer/{id}"); navigationManager.NavigateTo($"/cosplayer/{id}");
} }
void HandleCardClick(Guid id) {
if (cosplayerSelectMode) ToggleCosplayerSelection(id);
else NavigateToPerson(id);
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
void OpenCreateForm() {
showCreateForm = true;
}
async Task HandleFormSaved() {
showCreateForm = false;
people.Clear();
currentPage = 0;
hasMore = true;
await LoadFirstPage();
}
void CloseForm() {
showCreateForm = false;
}
void ToggleCosplayerSelection(Guid id) { void ToggleCosplayerSelection(Guid id) {
if (selectedCosplayerIds.Contains(id)) selectedCosplayerIds.Remove(id); if (selectedCosplayerIds.Contains(id)) selectedCosplayerIds.Remove(id);
else selectedCosplayerIds.Add(id); else selectedCosplayerIds.Add(id);
} }
void OpenCreateForm() {
showCreateForm = true;
}
async Task HandleFormSaved() {
showCreateForm = false;
loadVersion++;
}
void CloseForm() {
showCreateForm = false;
}
async Task DeleteSelectedCosplayers() { async Task DeleteSelectedCosplayers() {
foreach (var id in selectedCosplayerIds.ToList()) foreach (var id in selectedCosplayerIds.ToList())
await personService.DeletePersonAsync(id); await personService.DeletePersonAsync(id);
selectedCosplayerIds.Clear(); selectedCosplayerIds.Clear();
cosplayerSelectMode = false; cosplayerSelectMode = false;
people.Clear(); loadVersion++;
currentPage = 0; }
hasMore = true;
await LoadFirstPage(); public void Dispose() {
loginService.LoggedUserChanged -= OnLoggedUserChanged;
} }
} }

View File

@@ -0,0 +1,190 @@
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using Microsoft.JSInterop
@using MilkStream.Client
@using MilkStream.Client.Components.Layout
@using MilkStream.Client.Services
@using System.Globalization
@implements IAsyncDisposable
@inject PersonService personService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject IJSRuntime jsRuntime
@if (isLoading) {
<LoadSpinner/>
} else if (people.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
} else {
<p>There is no public media available. Logging in <i>might</i> make more content visible.</p>
}
</EmptyState>
} else {
<div class="cosplayers-grid">
@foreach (var person in people) {
<div class="cosplayer-card @(SelectionMode ? "cosplayer-card-select" : "")" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="true">
@if (person.ProfileAssetId.HasValue) {
var cx1 = person.ProfileCropX ?? 50;
var cy1 = person.ProfileCropY ?? 50;
var zoom1 = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-card-img-wrap">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-card-img"
style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="cosplayer-card-fallback" style="display:none">
<i class="bi bi-person-circle"></i>
</div>
</div>
} else {
<div class="cosplayer-card-img-wrap">
<div class="cosplayer-card-fallback">
<i class="bi bi-person-circle"></i>
</div>
</div>
}
<div class="cosplayer-card-info">
<span class="cosplayer-card-name">@person.Name</span>
<span class="cosplayer-card-count">@person.TotalAlbums albums</span>
</div>
@if (SelectionMode) {
<div class="cosplayer-card-check">
@if (SelectedIds.Contains(person.Id)) {
<i class="bi bi-check-circle-fill text-danger"></i>
} else {
<i class="bi bi-circle text-white"></i>
}
</div>
}
</div>
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@code {
[Parameter] public string? SearchQuery { get; set; }
[Parameter] public string? SortBy { get; set; }
[Parameter] public bool SortAsc { get; set; } = true;
[Parameter] public bool SelectionMode { get; set; }
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<Guid> OnCardClicked { get; set; }
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
List<PersonPreviewDto> people = [];
int currentPage = 0;
bool hasMore = true;
bool isLoading = true;
bool isLoadingMore;
ElementReference sentinelRef;
DotNetObjectReference<CosplayerGrid>? dotNetRef;
int? _prevLoadVersion;
string? _prevSearch;
string? _prevSortBy;
bool _prevSortAsc;
string _token = string.Empty;
string _apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(_token) ? "" : $"?token={_token}";
protected override void OnInitialized() {
loginService.AuthInfoChanged += OnAuthInfoChanged;
_token = loginService.AuthInfo?.Token ?? string.Empty;
}
protected override async Task OnParametersSetAsync() {
bool filterChanged = _prevLoadVersion != LoadVersion
|| _prevSearch != SearchQuery
|| _prevSortBy != SortBy
|| _prevSortAsc != SortAsc;
_prevLoadVersion = LoadVersion;
_prevSearch = SearchQuery;
_prevSortBy = SortBy;
_prevSortAsc = SortAsc;
if (filterChanged)
await Reload();
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (people.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
);
}
}
async Task Reload() {
await DisposeObserver();
people.Clear();
currentPage = 0;
hasMore = true;
isLoading = true;
StateHasChanged();
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people = items;
currentPage = 1;
}
hasMore = items?.Count == 30;
isLoading = false;
}
[JSInvokable]
public async Task LoadMoreCosplayers() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var items = await personService.GetAllAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people.AddRange(items);
currentPage++;
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
void HandleCardClick(Guid id) {
if (SelectionMode) {
OnToggleSelection.InvokeAsync(id);
} else {
OnCardClicked.InvokeAsync(id);
}
}
string ThumbUrl(Guid id) => $"{_apiBase}/api/media/thumb/{id}{TokenParam}";
void OnAuthInfoChanged(object? _, AuthInfo? info) {
_token = info?.Token ?? string.Empty;
}
async Task DisposeObserver() {
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
dotNetRef.Dispose();
dotNetRef = null;
}
}
public async ValueTask DisposeAsync() {
loginService.AuthInfoChanged -= OnAuthInfoChanged;
await DisposeObserver();
}
}