Compare commits

5 Commits

Author SHA1 Message Date
2b150060f4 Merge pull request 'Add searchbar to cosplayer detail page for filtering albums' (#100) from feat/cosplayer-detail-search into develop
Reviewed-on: #100
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-12 18:23:51 +00:00
b93cb73e0a fix(cosplayer-detail): remove search debounce, instant inline reload
Search now fires ReloadAlbums immediately on every keystroke, matching
the same instant behavior as Albums and Cosplayers pages. Since
ReloadAlbums only swaps the album grid content without touching
isLoading, focus is preserved and the page stays intact.
2026-07-12 20:21:27 +02:00
d2d584d509 fix(cosplayer-detail): debounce search, keep page intact on filter changes
- Remove OnFilterChanged from SortFilterBar to prevent per-keystroke reloads
- Debounce search input with 300ms CancellationTokenSource delay
- Sort changes (dropdown/toggle) trigger immediate ReloadAlbums
- ReloadAlbums no longer sets isLoading — updates album grid in-place
  without unmounting the page DOM, preserving focus on the search box
- Dispose searchCts on component disposal
2026-07-12 20:19:30 +02:00
8bcb408a5b fix(cosplayer-detail): prevent banner image shuffle on album search/sort
Add updateBanner parameter to LoadPerson so ReloadAlbums (triggered
by search/sort filter changes) does not regenerate the random banner
cover URLs, keeping the header fixed during filtering.
2026-07-12 20:15:09 +02:00
445b030160 feat(cosplayer-detail): add SortFilterBar for album search and sort
- Backend: accept PagedSearchParametersDto on GET /api/person/{id}
  with in-memory search (case-insensitive title Contains) and sort
  (name/created/updated/assets) before pagination
- Frontend service: add search, sortBy, sortAsc params to GetByIdAsync
- Frontend page: replace inline action buttons with reusable SortFilterBar
  component, wire search/sort state into initial load and infinite scroll
2026-07-12 20:11:06 +02:00
3 changed files with 129 additions and 43 deletions

View File

@@ -27,10 +27,10 @@ public class PersonController(
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="albumPaging">Pagination parameters for albums.</param>
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
/// <returns>The person details with albums, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -56,6 +56,33 @@ public class PersonController(
}
if (person.Albums != null) {
if (!string.IsNullOrEmpty(albumPaging.Search))
{
person.Albums = person.Albums
.Where(a => a.Title.Contains(albumPaging.Search, StringComparison.OrdinalIgnoreCase))
.ToList();
}
if (!string.IsNullOrEmpty(albumPaging.SortBy))
{
person.Albums = albumPaging.SortBy.ToLowerInvariant() switch
{
"name" => albumPaging.SortAsc
? [..person.Albums.OrderBy(a => a.Title)]
: [..person.Albums.OrderByDescending(a => a.Title)],
"created" => albumPaging.SortAsc
? [..person.Albums.OrderBy(a => a.CreatedAt)]
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
"updated" => albumPaging.SortAsc
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
"assets" => albumPaging.SortAsc
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
_ => person.Albums
};
}
person.Albums = person.Albums
.Skip(albumPaging.Page * albumPaging.PageSize)
.Take(albumPaging.PageSize)

View File

@@ -5,6 +5,7 @@
@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
@@ -69,46 +70,57 @@
</div>
@* Albums header *@
<div class="d-flex align-items-center justify-content-between mt-4 mb-3 px-2">
<div class="d-flex align-items-center mt-4 mb-2 px-2">
<h4 class="m-0"><i class="bi bi-collection"></i> Albums</h4>
<div class="d-flex gap-2 align-items-start">
@if (allAlbums.Count > 0) {
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
<i class="bi bi-grid-3x2-gap"></i>
</button>
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
@onclick="SetGrid" title="Grid">
<i class="bi bi-grid-3x3"></i>
</button>
</div>
}
@if (IsAdminOrCurator) {
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
@if (allAlbums.Count > 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
</button>
<button class="btn btn-secondary btn-sm" @onclick="() => { selectMode = false; selectedAlbumIds.Clear(); }">
Done
</button>
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
}
}
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete
</button>
}
</div>
</div>
<SortFilterBar SearchQuery="@albumSearchQuery"
SearchQueryChanged="@((string? v) => { albumSearchQuery = v; _ = ReloadAlbums(); })"
SortBy="@albumSortBy"
SortByChanged="@((string? v) => { albumSortBy = v; _ = ReloadAlbums(); })"
SortAsc="@albumSortAsc"
SortAscChanged="@((bool v) => { albumSortAsc = v; _ = ReloadAlbums(); })"
SortOptions="@albumSortOptions">
<ActionButtons>
<div class="d-flex gap-2 align-items-start">
@if (allAlbums.Count > 0) {
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
<i class="bi bi-grid-3x2-gap"></i>
</button>
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
@onclick="SetGrid" title="Grid">
<i class="bi bi-grid-3x3"></i>
</button>
</div>
}
@if (IsAdminOrCurator) {
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
@if (allAlbums.Count > 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
</button>
<button class="btn btn-secondary btn-sm" @onclick="() => { selectMode = false; selectedAlbumIds.Clear(); }">
Done
</button>
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
}
}
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete
</button>
}
</div>
</ActionButtons>
</SortFilterBar>
@if (allAlbums.Count > 0) {
<div class="@("album-grid " + viewMode)">
@foreach (var album in allAlbums) {
@@ -192,6 +204,19 @@
HashSet<Guid> selectedAlbumIds = [];
List<string> bannerCoverUrls = [];
// Search & sort
string? albumSearchQuery;
string? albumSortBy;
bool albumSortAsc = true;
Dictionary<string, string> albumSortOptions = new()
{
["Name"] = "name",
["Created"] = "created",
["Updated"] = "updated",
["Asset count"] = "assets"
};
// Infinite scroll
List<AlbumPreviewDto> allAlbums = [];
int currentAlbumPage;
@@ -236,7 +261,7 @@
currentAlbumPage = 0;
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id);
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize);
if (person?.Albums != null) {
allAlbums = person.Albums;
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
@@ -253,13 +278,28 @@
isLoading = false;
}
async Task ReloadAlbums() {
currentAlbumPage = 0;
hasMoreAlbums = true;
person = await personService.GetByIdAsync(Id, 0, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
allAlbums.Clear();
if (person?.Albums != null) {
allAlbums = person.Albums;
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);
var fresh = await personService.GetByIdAsync(Id, currentAlbumPage, AlbumPageSize, albumSearchQuery, albumSortBy, albumSortAsc);
if (fresh?.Albums?.Count > 0) {
allAlbums.AddRange(fresh.Albums);
currentAlbumPage++;

View File

@@ -43,11 +43,30 @@ public sealed class PersonService(
/// <param name="id">The person ID.</param>
/// <param name="albumPage">Optional zero-based page number for albums.</param>
/// <param name="albumPageSize">Optional number of albums per page.</param>
/// <param name="search">Optional search term to filter album titles.</param>
/// <param name="sortBy">Optional field to sort albums by (e.g. "name", "created", "updated", "assets").</param>
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
/// <returns>The person details, or null if not found.</returns>
public async Task<PersonDetailedDto?> GetByIdAsync(Guid id, int? albumPage = null, int? albumPageSize = null) {
public async Task<PersonDetailedDto?> GetByIdAsync(Guid id, int? albumPage = null, int? albumPageSize = null, string? search = null, string? sortBy = null, bool sortAsc = true) {
var url = $"/api/person/{id}";
if (albumPage.HasValue && albumPageSize.HasValue)
var hasParams = false;
if (albumPage.HasValue && albumPageSize.HasValue) {
url += $"?page={albumPage}&pageSize={albumPageSize}";
hasParams = true;
}
if (!string.IsNullOrEmpty(search)) {
url += $"{(hasParams ? "&" : "?")}search={Uri.EscapeDataString(search)}";
hasParams = true;
}
if (!string.IsNullOrEmpty(sortBy)) {
url += $"{(hasParams ? "&" : "?")}sortBy={Uri.EscapeDataString(sortBy)}";
hasParams = true;
}
url += $"{(hasParams ? "&" : "?")}sortAsc={sortAsc.ToString().ToLowerInvariant()}";
var response = await Client.GetAsync(url);