feat(ui): unassigned album selector in PersonForm, Add Albums button in CosplayerDetail

This commit is contained in:
2026-07-09 15:07:19 +02:00
parent 9aead51481
commit cc611828b9
6 changed files with 172 additions and 3 deletions

View File

@@ -99,6 +99,16 @@ public class AlbumController(
}
}
/// <summary>
/// Gets all albums that have no person assigned.
/// </summary>
/// <returns>A list of unassigned album previews.</returns>
[HttpGet("unassigned")]
public ActionResult<List<AlbumPreviewDto>> GetUnassigned() {
var albums = albumRepository.FindUnassigned().ToList();
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
}
/// <summary>
/// Updates an existing album.
/// </summary>

View File

@@ -60,6 +60,10 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
/// <inheritdoc />
public void Remove(Album album) => context.Albums.Remove(album);
/// <inheritdoc />
public IEnumerable<Album> FindUnassigned() =>
context.Albums.Where(a => a.PersonOwnerId == null);
/// <inheritdoc />
public void Dispose() => context.Dispose();
}

View File

@@ -79,4 +79,11 @@ public interface IAlbumRepository : IDisposable {
/// <param name="pageSize">The number of results per page (default is 150).</param>
/// <returns>A list of album IDs that match the search query.</returns>
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150);
}
/// <summary>
/// Finds albums that have no person assigned.
/// </summary>
/// <returns>A collection of albums without a person owner.</returns>
public IEnumerable<Album> FindUnassigned();
}

View File

@@ -1,4 +1,5 @@
@page "/cosplayer/{Id:guid}"
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@@ -6,6 +7,7 @@
@using MilkStream.Client.Services
@inject PersonService personService
@inject AlbumService albumService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject NavigationManager navigationManager
@@ -75,6 +77,9 @@
</button>
</div>
@if (IsAdminOrCurator) {
<button class="btn btn-outline-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
<button class="btn btn-outline-primary btn-sm" @onclick="OpenEditForm">
<i class="bi bi-pencil"></i> Edit
</button>
@@ -106,6 +111,45 @@
OnClosed="CloseForm" />
}
@if (showAlbumAssigner && person != null) {
<div class="modal-overlay" @onclick="CloseAlbumAssigner">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5>Assign Albums to @person.Name</h5>
<button class="btn-close" @onclick="CloseAlbumAssigner"></button>
</div>
<div class="modal-body">
@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>
}
</div>
<div class="modal-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>
</div>
</div>
</div>
}
@code {
[Parameter]
public Guid Id { get; set; }
@@ -117,6 +161,9 @@
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 = [];
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
@@ -166,6 +213,32 @@
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);
}
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 ConfirmDelete() {
if (person == null) return;
await personService.DeletePersonAsync(person.Id);

View File

@@ -1,7 +1,9 @@
@using Butter.Dtos.Person
@using Butter.Dtos.Album
@using MilkStream.Client.Services
@inject PersonService personService
@inject AlbumService albumService
@if (Show) {
<div class="modal-overlay" @onclick="OnClose">
@@ -35,6 +37,30 @@
<div class="mt-2 text-muted small">Crop: @(cropX?.ToString("F1") ?? "auto") × @(cropY?.ToString("F1") ?? "auto")</div>
}
</div>
<div class="mb-3">
<label class="form-label">Assign Albums</label>
@if (unassignedAlbums == null) {
<div class="text-muted small">Loading...</div>
} else if (unassignedAlbums.Count == 0) {
<div class="text-muted small">All albums are already assigned.</div>
} else {
<div class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
@foreach (var album in unassignedAlbums) {
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@("album-" + album.Id)"
checked="@selectedAlbumIds.Contains(album.Id)"
@onchange="(e) => ToggleAlbum(album.Id, (bool)(e.Value ?? false))" />
<label class="form-check-label" for="@("album-" + album.Id)">
<span class="album-select-name">@album.Name</span>
<span class="album-select-count text-muted small ms-1">@album.AssetCount items</span>
</label>
</div>
}
</div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="OnClose">Cancel</button>
@@ -95,8 +121,11 @@
float? cropZoom;
bool showAssetPicker;
bool showCropper;
List<AlbumPreviewDto>? unassignedAlbums;
HashSet<Guid> selectedAlbumIds = [];
Guid? createdPersonId;
protected override void OnParametersSet() {
protected override async Task OnParametersSetAsync() {
if (Show && EditPerson != null) {
name = EditPerson.Name;
profileAssetId = EditPerson.ProfileAssetId;
@@ -110,6 +139,14 @@
cropY = null;
cropZoom = null;
}
if (Show && unassignedAlbums == null) {
unassignedAlbums = await albumService.GetUnassignedAsync();
}
if (!Show) {
unassignedAlbums = null;
selectedAlbumIds.Clear();
createdPersonId = null;
}
}
void OpenAssetPicker() {
@@ -142,7 +179,14 @@
showCropper = false;
}
void ToggleAlbum(Guid id, bool selected) {
if (selected) selectedAlbumIds.Add(id);
else selectedAlbumIds.Remove(id);
}
async Task OnSave() {
Guid? personId = EditPerson?.Id;
if (EditPerson == null) {
var dto = new PersonCreateDto {
Name = name,
@@ -151,7 +195,8 @@
ProfileCropY = cropY,
ProfileCropZoom = cropZoom
};
await personService.CreatePersonAsync(dto);
personId = await personService.CreatePersonAsync(dto);
createdPersonId = personId;
} else {
var dto = new PersonUpdateDto {
Name = name,
@@ -163,6 +208,12 @@
await personService.UpdatePersonAsync(EditPerson.Id, dto);
}
if (personId.HasValue && selectedAlbumIds.Count > 0) {
foreach (var albumId in selectedAlbumIds) {
await albumService.AssignPersonAsync(albumId, personId.Value);
}
}
await OnSaved.InvokeAsync();
}

View File

@@ -46,6 +46,30 @@ public sealed class AlbumService(
return null;
}
/// <summary>
/// Gets all albums that have no person assigned.
/// </summary>
/// <returns>A list of unassigned album previews, or null if the request failed.</returns>
public async Task<List<AlbumPreviewDto>?> GetUnassignedAsync() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/album/unassigned"));
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<List<AlbumPreviewDto>>();
return null;
}
/// <summary>
/// Assigns a person to an album.
/// </summary>
/// <param name="albumId">The album ID.</param>
/// <param name="personId">The person ID to assign.</param>
public async Task AssignPersonAsync(Guid albumId, Guid personId) {
var dto = new AlbumUpdateDto { Person = personId };
var response = await Client.PostAsJsonAsync($"/api/album/{albumId}", dto);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Creates a new album.
/// </summary>