Files
MilkyShots/MilkStream.Client/Components/Pages/CosplayerDetail.razor
T
REDCODE 52e026d7e1 perf(ui): page cosplayer albums via album endpoint and skip count queries
CosplayerDetail now fetches album pages from GET /api/album?personOwnerId=
instead of re-fetching the entire PersonDetailedDto per scroll page. The home
browse page opts out of the asset count query (includeCount=false).
2026-08-11 13:11:13 +02:00

528 lines
23 KiB
Plaintext

@page "/cosplayer/{Id:guid}"
@using Butter.Dtos.Album
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization
@inject PersonService personService
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject NavigationManager navigationManager
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
@if (isLoading) {
<LoadSpinner/>
} else if (person == null) {
<EmptyState Variant="danger" Title="Cosplayer not found">
<p>The cosplayer you are looking for does not exist or you do not have access to them.</p>
<a href="/cosplayers" class="btn btn-danger">Back to Cosplayers</a>
</EmptyState>
} else {
<div class="cosplayer-detail">
@* Banner *@
<div class="cosplayer-banner @(bannerCoverUrls.Count == 0 ? "cosplayer-banner-empty" : "")">
@if (bannerCoverUrls.Count > 0) {
<div class="cosplayer-banner-grid" style="grid-template-columns: repeat(@bannerCoverUrls.Count, 1fr)">
@foreach (var url in bannerCoverUrls) {
<div class="banner-cell" style="background-image: url('@url')"></div>
}
</div>
}
<div class="cosplayer-banner-overlay"></div>
<div class="cosplayer-banner-content">
<div class="cosplayer-avatar">
@if (person.ProfileAssetId.HasValue) {
var cx = person.ProfileCropX ?? 50;
var cy = person.ProfileCropY ?? 50;
var z = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-avatar-clip">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-avatar-img"
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
</div>
} else {
<i class="bi bi-person-circle cosplayer-avatar-placeholder"></i>
}
</div>
<div>
<h2 class="cosplayer-name">@person.Name</h2>
<div class="cosplayer-stats">
<span><i class="bi bi-collection"></i> @person.TotalAlbums albums</span>
@if (selectMode && loginService.CanEdit(Id) && person != null && person.Albums != null) {
var pub = person.Albums.Count(a => a.Visibility == EVisibility.Public);
var prot = person.Albums.Count(a => a.Visibility == EVisibility.Protected);
var priv = person.Albums.Count(a => a.Visibility == EVisibility.Private);
<span class="mx-1">·</span>
<span class="text-muted small">@pub public, @prot protected, @priv private</span>
}
<span class="mx-1">·</span>
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
@if (person.MaintainerUserIds?.Count > 0)
{
<span class="mx-1">·</span>
<span>
<i class="bi bi-people"></i>
Maintained by:
@for (var i = 0; i < person.MaintainerUsernames!.Count; i++)
{
<a href="/User/@person.MaintainerUserIds[i]" class="text-reset text-decoration-none">
@person.MaintainerUsernames[i]
</a>
@if (i < person.MaintainerUsernames.Count - 1)
{
<text>, </text>
}
}
</span>
}
</div>
</div>
</div>
@if (loginService.CanEdit(Id)) {
<button class="cosplayer-banner-edit" @onclick="OpenEditForm" title="Edit Cosplayer">
<i class="bi bi-pencil"></i>
</button>
}
</div>
@* Albums header *@
<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>
<SortFilterBar SearchQuery="@albumSearchQuery"
SearchQueryChanged="@((string? v) => albumSearchQuery = v)"
SortBy="@albumSortBy"
SortByChanged="@((string? v) => albumSortBy = v)"
SortAsc="@albumSortAsc"
SortAscChanged="@((bool v) => albumSortAsc = v)"
SortOptions="@albumSortOptions">
<ActionButtons>
@if (person.TotalAlbums > 0) {
<div class="btn-group btn-group-sm d-none d-md-inline-flex">
<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 (loginService.CanEdit(Id)) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
@if (person.TotalAlbums > 0) {
if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-info" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
@(albumsAllSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
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" title="Delete cosplayer">
<i class="bi bi-trash"></i>
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
</li>
@if (person.TotalAlbums > 0) {
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
}
<li>
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete Cosplayer
</button>
</li>
</ul>
</div>
}
</ActionButtons>
</SortFilterBar>
<AlbumGrid @ref="personAlbumGridRef"
SearchQuery="@albumSearchQuery"
SortBy="@albumSortBy"
SortAsc="@albumSortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums"
OnDataLoaded="@OnGridAlbumsLoaded" />
</div>
}
@if (showForm && person != null) {
<PersonForm Show="true"
EditPerson="new PersonPreviewDto { Id = person.Id, Name = person.Name, ProfileAssetId = person.ProfileAssetId, ProfileCropX = person.ProfileCropX, ProfileCropY = person.ProfileCropY, ProfileCropZoom = person.ProfileCropZoom }"
AssetIds="allAssetIds"
MaintainerUserIds="person.MaintainerUserIds"
OnSaved="HandleFormSaved"
OnClosed="CloseForm" />
}
<ModalFrame Title="" Show="showAlbumAssigner && person != null" OnClose="CloseAlbumAssigner">
<Body>
@if (person != null)
{
<h5>Assign Albums to @person.Name</h5>
@if (unassignedAlbums == null) {
<div class="text-muted">Loading...</div>
} else if (unassignedAlbums.Count == 0) {
<div class="text-muted">All albums are already assigned.</div>
} else {
<div class="border rounded p-2" style="max-height: 300px; overflow-y: auto;">
@foreach (var album in unassignedAlbums) {
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@("aa-" + album.Id)"
checked="@assignAlbumIds.Contains(album.Id)"
@onchange="(e) => ToggleAssignAlbum(album.Id, (bool)(e.Value ?? false))" />
<label class="form-check-label" for="@("aa-" + album.Id)">
<span>@album.Name</span>
<span class="text-muted small ms-1">@album.AssetCount items</span>
</label>
</div>
}
</div>
}
}
</Body>
<Footer>
<button class="btn btn-secondary" @onclick="CloseAlbumAssigner">Cancel</button>
<button class="btn btn-success" @onclick="AssignSelectedAlbums" disabled="@(assignAlbumIds.Count == 0)">
Assign @assignAlbumIds.Count album(s)
</button>
</Footer>
</ModalFrame>
@if (showAlbumVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedAlbumIds.Count"
ItemLabel="album(s)"
Visibility="@selectedAlbumVisibility"
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
CascadeEnabled="@cascadeToAssets"
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
OnApply="ApplyAlbumVisibility"
OnClose="CloseVisibilityModal" />
}
@if (showAlbumMergeModal) {
<MergeModal Show="true"
ItemLabel="Album"
Items="@albumMergeItems"
OnMergeConfirmed="@ExecuteMergeAlbums"
OnClose="() => showAlbumMergeModal = false" />
}
<ConfirmDialog Show="_showDeleteConfirm"
Title="Confirm Deletion"
Message="@_deleteConfirmMessage"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecutePendingDelete"
OnCancel="() => _showDeleteConfirm = false" />
@code {
[Parameter]
public Guid Id { get; set; }
PersonDetailedDto? person;
AlbumGrid? personAlbumGridRef;
bool isLoading = true;
bool showForm;
bool selectMode;
bool showAlbumVisibilityModal;
bool showAlbumMergeModal;
List<MergeModal.MergeItem> albumMergeItems = [];
bool _showDeleteConfirm;
string _deleteConfirmMessage = "";
Func<Task>? _pendingDeleteAction;
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string viewMode = "masonry";
bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums;
HashSet<Guid> assignAlbumIds = [];
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"
};
int albumLoadVersion;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
List<Guid>? allAssetIds => person?.Albums?
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadPerson();
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
}
async Task LoadPerson() {
isLoading = true;
person = await personService.GetByIdAsync(Id, 0, 30);
isLoading = false;
}
async Task OnGridAlbumsLoaded(List<AlbumPreviewDto> albums) {
bannerCoverUrls = albums
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
.ToList();
}
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
return await albumService.GetAlbumsAsync(page, pageSize, search, sortBy, sortAsc, personOwnerId: Id);
}
void OpenEditForm() => showForm = true;
async Task HandleFormSaved() {
showForm = false;
await LoadPerson();
}
void SetMasonry() => viewMode = "masonry";
void SetGrid() => viewMode = "grid";
void CloseForm() => showForm = false;
async Task OpenAlbumAssigner() {
showAlbumAssigner = true;
unassignedAlbums = await albumService.GetUnassignedAsync();
assignAlbumIds.Clear();
}
void CloseAlbumAssigner() {
showAlbumAssigner = false;
unassignedAlbums = null;
}
void ToggleAssignAlbum(Guid id, bool selected) {
if (selected) assignAlbumIds.Add(id);
else assignAlbumIds.Remove(id);
}
bool albumsAllSelected => person?.Albums != null && person.Albums.Count > 0 && selectedAlbumIds.Count == person.Albums.Count;
void CancelSelectMode() {
selectMode = false;
selectedAlbumIds.Clear();
}
void OpenAlbumMergeModal() {
if (personAlbumGridRef is null) return;
albumMergeItems = personAlbumGridRef.GetCurrentAlbumPreviews()
.Where(a => selectedAlbumIds.Contains(a.Id))
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
.ToList();
showAlbumMergeModal = true;
}
async Task ExecuteMergeAlbums((Guid DestinationId, List<Guid> SourceIds) merge) {
if (merge.SourceIds.Count == 0) return;
var dto = new AlbumMergeDto { DestinationId = merge.DestinationId, SourceIds = merge.SourceIds };
await albumService.MergeAlbumsAsync(dto);
showAlbumMergeModal = false;
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
void ToggleSelectAllAlbums() {
if (person?.Albums == null) return;
if (albumsAllSelected)
selectedAlbumIds.Clear();
else
selectedAlbumIds.UnionWith(person.Albums.Select(a => a.Id));
}
void ToggleSelectAlbum(Guid id) {
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
else selectedAlbumIds.Add(id);
}
async Task AssignSelectedAlbums() {
if (person == null) return;
foreach (var albumId in assignAlbumIds)
await albumService.AssignPersonAsync(albumId, person.Id);
showAlbumAssigner = false;
unassignedAlbums = null;
await LoadPerson();
albumLoadVersion++;
}
async Task RemoveSelectedAlbums() {
if (person == null) return;
foreach (var albumId in selectedAlbumIds)
await albumService.UnlinkPersonAsync(albumId);
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
async Task DeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
_deleteConfirmMessage = $"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.";
_pendingDeleteAction = ExecuteDeleteSelectedAlbums;
_showDeleteConfirm = true;
}
async Task ExecuteDeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
_showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync([.. selectedAlbumIds]);
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
void OpenVisibilityModal() {
showAlbumVisibilityModal = true;
selectedAlbumVisibility = EVisibility.Public;
}
void CloseVisibilityModal() {
showAlbumVisibilityModal = false;
cascadeToAssets = false;
}
async Task ApplyAlbumVisibility() {
if (person == null || selectedAlbumIds.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
if (cascadeToAssets)
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
showAlbumVisibilityModal = false;
cascadeToAssets = false;
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
async Task ConfirmDelete() {
if (person == null) return;
_deleteConfirmMessage = $"Delete cosplayer '{person.Name}'? This cannot be undone.";
_pendingDeleteAction = ExecuteDeletePerson;
_showDeleteConfirm = true;
}
async Task ExecuteDeletePerson() {
if (person == null) return;
_showDeleteConfirm = false;
await personService.DeletePersonAsync(person.Id);
navigationManager.NavigateTo("/cosplayers");
}
async Task ExecutePendingDelete() {
if (_pendingDeleteAction != null)
await _pendingDeleteAction();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
}