Files
MilkyShots/MilkStream.Client/Components/Pages/CosplayerDetail.razor

381 lines
15 KiB
Plaintext

@page "/cosplayer/{Id:guid}"
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@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>
@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>
<span class="mx-1">·</span>
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
</div>
</div>
</div>
@if (IsAdminOrCurator) {
<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; _ = 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 (allAlbumsDict.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 (allAlbumsDict.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 (allAlbumsDict.Count > 0) {
<div class="@("album-grid " + viewMode)">
@foreach (var album in allAlbumsDict.Values) {
<AlbumCard Album="album"
SelectionMode="selectMode"
Selected="selectedAlbumIds.Contains(album.Id)"
OnNavigate="NavigateToAlbum"
OnToggleSelection="ToggleSelectAlbum" />
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
} else {
<EmptyState Variant="info" Title="No albums for this cosplayer" />
}
</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"
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>
@code {
[Parameter]
public Guid Id { get; set; }
PersonDetailedDto? person;
bool isLoading = true;
bool showForm;
bool selectMode;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
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"
};
// Infinite scroll
Dictionary<Guid, AlbumPreviewDto> allAlbumsDict = [];
int currentAlbumPage;
const int AlbumPageSize = 30;
bool hasMoreAlbums = true;
bool isLoadingMore;
ElementReference sentinelRef;
DotNetObjectReference<CosplayerDetail>? dotNetRef;
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;
};
}
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;
}
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();
}
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);
}
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();
}
async Task RemoveSelectedAlbums() {
if (person == null) return;
foreach (var albumId in selectedAlbumIds)
await albumService.UnlinkPersonAsync(albumId);
selectMode = false;
selectedAlbumIds.Clear();
await LoadPerson();
}
async Task ConfirmDelete() {
if (person == null) return;
await personService.DeletePersonAsync(person.Id);
navigationManager.NavigateTo("/cosplayers");
}
void NavigateToAlbum(Guid albumId) {
navigationManager.NavigateTo($"/albums/{albumId}");
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() {
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("albumObserver.dispose");
dotNetRef.Dispose();
}
}
}