feat: add bulk delete and visibility change to AlbumDetail select mode

- Fix AssetController.BulkUpdate/BulkDelete — add missing Save() call
- Add BulkUpdateAssetsAsync and BulkDeleteAssetsAsync to AssetService
- Rename 'Remove N' → 'Unlink N' (removes from album, original behavior)
- Add 'Delete N' button — soft-deletes selected assets via BulkDelete
- Add 'Visibility' button — opens ModalFrame to pick Public/Protected/Private
- Apply calls BulkUpdateAssetsAsync with chosen visibility level
This commit is contained in:
2026-07-15 21:46:28 +02:00
parent ec287f087c
commit fff5ea65df
3 changed files with 111 additions and 7 deletions
+2
View File
@@ -207,6 +207,7 @@ public class AssetController(
);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
@@ -273,6 +274,7 @@ public class AssetController(
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
@@ -1,10 +1,12 @@
@page "/albums/{Id:guid}"
@using Butter.Dtos.Album
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@@ -56,10 +58,16 @@
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
@if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="RemoveSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Remove @selectedImageIds.Count
<button class="btn btn-sm btn-secondary" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="CancelSelectMode">
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
} else {
@@ -79,8 +87,18 @@
<ul class="dropdown-menu dropdown-menu-end">
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="RemoveSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Remove @selectedImageIds.Count
<button class="dropdown-item" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
@@ -171,6 +189,39 @@
</ImagePreview>
}
@if (showVisibilityModal) {
<ModalFrame Title="Change Visibility" Show="true" OnClose="CloseVisibilityModal" Size="normal">
<Body>
<div class="d-flex flex-column gap-3">
<p class="text-muted mb-0">Select the visibility level for the @selectedImageIds.Count selected asset(s):</p>
@foreach (var vis in new[] { EVisibility.Public, EVisibility.Protected, EVisibility.Private }) {
<button class="btn @(selectedVisibility == vis ? "btn-primary" : "btn-outline-primary") w-100 text-start"
@onclick="() => selectedVisibility = vis">
@switch (vis) {
case EVisibility.Public:
<i class="bi bi-globe me-2"></i>
<span>Public</span>
break;
case EVisibility.Protected:
<i class="bi bi-shield-check me-2"></i>
<span>Protected</span>
break;
case EVisibility.Private:
<i class="bi bi-lock me-2"></i>
<span>Private</span>
break;
}
</button>
}
</div>
</Body>
<Footer>
<button class="btn btn-secondary" @onclick="CloseVisibilityModal">Cancel</button>
<button class="btn btn-primary" @onclick="ApplyVisibility">Apply</button>
</Footer>
</ModalFrame>
}
@code {
[Parameter]
public Guid Id { get; set; }
@@ -179,6 +230,8 @@
bool isLoading = true;
bool showForm;
bool selectMode;
bool showVisibilityModal;
EVisibility selectedVisibility = EVisibility.Public;
HashSet<Guid> selectedImageIds = [];
Guid? selectedAssetId;
int selectedIndex = -1;
@@ -283,7 +336,7 @@
selectedImageIds.Add(id);
}
async Task RemoveSelectedImages() {
async Task UnlinkSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
var remaining = album.Images.Where(id => !selectedImageIds.Contains(id)).ToList();
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = remaining });
@@ -292,6 +345,32 @@
await LoadAlbum();
}
async Task DeleteSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
await assetService.BulkDeleteAssetsAsync([.. selectedImageIds]);
selectMode = false;
selectedImageIds.Clear();
await LoadAlbum();
}
void OpenVisibilityModal() {
showVisibilityModal = true;
selectedVisibility = EVisibility.Public;
}
void CloseVisibilityModal() {
showVisibilityModal = false;
}
async Task ApplyVisibility() {
if (album == null || selectedImageIds.Count == 0) return;
await assetService.BulkUpdateAssetsAsync([.. selectedImageIds], new AssetUpdateDto { Visibility = selectedVisibility });
showVisibilityModal = false;
selectMode = false;
selectedImageIds.Clear();
await LoadAlbum();
}
string GetTileClass(bool isSelected) {
if (!selectMode) return "masonry-tile";
return isSelected ? "masonry-tile masonry-tile-selected" : "masonry-tile";
+24 -1
View File
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
namespace MilkStream.Client.Services;
/// <summary>
/// Provides access to asset listing and searching via the API.
/// Provides access to asset listing, searching, and bulk operations via the API.
/// </summary>
public sealed class AssetService(
IOptions<ServiceOptions> options,
@@ -39,4 +39,27 @@ public sealed class AssetService(
return null;
}
/// <summary>
/// Bulk updates selected assets (e.g. visibility, soft-delete).
/// </summary>
/// <param name="ids">The asset IDs to update.</param>
/// <param name="dto">The shared update values.</param>
public async Task<bool> BulkUpdateAssetsAsync(List<Guid> ids, AssetUpdateDto dto) {
var payload = new BulkDto<AssetUpdateDto> { Ids = ids, Data = dto };
var response = await Client.PostAsJsonAsync("/api/asset", payload);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Bulk soft-deletes selected assets.
/// </summary>
/// <param name="ids">The asset IDs to delete.</param>
public async Task<bool> BulkDeleteAssetsAsync(List<Guid> ids) {
var request = new HttpRequestMessage(HttpMethod.Delete, "/api/asset") {
Content = JsonContent.Create(ids)
};
var response = await Client.SendAsync(request);
return response.IsSuccessStatusCode;
}
}