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:
@@ -5,114 +5,58 @@
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Components.Shared
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Globalization
|
||||
@implements IAsyncDisposable
|
||||
@implements IDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject NavigationManager navigationManager
|
||||
@inject IJSRuntime jsRuntime
|
||||
|
||||
<PageTitle>Cosplayers</PageTitle>
|
||||
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else {
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 px-2 flex-wrap gap-2">
|
||||
<h4 class="m-0"><i class="bi bi-people"></i> Cosplayers</h4>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
if (cosplayerSelectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { cosplayerSelectMode = false; selectedCosplayerIds.Clear(); }">
|
||||
Done
|
||||
</button>
|
||||
} 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
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 px-2 flex-wrap gap-2">
|
||||
<h4 class="m-0"><i class="bi bi-people"></i> Cosplayers</h4>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
if (cosplayerSelectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { cosplayerSelectMode = false; selectedCosplayerIds.Clear(); }">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => cosplayerSelectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> New Cosplayer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
<SortFilterBar SearchQuery="@searchQuery"
|
||||
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) {
|
||||
<PersonForm Show="true"
|
||||
@@ -122,24 +66,15 @@
|
||||
}
|
||||
|
||||
@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 cosplayerSelectMode;
|
||||
HashSet<Guid> selectedCosplayerIds = [];
|
||||
ElementReference sentinelRef;
|
||||
DotNetObjectReference<Cosplayers>? dotNetRef;
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string? searchQuery;
|
||||
string? sortBy;
|
||||
bool sortAsc = true;
|
||||
int loadVersion;
|
||||
Dictionary<string, string> sortOptions = new()
|
||||
{
|
||||
["Name"] = "name",
|
||||
@@ -149,122 +84,41 @@
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
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 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) {
|
||||
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) {
|
||||
if (selectedCosplayerIds.Contains(id)) selectedCosplayerIds.Remove(id);
|
||||
else selectedCosplayerIds.Add(id);
|
||||
}
|
||||
|
||||
void OpenCreateForm() {
|
||||
showCreateForm = true;
|
||||
}
|
||||
|
||||
async Task HandleFormSaved() {
|
||||
showCreateForm = false;
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
void CloseForm() {
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
async Task DeleteSelectedCosplayers() {
|
||||
foreach (var id in selectedCosplayerIds.ToList())
|
||||
await personService.DeletePersonAsync(id);
|
||||
selectedCosplayerIds.Clear();
|
||||
cosplayerSelectMode = false;
|
||||
people.Clear();
|
||||
currentPage = 0;
|
||||
hasMore = true;
|
||||
await LoadFirstPage();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
loginService.LoggedUserChanged -= OnLoggedUserChanged;
|
||||
}
|
||||
}
|
||||
|
||||
190
MilkStream.Client/Components/Shared/CosplayerGrid.razor
Normal file
190
MilkStream.Client/Components/Shared/CosplayerGrid.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user