feat: add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page

This commit is contained in:
2026-07-16 23:03:37 +02:00
parent eae6152738
commit 75e57353e7
4 changed files with 204 additions and 62 deletions
@@ -45,6 +45,7 @@
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(AdminOrCurator) {
<li><a class="dropdown-item" @onclick="OnMaintenanceClick"><i class="bi bi-tools"></i> Maintenance</a></li>
<li><a class="dropdown-item" @onclick="OnStatsClick"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
}
@if(Admin) {
@@ -81,6 +82,7 @@
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnMaintenanceClick() => navigation.NavigateTo("/maintenance");
void OnStatsClick() => navigation.NavigateTo("/stats");
void OnUsersClick() => navigation.NavigateTo("/users");
@@ -0,0 +1,28 @@
@page "/maintenance"
@using MilkStream.Client.Components.Shared
@inject LoginService loginService
@inject NavigationManager navigationManager
<PageTitle>Asset Maintenance</PageTitle>
@if (!isAuthorized) {
<EmptyState Variant="danger" Title="Access Denied">
<p>You do not have permission to access this page.</p>
</EmptyState>
} else {
<AlbumAssetPicker Show="true"
Mode="AlbumAssetPicker.PickerMode.Maintenance"
OnClosed="GoHome" />
}
@code {
bool isAuthorized => loginService.IsAdminOrCurator;
void GoHome() => navigationManager.NavigateTo("/");
protected override void OnInitialized() {
if (!isAuthorized)
GoHome();
}
}
@@ -6,7 +6,7 @@
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
<ModalFrame Title="Add Assets to Album" Show="Show" Size="lg" OnClose="OnCancel">
<ModalFrame Title="@(Mode == PickerMode.Maintenance ? "Asset Browser" : "Add Assets to Album")" Show="Show" Size="lg" OnClose="OnCancel">
<Body>
@if (isLoading && groups == null) {
<LoadSpinner/>
@@ -54,6 +54,14 @@
type="search" placeholder="Search by filename..."
@bind="searchQuery"
@bind:after="OnSearchChanged" />
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView" title="@(_listView ? "Grid view" : "List view")">
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
</button>
@if (Mode == PickerMode.Maintenance && selectedIds.Count > 0) {
<button class="btn btn-sm btn-danger" @onclick="ConfirmDeleteSelected">
<i class="bi bi-trash"></i> Delete @selectedIds.Count
</button>
}
</div>
@if (browseResult?.Directories.Count > 0) {
@@ -79,8 +87,98 @@
</p>
</EmptyState>
} else if (browseResult.Assets.Count > 0) {
@if (_listView) {
<div class="picker-list">
@foreach (var asset in browseResult.Assets) {
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id"
@onclick="() => ToggleSelection(asset.Id)">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
</div>
}
</div>
} else {
<div class="picker-grid">
@foreach (var asset in browseResult.Assets) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id"
@onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@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>
}
<div class="picker-tile-check">
@if (selectedIds.Contains(asset.Id)) {
<i class="bi bi-check-circle-fill"></i>
} else {
<i class="bi bi-circle"></i>
}
</div>
</div>
}
</div>
}
<div class="picker-pagination">
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">
Previous
</button>
<span class="text-muted small">@(browseResult.TotalAssetCount) assets</span>
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">
Next
</button>
</div>
}
}
} else if (!isAdminOrCurator && selectedGroup != null) {
<div class="picker-toolbar">
<input class="form-control form-control-sm picker-search"
type="search" placeholder="Search by filename..."
@bind="searchQuery"
@bind:after="OnSearchChanged" />
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _listView = !_listView" title="@(_listView ? "Grid view" : "List view")">
<i class="bi @(_listView ? "bi-grid-3x3" : "bi-list-ul")"></i>
</button>
@if (Mode == PickerMode.Maintenance && selectedIds.Count > 0) {
<button class="btn btn-sm btn-danger" @onclick="ConfirmDeleteSelected">
<i class="bi bi-trash"></i> Delete @selectedIds.Count
</button>
}
</div>
@if (assetPage == null || assetPage.Count == 0) {
<EmptyState Variant="info" Title="No unlinked assets found">
<p>
@if (!string.IsNullOrEmpty(searchQuery)) {
<span>No assets matching "@searchQuery".</span>
} else {
<span>You have no unlinked uploads.</span>
}
</p>
</EmptyState>
} else {
@if (_listView) {
<div class="picker-list">
@foreach (var asset in assetPage) {
<div class="picker-list-item @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id"
@onclick="() => ToggleSelection(asset.Id)">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
<span class="list-name">@(asset.FileName ?? asset.Id.ToString())</span>
</div>
}
</div>
} else {
<div class="picker-grid">
@foreach (var asset in browseResult.Assets) {
@foreach (var asset in assetPage) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id"
@onclick="() => ToggleSelection(asset.Id)">
@@ -106,63 +204,7 @@
</div>
}
</div>
<div class="picker-pagination">
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">
Previous
</button>
<span class="text-muted small">@(browseResult.TotalAssetCount) assets</span>
<button class="btn btn-sm btn-secondary" @onclick="LoadNext" disabled="@(!hasMore)">
Next
</button>
</div>
}
}
} else if (!isAdminOrCurator && selectedGroup != null) {
<div class="picker-toolbar">
<input class="form-control form-control-sm picker-search"
type="search" placeholder="Search by filename..."
@bind="searchQuery"
@bind:after="OnSearchChanged" />
</div>
@if (assetPage == null || assetPage.Count == 0) {
<EmptyState Variant="info" Title="No unlinked assets found">
<p>
@if (!string.IsNullOrEmpty(searchQuery)) {
<span>No assets matching "@searchQuery".</span>
} else {
<span>You have no unlinked uploads.</span>
}
</p>
</EmptyState>
} else {
<div class="picker-grid">
@foreach (var asset in assetPage) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id"
@onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@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>
}
<div class="picker-tile-check">
@if (selectedIds.Contains(asset.Id)) {
<i class="bi bi-check-circle-fill"></i>
} else {
<i class="bi bi-circle"></i>
}
</div>
</div>
}
</div>
<div class="picker-pagination">
<button class="btn btn-sm btn-secondary" @onclick="LoadPrevious" disabled="@(currentPage <= 0)">
Previous
@@ -182,18 +224,31 @@
}
</Body>
<Footer>
<button class="btn btn-secondary" @onclick="OnCancel">Cancel</button>
<button class="btn btn-success" @onclick="AddSelected" disabled="@(selectedIds.Count == 0)">
Add @selectedIds.Count asset(s)
</button>
<button class="btn btn-secondary" @onclick="OnCancel">@(Mode == PickerMode.Maintenance ? "Close" : "Cancel")</button>
@if (Mode == PickerMode.AddToAlbum) {
<button class="btn btn-success" @onclick="AddSelected" disabled="@(selectedIds.Count == 0)">
Add @selectedIds.Count asset(s)
</button>
}
</Footer>
</ModalFrame>
<ConfirmDialog Show="_showDeleteConfirm"
Title="Confirm Deletion"
Message="@_deleteConfirmMessage"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDeleteSelected"
OnCancel="() => _showDeleteConfirm = false" />
@code {
[Parameter] public bool Show { get; set; }
[Parameter] public List<Guid>? ExistingAssetIds { get; set; }
[Parameter] public EventCallback<List<Guid>> OnSelected { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
[Parameter] public PickerMode Mode { get; set; } = PickerMode.AddToAlbum;
public enum PickerMode { AddToAlbum, Maintenance }
List<UnlinkedAssetGroupDto>? groups;
UnlinkedAssetGroupDto? selectedGroup;
@@ -209,6 +264,9 @@
Timer? _searchTimer;
private bool _initializing;
private bool _sidebarCollapsed;
private bool _listView;
private bool _showDeleteConfirm;
private string _deleteConfirmMessage = "";
bool isAdminOrCurator => loginService.IsAdminOrCurator;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
@@ -358,6 +416,19 @@
await OnClosed.InvokeAsync();
}
async Task ConfirmDeleteSelected() {
_deleteConfirmMessage = $"Delete {selectedIds.Count} selected asset(s)? This cannot be undone.";
_showDeleteConfirm = true;
}
async Task ExecuteDeleteSelected() {
_showDeleteConfirm = false;
if (selectedIds.Count == 0) return;
await assetService.BulkDeleteAssetsAsync([.. selectedIds]);
selectedIds.Clear();
await LoadContent();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
static string TruncateName(string name, int maxLen = 40) {
@@ -243,3 +243,44 @@
white-space: nowrap;
max-width: 180px;
}
.picker-list {
flex: 1;
overflow-y: auto;
}
.picker-list-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
cursor: pointer;
border-radius: 3px;
font-size: 0.85rem;
color: var(--bs-body-color);
transition: background 0.1s;
}
.picker-list-item:hover {
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.06);
}
.picker-list-item.selected {
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.12);
}
.picker-list-item .bi-circle,
.picker-list-item .bi-check-circle-fill {
font-size: 1rem;
flex-shrink: 0;
}
.picker-list-item .bi-check-circle-fill {
color: var(--bs-success, #198754);
}
.picker-list-item .list-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}