feat: add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user