Merge pull request 'feat: restore soft-deleted assets (#198)' (#200) from feature/restore-softdeleted-assets into develop

Reviewed-on: #200
This commit was merged in pull request #200.
This commit is contained in:
2026-08-22 11:07:51 +00:00
15 changed files with 780 additions and 10 deletions
@@ -39,6 +39,19 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// </summary>
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets whether to filter exclusively for soft-deleted assets.
/// Results remain scoped by the requester's access level.
/// </summary>
public bool DeletedOnly { get; set; }
/// <summary>
/// Gets or sets the album ID to filter assets belonging to that album.
/// </summary>
public Guid? AlbumId { get; set; }
/// <summary>
/// Gets or sets the person ID to filter assets in albums owned by that person.
/// </summary>
public Guid? PersonId { get; set; }
/// <summary>
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
/// Skipping it avoids a full count query over the filtered result set.
/// </summary>
+4 -2
View File
@@ -11,9 +11,11 @@ public class AssetUpdateDto {
/// </summary>
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets the deletion date to set on the asset.
/// Gets or sets the soft-delete state to apply to the asset.
/// <see langword="true"/> soft-deletes the asset, <see langword="false"/> restores it,
/// and <see langword="null"/> leaves the current state unchanged.
/// </summary>
public DateTime? DeletedAt { get; set; }
public bool? IsDeleted { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
+15
View File
@@ -0,0 +1,15 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Reports the outcome of a bulk restore operation on soft-deleted assets.
/// </summary>
public class BulkRestoreResultDto {
/// <summary>
/// Gets or sets the IDs of assets that were successfully restored.
/// </summary>
public List<Guid> Restored { get; set; } = [];
/// <summary>
/// Gets or sets the IDs of assets that were skipped because their source file no longer exists on disk.
/// </summary>
public List<Guid> SkippedMissingFile { get; set; } = [];
}
+54 -4
View File
@@ -104,7 +104,8 @@ public class AssetController(
searchOptionsDto.Page, searchOptionsDto.PageSize,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
searchOptionsDto.IncludeCount, cancellationToken
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
cancellationToken
);
logger.LogTrace(
@@ -239,8 +240,18 @@ public class AssetController(
asset.Visibility = dto.Visibility ?? asset.Visibility;
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
if (dto.IsDeleted.HasValue) {
// Restoring a soft-deleted asset requires its source file to still exist on disk;
// otherwise the crawl/integrity jobs would silently re-delete it on their next run.
if (!dto.IsDeleted.Value && asset.DeletedAt != null && !System.IO.File.Exists(asset.OriginalPath)) {
logger.LogWarning($"Cannot restore asset {id}: source file '{asset.OriginalPath}' does not exist on disk!");
return Conflict();
}
DateTime? newDeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null;
log += $"DeletedAt: {asset.DeletedAt} -> {newDeletedAt}\n";
asset.DeletedAt = newDeletedAt;
}
if (accesslevel == EAccessLevel.Admin) {
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
@@ -286,10 +297,17 @@ public class AssetController(
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
: enumerable.Where(x => x.UploadedBy == uid).ToList();
if (bulkDto.Data.IsDeleted == false) {
var result = ApplyBulkRestore(enumerable, bulkDto.Data);
assetRepository.UpdateBulk(enumerable);
await assetRepository.SaveAsync(cancellationToken);
return Ok(result);
}
enumerable.ForEach(
x => {
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
@@ -302,6 +320,38 @@ public class AssetController(
return Ok();
}
/// <summary>
/// Applies a restore request (<c>IsDeleted</c> = <see langword="false"/>) to the given assets.
/// Assets whose source file no longer exists on disk are skipped and reported instead of restored,
/// since the crawl/integrity jobs would re-delete them otherwise. All other update fields are still
/// applied to the assets that are not skipped.
/// </summary>
/// <param name="assets">The assets selected for the bulk update.</param>
/// <param name="data">The shared update values.</param>
/// <returns>The outcome listing restored IDs and IDs skipped due to missing files.</returns>
private BulkRestoreResultDto ApplyBulkRestore(List<Asset> assets, AssetUpdateDto data) {
BulkRestoreResultDto result = new();
foreach (var x in assets) {
if (x.DeletedAt != null && !System.IO.File.Exists(x.OriginalPath)) {
result.SkippedMissingFile.Add(x.Id);
continue;
}
bool wasRestored = x.DeletedAt != null;
x.Visibility = data.Visibility ?? x.Visibility;
x.DeletedAt = null;
if (wasRestored) result.Restored.Add(x.Id);
}
if (result.SkippedMissingFile.Count > 0)
logger.LogWarning($"Bulk restore: {result.SkippedMissingFile.Count} asset(s) skipped because their source files are missing on disk");
return result;
}
/// <summary>
/// Soft-deletes an asset by its ID.
/// </summary>
+7 -1
View File
@@ -271,7 +271,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
/// <inheritdoc />
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, CancellationToken cancellationToken) {
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken) {
var query = context.Assets.AsNoTracking().AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
@@ -281,6 +281,12 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
query = query.Where(a => a.UpdatedAt <= to.Value);
if (unlinked)
query = query.Where(a => a.Albums!.Count == 0);
if (deletedOnly)
query = query.Where(a => a.DeletedAt != null);
if (albumId.HasValue)
query = query.Where(a => a.Albums!.Any(al => al.Id == albumId.Value));
if (personId.HasValue)
query = query.Where(a => a.Albums!.Any(al => al.PersonOwnerId == personId.Value));
if (folderId.HasValue)
query = query.Where(a => a.FolderId == folderId.Value);
if (uploadedBy.HasValue)
+4 -1
View File
@@ -282,9 +282,12 @@ public interface IAssetRepository : IDisposable {
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
/// <param name="includeCount">If true (default), computes the total matching count. When false, the returned total is zero and the count query is skipped.</param>
/// <param name="deletedOnly">If true, filters exclusively for soft-deleted assets. Results remain scoped by the requester's access level.</param>
/// <param name="albumId">Optional album ID to filter assets belonging to that album.</param>
/// <param name="personId">Optional person ID to filter assets in albums owned by that person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A paginated collection of asset previews matching the filters, and the total matching count.</returns>
Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, CancellationToken cancellationToken);
Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken);
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
+144
View File
@@ -2178,6 +2178,150 @@ GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
}
%}
# ---------------------------------------------------------------------------
# Restore soft-deleted assets (issue #198)
# ---------------------------------------------------------------------------
### 131.1 Admin soft-deletes the test asset (IsDeleted = true)
POST {{WepApiTest_HostAddress}}/api/asset
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"ids": ["{{testAssetId}}"],
"data": { "isDeleted": true }
}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Soft-delete asset via IsDeleted flag", function () {
client.assert(response.status === 200)
});
} else {
client.test("No assets — skipping soft-delete test", function () {
client.assert(true)
});
}
%}
### 131.2 Admin: deletedOnly search lists the soft-deleted asset
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{admin_token}}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Admin sees deleted assets in deletedOnly search", function () {
client.assert(response.status === 200);
var total = parseInt(response.headers.valueOf("X-Total-Count"));
client.assert(total >= 1, "Expected at least one deleted asset but got total " + total);
});
} else {
client.test("No assets — skipping deletedOnly search test", function () {
client.assert(true)
});
}
%}
### 131.3 Regular user: deletedOnly search returns nothing (R7 ? users never see deleted)
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{user_token}}
> {%
client.test("Regular user gets empty deletedOnly results", function () {
client.assert(response.status === 200);
client.assert(response.body.length === 0, "Expected no deleted assets for regular user");
});
%}
### 131.4 Curator: deletedOnly search succeeds (scoped to own uploads by R7)
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{curator_token}}
> {%
client.test("Curator can query deletedOnly search", function () {
client.assert(response.status === 200)
});
%}
### 131.5 Anonymous: restore attempt should 401
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Content-Type: application/json
{
"isDeleted": false
}
> {%
client.test("Anonymous restore attempt is rejected", function () {
client.assert([401, 403].indexOf(response.status) !== -1, "Expected 401/403 but got " + response.status);
});
%}
### 131.6 Regular user: restore attempt should 403
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Authorization: Bearer {{user_token}}
Content-Type: application/json
{
"isDeleted": false
}
> {%
client.test("Regular user restore attempt is rejected", function () {
client.assert(response.status === 403, "Expected 403 but got " + response.status);
});
%}
### 131.7 Admin: bulk restore via update endpoint (IsDeleted = false)
# Assets whose source files are missing on disk are reported in skippedMissingFile
# instead of being restored; a bare 200 therefore always indicates a valid outcome.
POST {{WepApiTest_HostAddress}}/api/asset
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"ids": ["{{testAssetId}}"],
"data": { "isDeleted": false }
}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Bulk restore reports restored/skipped lists", function () {
client.assert(response.status === 200);
client.assert(jsonPath(response.body, "$.restored") != null, "Missing 'restored' list");
client.assert(jsonPath(response.body, "$.skippedMissingFile") != null, "Missing 'skippedMissingFile' list");
});
var restored = response.body.restored;
client.global.set("assetRestored", restored != null && restored.indexOf(client.global.get("testAssetId")) !== -1 ? "true" : "false");
} else {
client.global.set("assetRestored", "false");
client.test("No assets — skipping bulk restore test", function () {
client.assert(true)
});
}
%}
### 131.8 Verify restored asset is live again (skipped when its file was missing on disk)
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Authorization: Bearer {{admin_token}}
> {%
var assetRestored = client.global.get("assetRestored");
if (assetRestored === "true") {
client.test("Restored asset no longer reports DeletedAt", function () {
client.assert(response.status === 200);
client.assert(response.body.deletedAt === null, "deletedAt should be cleared after restore");
});
} else {
client.test("Asset not restored (missing file) — skipping verification", function () {
client.assert(true)
});
}
%}
# =============================================================================
# CLEANUP
# =============================================================================
@@ -256,6 +256,11 @@
<button class="btn btn-primary btn-sm" @onclick="() => passwordUser = editUser">
<i class="bi bi-key"></i> Change Password
</button>
@if (editUser.DeletedAt != null) {
<button class="btn btn-success btn-sm" @onclick="() => ToggleDeleted(editUser, false)">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
}
<button class="btn btn-danger btn-sm" disabled="@(editUser.DeletedAt != null)" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete
</button>
@@ -519,6 +524,21 @@
}
}
private async Task ToggleDeleted(UserInfoDto user, bool deleted) {
var dto = new UserUpdateDto { Id = user.Id, IsDeleted = deleted };
var (result, _) = await UserService.UpdateUserAsync(dto);
if (result != null) {
successMessage = deleted
? $"'{user.Username}' has been deleted."
: $"'{user.Username}' has been restored.";
var idx = users?.FindIndex(u => u.Id == user.Id) ?? -1;
if (idx >= 0) users![idx] = result;
if (editUser?.Id == user.Id) editUser = result;
} else {
error = $"Failed to {(deleted ? "delete" : "restore")} user.";
}
}
private async Task ConfirmDelete() {
if (editUser == null) return;
showDeleteConfirm = true;
@@ -50,6 +50,10 @@
@if (selectMode) {
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedImages.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedImages.Count == 0)" OnClick="DeleteSelectedImages" />
@if (selectedDeletedCount > 0) {
<ActionButton Icon="bi-arrow-counterclockwise" Label="@($"Restore {selectedDeletedCount}")" Class="btn-success" MobileClass="text-success"
OnClick="RestoreSelectedImages" />
}
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedImages.Count}")" Class="btn-warning" MobileClass="text-warning"
Disabled="@(selectedImages.Count == 0)" OnClick="UnlinkSelectedImages" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
@@ -155,6 +159,14 @@
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
<ConfirmDialog Show="restoreConfirm.Show"
Title="Confirm Restore"
Message="@restoreConfirm.Message"
ConfirmText="Restore"
ConfirmButtonClass="btn-success"
OnConfirm="ExecuteRestorePending"
OnCancel="restoreConfirm.Cancel" />
@if (showAssetPicker && album != null) {
<AlbumAssetPicker Show="true"
OnSelected="OnAssetsAdded"
@@ -171,6 +183,7 @@
bool selectMode;
bool showAssetPicker;
DeleteConfirmation deleteConfirm = new();
DeleteConfirmation restoreConfirm = new();
VisibilityApplyState visibility = new();
MultiSelect<Guid> selectedImages = new();
SelectionRange assetSelectionRange = new();
@@ -185,6 +198,19 @@
bool allSelected => album != null && selectedImages.IsAllSelected(album.Images);
// Deleted tiles are only selectable in edit mode; this counts how many of the
// currently selected assets are soft-deleted and thus eligible for restore.
int selectedDeletedCount {
get {
if (album == null || selectedImages.Count == 0) return 0;
var count = 0;
for (var i = 0; i < album.Images.Count && i < album.AssetPreviews.Count; i++)
if (selectedImages.Contains(album.Images[i]) && album.AssetPreviews[i].DeletedAt != null)
count++;
return count;
}
}
void ToggleSelectAll() {
if (album == null) return;
selectedImages.ToggleAll(album.Images);
@@ -337,6 +363,28 @@
await LoadAlbum();
}
void RestoreSelectedImages() {
var ids = album?.AssetPreviews
.Where(p => p.DeletedAt != null && selectedImages.Contains(p.Id))
.Select(p => p.Id)
.ToList();
if (ids == null || ids.Count == 0) return;
restoreConfirm.Confirm(
$"Restore {ids.Count} selected asset{(ids.Count == 1 ? "" : "s")}?",
() => ExecuteRestoreSelectedImages(ids)
);
}
async Task ExecuteRestoreSelectedImages(List<Guid> ids) {
if (album == null) return;
await assetService.RestoreAssetsAsync(ids);
selectMode = false;
selectedImages.Clear();
await LoadAlbum();
}
async Task ExecuteRestorePending() => await restoreConfirm.ExecuteAsync();
void OpenVisibilityModal() => visibility.Open();
async Task ApplyVisibility() {
@@ -16,6 +16,11 @@
<i class="bi bi-exclamation-triangle"></i> Broken Assets
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == Tab.Deleted ? "active" : "")" @onclick="() => SetTab(Tab.Deleted)">
<i class="bi bi-trash3"></i> Deleted Assets
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == Tab.Browse ? "active" : "")" @onclick="() => SetTab(Tab.Browse)">
<i class="bi bi-folder"></i> Browse Folders
@@ -25,13 +30,15 @@
@if (activeTab == Tab.Broken) {
<BrokenAssetList />
} else if (activeTab == Tab.Deleted) {
<DeletedAssetList />
} else {
<FolderBrowser />
}
}
@code {
enum Tab { Broken, Browse }
enum Tab { Broken, Deleted, Browse }
Tab activeTab = Tab.Broken;
bool isAuthorized => loginService.IsAdmin;
@@ -0,0 +1,135 @@
@using Butter.Dtos.Album
@inject AlbumService albumService
@inject IJSRuntime JSRuntime
<div class="position-relative">
<input type="text" class="form-control"
placeholder="@Placeholder"
value="@Query"
@ref="albumInputRef"
@oninput="OnInput"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showAlbumDropdown) {
<div class="dropdown-menu show album-dropdown-menu"
@ref="albumDropdownRef">
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (Query.Length < 2) {
<span class="dropdown-item disabled">Type at least 2 characters to search</span>
} else if (searchResults is null || searchResults.Count == 0) {
<span class="dropdown-item disabled">No matching albums</span>
} else {
@foreach (var album in searchResults) {
<button type="button"
class="dropdown-item @(SelectedAlbumId == album.Id ? "active" : "")"
@onclick="() => SelectAlbum(album)">
@album.Name
@if (!string.IsNullOrEmpty(album.PersonName)) {
<small class="text-muted ms-1">@album.PersonName</small>
}
</button>
}
}
</div>
}
</div>
@code {
/// <summary>
/// Gets or sets the current search text (two-way bound so the owner can prefill/reset it).
/// </summary>
[Parameter] public string Query { get; set; } = "";
/// <summary>
/// Gets or sets the callback fired as <see cref="Query"/> changes.
/// </summary>
[Parameter] public EventCallback<string> QueryChanged { get; set; }
/// <summary>
/// Gets or sets the currently selected album ID, used to highlight the active result.
/// </summary>
[Parameter] public Guid? SelectedAlbumId { get; set; }
/// <summary>
/// Gets or sets the callback fired when an album is selected.
/// </summary>
[Parameter] public EventCallback<AlbumPreviewDto> OnAlbumSelected { get; set; }
/// <summary>
/// Gets or sets the input placeholder text.
/// </summary>
[Parameter] public string Placeholder { get; set; } = "Search albums…";
bool showAlbumDropdown;
CancellationTokenSource? blurCts;
List<AlbumPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference albumInputRef;
ElementReference albumDropdownRef;
bool pendingPosition;
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showAlbumDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", albumInputRef, albumDropdownRef);
}
}
void OnFocus() {
blurCts?.Cancel();
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
_ = Search(Query);
}
}
async Task OnInput(ChangeEventArgs e) {
Query = e.Value?.ToString() ?? string.Empty;
await QueryChanged.InvokeAsync(Query);
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
await Search(Query);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await albumService.GetAlbumsAsync(page: 0, pageSize: 20, search: query);
if (version == searchVersion) {
isSearching = false;
}
StateHasChanged();
}
async Task OnBlur() {
blurCts?.Cancel();
blurCts = new CancellationTokenSource();
try {
await Task.Delay(150, blurCts.Token);
showAlbumDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showAlbumDropdown = false;
}
}
void SelectAlbum(AlbumPreviewDto album) {
Query = album.Name;
_ = QueryChanged.InvokeAsync(Query);
showAlbumDropdown = false;
searchResults = null;
_ = OnAlbumSelected.InvokeAsync(album);
}
}
@@ -0,0 +1,5 @@
.album-dropdown-menu {
display: block;
max-height: 250px;
overflow-y: auto;
}
@@ -0,0 +1,275 @@
@using Butter.Dtos.Asset
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using Butter.Dtos.User
@inject AssetService assetService
@inject UserService userService
@inject AlbumService albumService
@inject MediaService mediaService
<div class="d-flex flex-column gap-2 mb-3">
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label mb-0 small text-muted">Cosplayer</label>
<div class="d-flex gap-1 align-items-center">
<PersonSearchDropdown @bind-Query="personQuery"
SelectedPersonId="selectedPersonId"
OnPersonSelected="HandlePersonSelected"
Placeholder="Filter by cosplayer..." />
@if (selectedPersonId != null) {
<button type="button" class="btn btn-sm btn-outline-secondary" title="Clear cosplayer filter"
@onclick="ClearPersonFilter">
<i class="bi bi-x"></i>
</button>
}
</div>
</div>
<div>
<label class="form-label mb-0 small text-muted">Album</label>
<div class="d-flex gap-1 align-items-center">
<AlbumSearchDropdown @bind-Query="albumQuery"
SelectedAlbumId="selectedAlbumId"
OnAlbumSelected="HandleAlbumSelected"
Placeholder="Filter by album..." />
@if (selectedAlbumId != null) {
<button type="button" class="btn btn-sm btn-outline-secondary" title="Clear album filter"
@onclick="ClearAlbumFilter">
<i class="bi bi-x"></i>
</button>
}
</div>
</div>
<div>
<label class="form-label mb-0 small text-muted">Uploader</label>
<select class="form-select form-select-sm" value="@uploaderFilter" @onchange="HandleUploaderChanged">
<option value="">All uploaders</option>
@foreach (var user in users) {
<option value="@user.Id">@user.Username</option>
}
</select>
</div>
<div>
<label class="form-label mb-0 small text-muted">From</label>
<input type="date" class="form-control form-control-sm" value="@startDateFilter" @onchange="HandleStartDateChanged" />
</div>
<div>
<label class="form-label mb-0 small text-muted">To</label>
<input type="date" class="form-control form-control-sm" value="@endDateFilter" @onchange="HandleEndDateChanged" />
</div>
@if (HasActiveFilters) {
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearFilters">
<i class="bi bi-x-circle"></i> Clear
</button>
}
<span class="flex-grow-1"></span>
<span class="text-muted small">@selectedIds.Count selected</span>
<button class="btn btn-sm btn-success" @onclick="RestoreSelected" disabled="@(selectedIds.Count == 0)">
<i class="bi bi-arrow-counterclockwise"></i> Restore selected
</button>
</div>
@if (!string.IsNullOrEmpty(statusMessage)) {
<div class="alert alert-info py-1 px-2 mb-0 small" role="alert">@statusMessage</div>
}
</div>
@if (isLoading && assets == null) {
<LoadSpinner/>
} else if (assets == null) {
<EmptyState Variant="danger" Title="Could not load deleted assets">
<p>The deleted-assets report is unavailable. Check the server connection.</p>
</EmptyState>
} else if (assets.Count == 0) {
<EmptyState Variant="success" Title="No deleted assets match">
<p>Nothing has been soft-deleted for the current filters.</p>
</EmptyState>
} else {
<div class="table-responsive">
<table class="table table-sm table-hover align-middle deleted-table">
<thead>
<tr>
<th style="width:2rem"><input type="checkbox" checked="@allSelected" @onchange="ToggleAll" /></th>
<th style="width:3.5rem"></th>
<th>File</th>
<th style="width:10rem">Visibility</th>
<th style="width:12rem">Deleted at</th>
<th style="width:5rem"></th>
</tr>
</thead>
<tbody>
@foreach (var asset in assets) {
<tr>
<td><input type="checkbox" checked="@selectedIds.Contains(asset.Id)" @onchange="() => ToggleSelection(asset.Id)" /></td>
<td>
@if (asset.HasThumbnail) {
<img class="deleted-thumb" src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
<div class="tile-fallback"><i class="bi bi-image"></i></div>
}
</td>
<td>
<span class="fw-semibold">@(asset.FileName ?? asset.Id.ToString())</span>
<div class="text-muted small text-break">@asset.OriginalPath</div>
</td>
<td><span class="badge bg-secondary">@asset.Visibility</span></td>
<td class="text-muted small">@FormatDate(asset.DeletedAt)</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-success" title="Restore asset" @onclick="() => RestoreSingle(asset.Id)">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex gap-2 align-items-center mt-3">
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">Previous</button>
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">Next</button>
</div>
}
@code {
List<AssetPreviewDto>? assets;
HashSet<Guid> selectedIds = [];
int currentPage;
bool hasMore = true;
bool isLoading;
string? statusMessage;
List<UserInfoDto> users = [];
string uploaderFilter = "";
string? startDateFilter;
string? endDateFilter;
string albumQuery = "", personQuery = "";
Guid? selectedAlbumId, selectedPersonId;
const int PageSize = 50;
bool allSelected => assets != null && assets.Count > 0 && assets.All(a => selectedIds.Contains(a.Id));
bool HasActiveFilters => !string.IsNullOrEmpty(uploaderFilter)
|| !string.IsNullOrEmpty(startDateFilter) || !string.IsNullOrEmpty(endDateFilter)
|| selectedAlbumId != null || selectedPersonId != null;
protected override async Task OnInitializedAsync() {
users = await userService.GetAllUsersAsync() ?? [];
await Load();
}
async Task Load() {
isLoading = true;
try {
var uploaderId = Guid.TryParse(uploaderFilter, out var parsedUploader) ? parsedUploader : (Guid?)null;
assets = await assetService.GetAssetsAsync(
page: currentPage, pageSize: PageSize,
uploadedBy: uploaderId,
includeCount: true,
deletedOnly: true,
albumId: selectedAlbumId,
personId: selectedPersonId,
startDate: startDateFilter,
endDate: endDateFilter
);
hasMore = assets is { Count: > 0 } && assets.Count >= PageSize;
selectedIds.Clear();
} finally { isLoading = false; }
}
async Task HandlePersonSelected(PersonPreviewDto person) {
selectedPersonId = person.Id;
await ReloadFromStart();
}
async Task ClearPersonFilter() {
selectedPersonId = null;
personQuery = "";
await ReloadFromStart();
}
async Task HandleAlbumSelected(AlbumPreviewDto album) {
selectedAlbumId = album.Id;
await ReloadFromStart();
}
async Task ClearAlbumFilter() {
selectedAlbumId = null;
albumQuery = "";
await ReloadFromStart();
}
async Task HandleUploaderChanged(ChangeEventArgs e) {
uploaderFilter = e.Value?.ToString() ?? "";
await ReloadFromStart();
}
async Task HandleStartDateChanged(ChangeEventArgs e) {
startDateFilter = string.IsNullOrEmpty(e.Value?.ToString()) ? null : e.Value?.ToString();
await ReloadFromStart();
}
async Task HandleEndDateChanged(ChangeEventArgs e) {
endDateFilter = string.IsNullOrEmpty(e.Value?.ToString()) ? null : e.Value?.ToString();
await ReloadFromStart();
}
async Task ReloadFromStart() {
currentPage = 0;
statusMessage = null;
await Load();
}
async Task ClearFilters() {
uploaderFilter = "";
startDateFilter = null;
endDateFilter = null;
albumQuery = "";
personQuery = "";
selectedAlbumId = null;
selectedPersonId = null;
statusMessage = null;
currentPage = 0;
await Load();
}
async Task LoadPrevious() { if (currentPage <= 0) return; currentPage--; await Load(); }
async Task LoadNext() { if (!hasMore) return; currentPage++; await Load(); }
void ToggleSelection(Guid id) { if (selectedIds.Contains(id)) selectedIds.Remove(id); else selectedIds.Add(id); }
void ToggleAll() {
if (allSelected) selectedIds.Clear();
else if (assets != null) selectedIds = [.. assets.Select(a => a.Id)];
}
async Task RestoreSingle(Guid id) {
var result = await assetService.RestoreAssetsAsync([id]);
ReportRestoreOutcome(result, 1);
await Load();
}
async Task RestoreSelected() {
if (selectedIds.Count == 0) return;
var ids = selectedIds.ToList();
var result = await assetService.RestoreAssetsAsync(ids);
ReportRestoreOutcome(result, ids.Count);
await Load();
}
void ReportRestoreOutcome(BulkRestoreResultDto? result, int requested) {
if (result == null) {
statusMessage = "Restore request failed.";
return;
}
var parts = new List<string>();
if (result.Restored.Count > 0) parts.Add($"{result.Restored.Count} of {requested} restored");
if (result.SkippedMissingFile.Count > 0) parts.Add($"{result.SkippedMissingFile.Count} skipped — source file missing on disk");
statusMessage = parts.Count > 0 ? string.Join("; ", parts) + "." : "Nothing was restored.";
}
static string FormatDate(DateTime? dt) => dt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
}
@@ -0,0 +1,17 @@
.deleted-table img.deleted-thumb {
width: 3rem;
height: 3rem;
object-fit: cover;
border-radius: .25rem;
}
.deleted-table .tile-fallback {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--bs-secondary-bg);
border-radius: .25rem;
color: var(--bs-secondary-color);
}
+31 -1
View File
@@ -27,10 +27,17 @@ public sealed class AssetService(
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
/// <param name="search">Optional search term for filename matching.</param>
/// <param name="includeCount">If true, the API also computes the total matching count. Skipping it avoids a full count query.</param>
/// <param name="deletedOnly">If true, filters exclusively for soft-deleted assets (scoped by access level server-side).</param>
/// <param name="albumId">Optional album ID to filter assets belonging to that album.</param>
/// <param name="personId">Optional person ID to filter assets in albums owned by that person.</param>
/// <param name="startDate">Optional start date string (server-side <c>StartDate</c> filter on CreatedAt).</param>
/// <param name="endDate">Optional end date string (server-side <c>EndDate</c> filter on UpdatedAt).</param>
/// <returns>A list of asset previews, or null if the request failed.</returns>
public Task<List<AssetPreviewDto>?> GetAssetsAsync(
EAssetType? type = null, bool random = false, Guid? seed = null, int page = 0, int pageSize = PagedParametersDto.MaxPageSize,
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true,
bool deletedOnly = false, Guid? albumId = null, Guid? personId = null,
string? startDate = null, string? endDate = null
) {
var url = $"/api/asset?page={page}&pageSize={pageSize}&random={random}";
if (type.HasValue)
@@ -39,10 +46,20 @@ public sealed class AssetService(
url += $"&seed={seed.Value}";
if (unlinked)
url += "&unlinked=true";
if (deletedOnly)
url += "&deletedOnly=true";
if (folderId.HasValue)
url += $"&folderId={folderId.Value}";
if (uploadedBy.HasValue)
url += $"&uploadedBy={uploadedBy.Value}";
if (albumId.HasValue)
url += $"&albumId={albumId.Value}";
if (personId.HasValue)
url += $"&personId={personId.Value}";
if (!string.IsNullOrEmpty(startDate))
url += $"&startDate={Uri.EscapeDataString(startDate)}";
if (!string.IsNullOrEmpty(endDate))
url += $"&endDate={Uri.EscapeDataString(endDate)}";
if (!string.IsNullOrEmpty(search))
url += $"&search={Uri.EscapeDataString(search)}";
if (!includeCount)
@@ -119,4 +136,17 @@ public sealed class AssetService(
var response = await Client.SendAsync(request);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Restores soft-deleted assets by clearing their deletion timestamp.
/// Assets whose source files no longer exist on disk are skipped server-side and reported.
/// </summary>
/// <param name="ids">The asset IDs to restore.</param>
/// <returns>The restore outcome listing restored and skipped IDs, or null if the request failed.</returns>
public async Task<BulkRestoreResultDto?> RestoreAssetsAsync(List<Guid> ids) {
var payload = new BulkDto<AssetUpdateDto> { Ids = ids, Data = new AssetUpdateDto { IsDeleted = false } };
var response = await Client.PostAsJsonAsync("/api/asset", payload);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<BulkRestoreResultDto>();
}
}