381 lines
15 KiB
Plaintext
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();
|
|
}
|
|
}
|
|
}
|