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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user