Merge pull request 'refactor: extract shared components (C) — action toolbar, infinite-scroll, picker, state helpers, search' (#193) from feature/shared-components-c into develop

Reviewed-on: #193
This commit was merged in pull request #193.
This commit is contained in:
2026-08-21 03:18:51 +00:00
21 changed files with 1025 additions and 1230 deletions
@@ -46,89 +46,26 @@
<div class="album-detail-actions">
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
@if (loginService.CanEdit(album?.Person)) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<ResponsiveActionBar>
@if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
@(allSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedImages.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedImages.Count == 0)" OnClick="DeleteSelectedImages" />
<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"
Disabled="@(selectedImages.Count == 0)" OnClick="OpenVisibilityModal" />
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="@(allSelected ? "bi-x-square" : "bi-check2-square")" Label="@(allSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAll" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-sm btn-success" @onclick="OpenAssetPicker">
<i class="bi bi-plus-circle"></i> Add Assets
</button>
<button class="btn btn-sm btn-secondary" @onclick="EnterSelectMode">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-plus-circle" Label="Add Assets" Class="btn-success" OnClick="OpenAssetPicker" />
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="EnterSelectMode" />
}
<button class="btn btn-sm btn-danger" @onclick="ConfirmDelete" title="Delete album">
<i class="bi bi-trash"></i>
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAll">
<i class="bi @(allSelected ? "bi-x-square" : "bi-check2-square")"></i> @(allSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="OpenAssetPicker">
<i class="bi bi-plus-circle"></i> Add Assets
</button>
</li>
<li>
<button class="dropdown-item" @onclick="EnterSelectMode">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete album
</button>
</li>
</ul>
</div>
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete album" Class="btn-danger" MobileClass="text-danger"
Title="Delete album" OnClick="ConfirmDelete" />
</ResponsiveActionBar>
}
</div>
</div>
@@ -142,7 +79,7 @@
var preview = album.AssetPreviews.Count > i ? album.AssetPreviews[i] : null;
if (!selectMode && preview != null && (preview.DeletedAt != null || (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private))) continue;
var index = i;
var isSelected = selectedImageIds.Contains(assetId);
var isSelected = selectedImages.Contains(assetId);
<div class="@GetTileClass(isSelected, preview)" @key="assetId"
style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="(e) => HandleTileClick(index, assetId, e.ShiftKey)">
@@ -199,23 +136,24 @@
</ImagePreview>
}
@if (showVisibilityModal) {
@if (visibility.Show) {
<VisibilityModal Show="true"
ItemCount="@selectedImageIds.Count"
ItemCount="@selectedImages.Count"
ItemLabel="asset(s)"
Visibility="@selectedVisibility"
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
Visibility="visibility.Selection"
VisibilityChanged="@((EVisibility v) => visibility.Selection = v)"
Error="@visibility.Error"
OnApply="ApplyVisibility"
OnClose="CloseVisibilityModal" />
OnClose="visibility.Close" />
}
<ConfirmDialog Show="showDeleteConfirm"
<ConfirmDialog Show="deleteConfirm.Show"
Title="Confirm Deletion"
Message="@deleteConfirmMessage"
Message="@deleteConfirm.Message"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecutePendingDelete"
OnCancel="() => showDeleteConfirm = false" />
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
@if (showAssetPicker && album != null) {
<AlbumAssetPicker Show="true"
@@ -231,13 +169,10 @@
bool isLoading = true;
bool showForm;
bool selectMode;
bool showVisibilityModal;
bool showAssetPicker;
bool showDeleteConfirm;
string deleteConfirmMessage = "";
Func<Task>? pendingDeleteAction;
EVisibility selectedVisibility = EVisibility.Public;
HashSet<Guid> selectedImageIds = [];
DeleteConfirmation deleteConfirm = new();
VisibilityApplyState visibility = new();
MultiSelect<Guid> selectedImages = new();
SelectionRange assetSelectionRange = new();
Guid? selectedAssetId;
int selectedIndex = -1;
@@ -248,14 +183,11 @@
? album.AssetPreviews[selectedIndex]
: null;
bool allSelected => album != null && album.Images.Count > 0 && selectedImageIds.Count == album.Images.Count;
bool allSelected => album != null && selectedImages.IsAllSelected(album.Images);
void ToggleSelectAll() {
if (album == null) return;
if (allSelected)
selectedImageIds.Clear();
else
selectedImageIds.UnionWith(album.Images);
selectedImages.ToggleAll(album.Images);
}
string AssetStatsText {
@@ -300,7 +232,7 @@
selectedAssetId = null;
selectedIndex = -1;
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
album = await albumService.GetAlbumAsync(Id);
if (album != null) {
var seen = new Dictionary<Guid, int>();
@@ -339,24 +271,18 @@
navigationManager.NavigateTo("/albums");
}
async Task ConfirmDelete() {
void ConfirmDelete() {
if (album == null) return;
deleteConfirmMessage = $"Delete album '{album.Name}'? This cannot be undone.";
pendingDeleteAction = ExecuteDeleteAlbum;
showDeleteConfirm = true;
deleteConfirm.Confirm($"Delete album '{album.Name}'? This cannot be undone.", ExecuteDeleteAlbum);
}
async Task ExecuteDeleteAlbum() {
if (album == null) return;
showDeleteConfirm = false;
await albumService.DeleteAlbumAsync(album.Id);
navigationManager.NavigateTo("/albums");
}
async Task ExecutePendingDelete() {
if (pendingDeleteAction != null)
await pendingDeleteAction();
}
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
void OpenAssetPicker() {
showAssetPicker = true;
@@ -374,61 +300,51 @@
void EnterSelectMode() {
selectMode = true;
selectedImageIds.Clear();
selectedImages.Clear();
}
void CancelSelectMode() {
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
}
void HandleTileClick(int index, Guid assetId, bool shift) {
if (selectMode)
assetSelectionRange.Toggle(selectedImageIds, album?.Images ?? [], assetId, shift);
assetSelectionRange.Toggle(selectedImages, album?.Images ?? [], assetId, shift);
else
OpenPreview(index);
}
async Task UnlinkSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
var remaining = album.Images.Where(id => !selectedImageIds.Contains(id)).ToList();
if (album == null || selectedImages.Count == 0) return;
var remaining = album.Images.Where(id => !selectedImages.Contains(id)).ToList();
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = remaining });
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
async Task DeleteSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
deleteConfirmMessage = $"Delete {selectedImageIds.Count} selected asset{(selectedImageIds.Count == 1 ? "" : "s")}? This cannot be undone.";
pendingDeleteAction = ExecuteDeleteSelectedImages;
showDeleteConfirm = true;
void DeleteSelectedImages() {
if (album == null || selectedImages.Count == 0) return;
deleteConfirm.Confirm($"Delete {selectedImages.Count} selected asset{(selectedImages.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedImages);
}
async Task ExecuteDeleteSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
showDeleteConfirm = false;
await assetService.BulkDeleteAssetsAsync([.. selectedImageIds]);
if (album == null || selectedImages.Count == 0) return;
await assetService.BulkDeleteAssetsAsync(selectedImages.ToList());
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
void OpenVisibilityModal() {
showVisibilityModal = true;
selectedVisibility = EVisibility.Public;
}
void CloseVisibilityModal() {
showVisibilityModal = false;
}
void OpenVisibilityModal() => visibility.Open();
async Task ApplyVisibility() {
if (album == null || selectedImageIds.Count == 0) return;
await assetService.BulkUpdateAssetsAsync([.. selectedImageIds], new AssetUpdateDto { Visibility = selectedVisibility });
showVisibilityModal = false;
if (album == null || selectedImages.Count == 0) return;
await assetService.BulkUpdateAssetsAsync(selectedImages.ToList(), new AssetUpdateDto { Visibility = visibility.Selection });
visibility.MarkApplied();
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
+36 -104
View File
@@ -21,98 +21,33 @@
SortOptions="@sortOptions">
<ActionButtons>
@if (loginService.CanEditAll) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<ResponsiveActionBar>
@if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Link to Cosplayer
</button>
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
@(albumsAllSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
<ActionButton Icon="bi-link-45deg" Label="Link to Cosplayer" Class="btn-warning" MobileClass="btn-warning"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenLinkCosplayerModal" />
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenMergeModal" />
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
}
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Link to Cosplayer
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> New Album
</button>
</li>
</ul>
</div>
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="bi-plus-circle" Label="New Album" Class="btn-primary" OnClick="OpenCreateForm" />
</ResponsiveActionBar>
}
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
@if (loginService.CanEditAll) {
<button class="btn btn-primary btn-sm d-none d-md-inline-block" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
</button>
}
</ActionButtons>
</SortFilterBar>
@if (showVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedAlbumIds.Count"
ItemCount="@selectedAlbums.Count"
ItemLabel="album(s)"
Visibility="@selectedAlbumVisibility"
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
@@ -126,14 +61,14 @@
@if (showLinkCosplayerModal) {
<LinkToCosplayerModal Show="true"
AlbumCount="@selectedAlbumIds.Count"
AlbumCount="@selectedAlbums.Count"
OnLink="@LinkSelectedAlbumsToCosplayer"
OnClose="() => showLinkCosplayerModal = false" />
}
<ConfirmDialog Show="showDeleteConfirm"
Title="Confirm Deletion"
Message="@($"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
Message="@($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.")"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDeleteSelectedAlbums"
@@ -153,7 +88,7 @@
SortAsc="@sortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
SelectedIds="@selectedAlbums.Selected"
OnToggleSelection="@ToggleAlbumSelection"
LoadVersion="@loadVersion" />
@@ -179,8 +114,8 @@
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string? visibilityError;
HashSet<Guid> selectedAlbumIds = [];
bool albumsAllSelected => albumGridRef != null && selectedAlbumIds.Count == albumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0;
MultiSelect<Guid> selectedAlbums = new();
bool albumsAllSelected => albumGridRef != null && selectedAlbums.IsAllSelected(albumGridRef.GetCurrentAlbumIds());
[SupplyParameterFromQuery(Name = "search")] public string? searchQuery { get; set; }
string? sortBy;
bool sortAsc;
@@ -202,23 +137,23 @@
}
void ToggleAlbumSelection((Guid Id, bool Shift) args) {
albumSelectionRange.Toggle(selectedAlbumIds, albumGridRef?.GetCurrentAlbumIds() ?? [], args.Id, args.Shift);
albumSelectionRange.Toggle(selectedAlbums, albumGridRef?.GetCurrentAlbumIds() ?? [], args.Id, args.Shift);
}
void CancelSelectMode() {
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
}
async Task DeleteSelectedAlbums() {
if (selectedAlbumIds.Count == 0) return;
if (selectedAlbums.Count == 0) return;
showDeleteConfirm = true;
}
async Task ExecuteDeleteSelectedAlbums() {
showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync(selectedAlbumIds.ToList());
selectedAlbumIds.Clear();
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
selectedAlbums.Clear();
selectMode = false;
loadVersion++;
}
@@ -226,10 +161,7 @@
void ToggleSelectAllAlbums() {
if (albumGridRef == null) return;
var allIds = albumGridRef.GetCurrentAlbumIds();
if (albumsAllSelected)
selectedAlbumIds.Clear();
else
selectedAlbumIds.UnionWith(allIds);
selectedAlbums.ToggleAll(allIds);
}
void OpenVisibilityModal() {
@@ -250,7 +182,7 @@
void OpenMergeModal() {
if (albumGridRef is null) return;
mergeItems = albumGridRef.GetCurrentAlbumPreviews()
.Where(a => selectedAlbumIds.Contains(a.Id))
.Where(a => selectedAlbums.Contains(a.Id))
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
.ToList();
showMergeModal = true;
@@ -262,32 +194,32 @@
await albumService.MergeAlbumsAsync(dto);
showMergeModal = false;
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
loadVersion++;
}
async Task LinkSelectedAlbumsToCosplayer(Guid personId) {
if (selectedAlbumIds.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Person = personId });
if (selectedAlbums.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync(selectedAlbums.ToList(), new AlbumUpdateDto { Person = personId });
showLinkCosplayerModal = false;
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
loadVersion++;
}
async Task ApplyAlbumVisibility() {
if (selectedAlbumIds.Count == 0) return;
if (selectedAlbums.Count == 0) return;
visibilityError = null;
try {
await albumService.BulkUpdateAlbumsAsync(
[.. selectedAlbumIds],
selectedAlbums.ToList(),
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
new CascadeOptions { ToAssets = cascadeToAssets }
);
showVisibilityModal = false;
cascadeToAssets = false;
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
loadVersion++;
} catch (Exception) {
visibilityError = "The visibility update failed. Please try again.";
@@ -103,100 +103,29 @@
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" Class="d-none d-md-inline-flex" />
}
@if (loginService.CanEdit(Id)) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
<ResponsiveActionBar>
<ActionButton Icon="bi-plus-circle" Label="Add Albums" Class="btn-success" OnClick="OpenAlbumAssigner" />
@if (person.TotalAlbums > 0) {
if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-info" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
@(albumsAllSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
@if (selectMode) {
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedAlbums.Count}")" Class="btn-warning" MobileClass="text-warning"
Disabled="@(selectedAlbums.Count == 0)" OnClick="RemoveSelectedAlbums" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenAlbumMergeModal" />
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
}
<li class="dropdown-divider d-md-none"></li>
}
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete" title="Delete cosplayer">
<i class="bi bi-trash"></i>
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
</li>
@if (person.TotalAlbums > 0) {
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
}
<li>
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete Cosplayer
</button>
</li>
</ul>
</div>
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete Cosplayer" Class="btn-danger" MobileClass="text-danger"
Title="Delete cosplayer" OnClick="ConfirmDelete" />
</ResponsiveActionBar>
}
</ActionButtons>
</SortFilterBar>
@@ -207,7 +136,7 @@
SortAsc="@albumSortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
SelectedIds="@selectedAlbums.Selected"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums"
@@ -261,7 +190,7 @@
@if (showAlbumVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedAlbumIds.Count"
ItemCount="@selectedAlbums.Count"
ItemLabel="album(s)"
Visibility="@selectedAlbumVisibility"
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
@@ -281,13 +210,13 @@
OnClose="() => showAlbumMergeModal = false" />
}
<ConfirmDialog Show="showDeleteConfirm"
<ConfirmDialog Show="deleteConfirm.Show"
Title="Confirm Deletion"
Message="@deleteConfirmMessage"
Message="@deleteConfirm.Message"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecutePendingDelete"
OnCancel="() => showDeleteConfirm = false" />
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
@code {
[Parameter]
@@ -301,9 +230,7 @@
bool showAlbumVisibilityModal;
bool showAlbumMergeModal;
List<MergeModal.MergeItem> albumMergeItems = [];
bool showDeleteConfirm;
string deleteConfirmMessage = "";
Func<Task>? pendingDeleteAction;
DeleteConfirmation deleteConfirm = new();
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string? visibilityError;
@@ -311,7 +238,7 @@
bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums;
HashSet<Guid> assignAlbumIds = [];
HashSet<Guid> selectedAlbumIds = [];
MultiSelect<Guid> selectedAlbums = new();
SelectionRange albumSelectionRange = new();
List<string> bannerCoverUrls = [];
@@ -388,17 +315,17 @@
else assignAlbumIds.Remove(id);
}
bool albumsAllSelected => personAlbumGridRef != null && selectedAlbumIds.Count == personAlbumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0;
bool albumsAllSelected => personAlbumGridRef != null && selectedAlbums.IsAllSelected(personAlbumGridRef.GetCurrentAlbumIds());
void CancelSelectMode() {
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
}
void OpenAlbumMergeModal() {
if (personAlbumGridRef is null) return;
albumMergeItems = personAlbumGridRef.GetCurrentAlbumPreviews()
.Where(a => selectedAlbumIds.Contains(a.Id))
.Where(a => selectedAlbums.Contains(a.Id))
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
.ToList();
showAlbumMergeModal = true;
@@ -417,15 +344,12 @@
void ToggleSelectAllAlbums() {
if (personAlbumGridRef == null) return;
var allIds = personAlbumGridRef.GetCurrentAlbumIds();
if (albumsAllSelected)
selectedAlbumIds.Clear();
else
selectedAlbumIds.UnionWith(allIds);
selectedAlbums.ToggleAll(allIds);
}
void ToggleSelectAlbum((Guid Id, bool Shift) args) {
var ordered = personAlbumGridRef?.GetCurrentAlbumIds() ?? [];
albumSelectionRange.Toggle(selectedAlbumIds, ordered, args.Id, args.Shift);
albumSelectionRange.Toggle(selectedAlbums, ordered, args.Id, args.Shift);
}
async Task AssignSelectedAlbums() {
@@ -440,24 +364,21 @@
async Task RemoveSelectedAlbums() {
if (person == null) return;
foreach (var albumId in selectedAlbumIds)
foreach (var albumId in selectedAlbums.Selected)
await albumService.UnlinkPersonAsync(albumId);
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
async Task DeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
deleteConfirmMessage = $"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.";
pendingDeleteAction = ExecuteDeleteSelectedAlbums;
showDeleteConfirm = true;
void DeleteSelectedAlbums() {
if (person == null || selectedAlbums.Count == 0) return;
deleteConfirm.Confirm($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedAlbums);
}
async Task ExecuteDeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync([.. selectedAlbumIds]);
if (person == null || selectedAlbums.Count == 0) return;
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
@@ -475,11 +396,11 @@
}
async Task ApplyAlbumVisibility() {
if (person == null || selectedAlbumIds.Count == 0) return;
if (person == null || selectedAlbums.Count == 0) return;
visibilityError = null;
try {
await albumService.BulkUpdateAlbumsAsync(
[.. selectedAlbumIds],
selectedAlbums.ToList(),
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
new CascadeOptions { ToAssets = cascadeToAssets }
);
@@ -493,22 +414,16 @@
}
}
async Task ConfirmDelete() {
void ConfirmDelete() {
if (person == null) return;
deleteConfirmMessage = $"Delete cosplayer '{person.Name}'? This cannot be undone.";
pendingDeleteAction = ExecuteDeletePerson;
showDeleteConfirm = true;
deleteConfirm.Confirm($"Delete cosplayer '{person.Name}'? This cannot be undone.", ExecuteDeletePerson);
}
async Task ExecuteDeletePerson() {
if (person == null) return;
showDeleteConfirm = false;
await personService.DeletePersonAsync(person.Id);
navigationManager.NavigateTo("/cosplayers");
}
async Task ExecutePendingDelete() {
if (pendingDeleteAction != null)
await pendingDeleteAction();
}
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
}
@@ -20,80 +20,23 @@
SortOptions="@sortOptions">
<ActionButtons>
@if (loginService.IsAdminOrCurator) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<ResponsiveActionBar>
@if (cosplayerSelectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedCosplayerIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedCosplayerIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
Select All
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedCosplayers.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedCosplayers.Count == 0)" OnClick="DeleteSelectedCosplayers" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedCosplayers.Count == 0)" OnClick="OpenVisibilityModal" />
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
Disabled="@(selectedCosplayers.Count < 2)" OnClick="OpenMergeModal" />
<ActionButton Icon="@(allSelected ? "bi-x-square" : "bi-check2-square")" Label="@(allSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAll" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => cosplayerSelectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => cosplayerSelectMode = true" />
}
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> New Cosplayer
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (cosplayerSelectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedCosplayerIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedCosplayerIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAll">
<i class="bi bi-check2-square"></i> Select All
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => cosplayerSelectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> New Cosplayer
</button>
</li>
</ul>
</div>
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="bi-plus-circle" Label="New Cosplayer" Class="btn-primary" OnClick="OpenCreateForm" />
</ResponsiveActionBar>
}
</ActionButtons>
</SortFilterBar>
@@ -103,7 +46,7 @@
SortBy="@sortBy"
SortAsc="@sortAsc"
SelectionMode="@cosplayerSelectMode"
SelectedIds="@selectedCosplayerIds"
SelectedIds="@selectedCosplayers.Selected"
OnToggleSelection="@ToggleCosplayerSelection"
LoadVersion="@loadVersion" />
@@ -116,7 +59,7 @@
@if (showVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedCosplayerIds.Count"
ItemCount="@selectedCosplayers.Count"
ItemLabel="cosplayer(s)"
Visibility="@selectedVisibility"
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
@@ -133,7 +76,7 @@
<ConfirmDialog Show="showDeleteConfirm"
Title="Confirm Deletion"
Message="@($"Delete {selectedCosplayerIds.Count} selected cosplayer{(selectedCosplayerIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
Message="@($"Delete {selectedCosplayers.Count} selected cosplayer{(selectedCosplayers.Count == 1 ? "" : "s")}? This cannot be undone.")"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDeleteSelectedCosplayers"
@@ -160,7 +103,7 @@
bool cascadeToAssets;
EVisibility selectedVisibility = EVisibility.Public;
string? visibilityError;
HashSet<Guid> selectedCosplayerIds = [];
MultiSelect<Guid> selectedCosplayers = new();
[SupplyParameterFromQuery(Name = "search")] public string? searchQuery { get; set; }
string? sortBy;
bool sortAsc = true;
@@ -179,21 +122,19 @@
void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged();
void ToggleCosplayerSelection((Guid Id, bool Shift) args) {
cosplayerSelectionRange.Toggle(selectedCosplayerIds, cosplayerGridRef?.GetCurrentPersonIds() ?? [], args.Id, args.Shift);
cosplayerSelectionRange.Toggle(selectedCosplayers, cosplayerGridRef?.GetCurrentPersonIds() ?? [], args.Id, args.Shift);
}
void CancelSelectMode() {
cosplayerSelectMode = false;
selectedCosplayerIds.Clear();
selectedCosplayers.Clear();
}
bool allSelected => cosplayerGridRef != null && selectedCosplayers.IsAllSelected(cosplayerGridRef.GetCurrentPersonIds());
void ToggleSelectAll() {
if (cosplayerGridRef == null) return;
var allIds = cosplayerGridRef.GetCurrentPersonIds();
if (allIds.Count > 0 && selectedCosplayerIds.Count == allIds.Count)
selectedCosplayerIds.Clear();
else
selectedCosplayerIds.UnionWith(allIds);
selectedCosplayers.ToggleAll(cosplayerGridRef.GetCurrentPersonIds());
}
void OpenCreateForm() {
@@ -210,13 +151,13 @@
}
async Task DeleteSelectedCosplayers() {
if (selectedCosplayerIds.Count == 0) return;
if (selectedCosplayers.Count == 0) return;
showDeleteConfirm = true;
}
async Task ExecuteDeleteSelectedCosplayers() {
showDeleteConfirm = false;
await personService.BulkDeletePeopleAsync([.. selectedCosplayerIds]);
await personService.BulkDeletePeopleAsync(selectedCosplayers.ToList());
CancelSelectMode();
loadVersion++;
}
@@ -224,7 +165,7 @@
void OpenMergeModal() {
if (cosplayerGridRef is null) return;
mergeItems = cosplayerGridRef.GetCurrentPersonPreviews()
.Where(p => selectedCosplayerIds.Contains(p.Id))
.Where(p => selectedCosplayers.Contains(p.Id))
.Select(p => new MergeModal.MergeItem { Id = p.Id, Name = p.Name, Subtitle = $"{p.TotalAlbums} album{(p.TotalAlbums == 1 ? "" : "s")}" })
.ToList();
showMergeModal = true;
@@ -254,11 +195,11 @@
}
async Task ApplyVisibility() {
if (selectedCosplayerIds.Count == 0) return;
if (selectedCosplayers.Count == 0) return;
visibilityError = null;
try {
await personService.BulkUpdatePeopleAsync(
[.. selectedCosplayerIds],
selectedCosplayers.ToList(),
new PersonUpdateDto { Visibility = selectedVisibility },
new CascadeOptions { ToAlbums = cascadeToAlbums, ToAssets = cascadeToAssets }
);
+27 -99
View File
@@ -10,9 +10,9 @@
<PageTitle>Home</PageTitle>
@if (isLoading) {
@if (controller!.IsLoading) {
<LoadSpinner/>
} else if (flatList.Count == 0) {
} else if (controller!.Buffer.Count == 0) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>You are logged in, but there is no media available at the moment.</p>
@@ -23,13 +23,13 @@
} else {
<div @ref="topSentinelRef" class="masonry-sentinel"></div>
@if (isLoadingUp) {
@if (controller!.IsLoadingUp) {
<LoadSpinner/>
}
<div class="masonry-grid">
@{ var idx = 0; }
@foreach (var asset in flatList) {
@foreach (var asset in controller!.Buffer.FlatList) {
var capturedIndex = idx;
<div class="masonry-tile" @key="asset.Id"
style="aspect-ratio:@GetAspectRatio(asset)"
@@ -73,7 +73,7 @@
}
</div>
@if (isLoadingMore) {
@if (controller!.IsLoadingMore) {
<LoadSpinner/>
}
@@ -86,7 +86,7 @@
HasPreview="selectedAsset.HasPreview"
MimeType="selectedAsset.MimeType"
HasPrevious="selectedIndex > 0"
HasNext="selectedIndex < flatList.Count - 1"
HasNext="selectedIndex < controller!.Buffer.Count - 1"
Filename="@selectedAsset.FileName"
OnNavigate="NavigatePreview"
OnClose="ClosePreview">
@@ -120,16 +120,7 @@
}
@code {
List<List<AssetPreviewDto>> allAssetPages = [];
List<AssetPreviewDto> flatList = [];
int currentPage = 1;
int loadedMinPage = 1;
int loadedMaxPage = 1;
bool hasMore = true;
bool hasMoreUp;
bool isLoading = true;
bool isLoadingMore;
bool isLoadingUp;
InfiniteScrollController<AssetPreviewDto>? controller;
AssetPreviewDto? selectedAsset;
int selectedIndex = -1;
ElementReference sentinelRef;
@@ -137,14 +128,22 @@
DotNetObjectReference<Home>? dotNetRef;
Guid randomSeed;
protected override async Task OnInitializedAsync() {
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, _) => StateHasChanged();
await LoadFirstPage();
controller = new InfiniteScrollController<AssetPreviewDto>(
fetchPage: page => assetService.GetAssetsAsync(EAssetType.Image, true, randomSeed, page, 30, includeCount: false),
keySelector: a => a.Id,
onChanged: StateHasChanged);
}
protected override async Task OnInitializedAsync() {
randomSeed = Guid.NewGuid();
await controller!.ReloadAsync();
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (flatList.Count > 0 && dotNetRef == null) {
if (controller!.Buffer.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observeBottom", sentinelRef, dotNetRef
@@ -160,83 +159,11 @@
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
}
async Task LoadFirstPage() {
randomSeed = Guid.NewGuid();
isLoading = true;
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, randomSeed, 0, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
loadedMinPage = 0;
loadedMaxPage = 0;
currentPage = 1;
hasMoreUp = false;
}
hasMore = items?.Count == 30;
isLoading = false;
}
[JSInvokable]
public Task LoadNextPage() => controller!.LoadNextAsync();
[JSInvokable]
public async Task LoadNextPage() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, randomSeed, currentPage, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Add(items);
loadedMaxPage = currentPage;
currentPage++;
hasMoreUp = true;
RebuildFlatList();
if (allAssetPages.Count > 10) {
allAssetPages.RemoveAt(0);
loadedMinPage++;
}
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
[JSInvokable]
public async Task LoadPreviousPage() {
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 0) return;
isLoadingUp = true;
StateHasChanged();
var pageToLoad = loadedMinPage - 1;
if (pageToLoad < 0) {
hasMoreUp = false;
isLoadingUp = false;
return;
}
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, randomSeed, pageToLoad, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Insert(0, items);
loadedMinPage = pageToLoad;
RebuildFlatList();
if (allAssetPages.Count > 10) {
allAssetPages.RemoveAt(allAssetPages.Count - 1);
loadedMaxPage--;
}
}
hasMoreUp = loadedMinPage > 0;
isLoadingUp = false;
StateHasChanged();
}
void RebuildFlatList() {
var seen = new Dictionary<Guid, AssetPreviewDto>();
foreach (var page in allAssetPages)
foreach (var asset in page)
seen.TryAdd(asset.Id, asset);
flatList = [.. seen.Values];
}
public Task LoadPreviousPage() => controller!.LoadPreviousAsync();
void OpenPreview(AssetPreviewDto asset, int index) {
selectedAsset = asset;
@@ -250,15 +177,16 @@
void NavigatePreview(int direction) {
if (selectedAsset == null) return;
var list = controller!.Buffer.FlatList;
var newIndex = selectedIndex + direction;
if (newIndex < 0 || newIndex >= flatList.Count) return;
var newAsset = flatList[newIndex];
if (newIndex < 0 || newIndex >= list.Count) return;
var newAsset = list[newIndex];
if (newAsset.Id != selectedAsset.Id) {
var found = flatList.FindIndex(a => a.Id == selectedAsset.Id);
var found = list.FindIndex(a => a.Id == selectedAsset.Id);
if (found < 0) return;
newIndex = found + direction;
if (newIndex < 0 || newIndex >= flatList.Count) return;
newAsset = flatList[newIndex];
if (newIndex < 0 || newIndex >= list.Count) return;
newAsset = list[newIndex];
}
selectedAsset = newAsset;
selectedIndex = newIndex;
@@ -0,0 +1,81 @@
@code {
[CascadingParameter]
public ResponsiveActionVariant Variant { get; set; } = ResponsiveActionVariant.Desktop;
/// <summary>
/// Bootstrap icon class rendered before the label (e.g. "bi-trash").
/// </summary>
[Parameter]
public string? Icon { get; set; }
/// <summary>
/// The button label. May embed a dynamic value such as a selection count.
/// </summary>
[Parameter]
public string? Label { get; set; }
/// <summary>
/// Extra classes for the desktop button (e.g. "btn-danger", "btn-outline-secondary").
/// </summary>
[Parameter]
public string Class { get; set; } = "";
/// <summary>
/// Extra classes for the mobile dropdown item (e.g. "text-danger"). Defaults to <see cref="Class"/>.
/// </summary>
[Parameter]
public string MobileClass { get; set; } = "";
/// <summary>
/// Optional icon override for the mobile dropdown item.
/// </summary>
[Parameter]
public string? MobileIcon { get; set; }
/// <summary>
/// Optional label override for the mobile dropdown item.
/// </summary>
[Parameter]
public string? MobileLabel { get; set; }
/// <summary>
/// Gets or sets whether the button is disabled.
/// </summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Gets or sets the callback invoked when the button is clicked.
/// </summary>
[Parameter]
public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>
/// Gets or sets the tooltip text shown on hover (desktop).
/// </summary>
[Parameter]
public string Title { get; set; } = "";
}
@if (Variant == ResponsiveActionVariant.Mobile) {
<li>
<button class="dropdown-item @MobileClass"
@onclick="OnClick"
disabled="@Disabled"
title="@Title">
@if (MobileIcon != null) {
<i class="bi @MobileIcon"></i>
} else if (Icon != null) {
<i class="bi @Icon"></i>
}
@(MobileLabel ?? Label)
</button>
</li>
} else {
<button class="btn btn-sm @Class" @onclick="OnClick" disabled="@Disabled" title="@Title">
@if (Icon != null) {
<i class="bi @Icon"></i>
}
@Label
</button>
}
@@ -12,155 +12,7 @@
@if (Embedded) {
<div class="picker-embedded">
<div class="picker-layout">
<div class="picker-sidebar @(sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header">
<span>@(isAdminOrCurator ? "Folders" : "Source")</span>
<button class="sidebar-toggle" @onclick="() => sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
<i class="bi @(sidebarCollapsed ? "bi-chevron-right" : "bi-chevron-left")"></i>
</button>
</div>
@if (!sidebarCollapsed) {
@if (groups != null && groups.Count > 0) {
@foreach (var group in groups) {
<button class="sidebar-item @(selectedGroup?.FolderId == group.FolderId ? "active" : "")"
@onclick="() => SelectGroup(group)">
<span class="sidebar-name">@TruncateName(group.Name)</span>
<span class="badge bg-secondary rounded-pill">@group.AssetCount</span>
</button>
}
} else {
<div class="text-muted small p-2">No folders found.</div>
}
}
</div>
<div class="picker-main">
@if (isAdminOrCurator && selectedGroup != null) {
<div class="picker-breadcrumb">
<button class="breadcrumb-item @(string.IsNullOrEmpty(currentPath) ? "active" : "")"
@onclick="NavigateToRoot">root</button>
@foreach (var item in GetBreadcrumbs()) {
<span class="breadcrumb-sep">/</span>
<button class="breadcrumb-item @(item.Path == currentPath ? "active" : "")"
@onclick="() => NavigateToPath(item.Path)">@item.Segment</button>
}
</div>
<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>
</div>
@if (browseResult?.Directories.Count > 0) {
<div class="picker-dir-list">
@foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) {
<button class="picker-dir-item" @onclick="() => NavigateToPath(JoinPath(currentPath, dir.Name))">
<i class="bi bi-folder"></i><span class="dir-name">@dir.Name</span>
</button>
}
</div>
}
@if (browseResult != null) {
@if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) {
<EmptyState Variant="info" Title="No assets at this level">
<p>@(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "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>
</div>
@if (assetPage == null || assetPage.Count == 0) {
<EmptyState Variant="info" Title="No assets found">
<p>@(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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 assetPage) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
</div>
</div>
}
</div>
}
<div class="picker-pagination">
<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>
}
} else if (isAdminOrCurator) {
<EmptyState Variant="info" Title="Select a folder">
<p>Choose a folder from the sidebar to browse assets.</p>
</EmptyState>
}
</div>
</div>
@PickerLayout
<div class="picker-embedded-footer">
@if (Mode == PickerMode.Maintenance && selectedIds.Count > 0) {
<button class="btn btn-sm btn-outline-primary" @onclick="OpenVisibilityModal"><i class="bi bi-eye"></i> Visibility</button>
@@ -180,155 +32,7 @@
@if (isLoading && groups == null) {
<LoadSpinner/>
} else {
<div class="picker-layout">
<div class="picker-sidebar @(sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header">
<span>@(isAdminOrCurator ? "Folders" : "Source")</span>
<button class="sidebar-toggle" @onclick="() => sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
<i class="bi @(sidebarCollapsed ? "bi-chevron-right" : "bi-chevron-left")"></i>
</button>
</div>
@if (!sidebarCollapsed) {
@if (groups != null && groups.Count > 0) {
@foreach (var group in groups) {
<button class="sidebar-item @(selectedGroup?.FolderId == group.FolderId ? "active" : "")"
@onclick="() => SelectGroup(group)">
<span class="sidebar-name">@TruncateName(group.Name)</span>
<span class="badge bg-secondary rounded-pill">@group.AssetCount</span>
</button>
}
} else {
<div class="text-muted small p-2">No folders found.</div>
}
}
</div>
<div class="picker-main">
@if (isAdminOrCurator && selectedGroup != null) {
<div class="picker-breadcrumb">
<button class="breadcrumb-item @(string.IsNullOrEmpty(currentPath) ? "active" : "")"
@onclick="NavigateToRoot">root</button>
@foreach (var item in GetBreadcrumbs()) {
<span class="breadcrumb-sep">/</span>
<button class="breadcrumb-item @(item.Path == currentPath ? "active" : "")"
@onclick="() => NavigateToPath(item.Path)">@item.Segment</button>
}
</div>
<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>
</div>
@if (browseResult?.Directories.Count > 0) {
<div class="picker-dir-list">
@foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) {
<button class="picker-dir-item" @onclick="() => NavigateToPath(JoinPath(currentPath, dir.Name))">
<i class="bi bi-folder"></i><span class="dir-name">@dir.Name</span>
</button>
}
</div>
}
@if (browseResult != null) {
@if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) {
<EmptyState Variant="info" Title="No assets at this level">
<p>@(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "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>
</div>
@if (assetPage == null || assetPage.Count == 0) {
<EmptyState Variant="info" Title="No assets found">
<p>@(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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 assetPage) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
</div>
</div>
}
</div>
}
<div class="picker-pagination">
<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>
}
} else if (isAdminOrCurator) {
<EmptyState Variant="info" Title="Select a folder">
<p>Choose a folder from the sidebar to browse assets.</p>
</EmptyState>
}
</div>
</div>
@PickerLayout
}
</Body>
<Footer>
@@ -349,23 +53,23 @@
</ModalFrame>
}
<ConfirmDialog Show="showDeleteConfirm"
<ConfirmDialog Show="deleteConfirm.Show"
Title="Confirm Deletion"
Message="@deleteConfirmMessage"
Message="@deleteConfirm.Message"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDeleteSelected"
OnCancel="() => showDeleteConfirm = false" />
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
@if (showVisibilityModal) {
@if (visibility.Show) {
<VisibilityModal Show="true"
ItemCount="@selectedIds.Count"
ItemLabel="asset(s)"
Visibility="@visibilitySelection"
VisibilityChanged="@((EVisibility v) => visibilitySelection = v)"
Error="@visibilityError"
Visibility="visibility.Selection"
VisibilityChanged="@((EVisibility v) => visibility.Selection = v)"
Error="@visibility.Error"
OnApply="ApplyVisibility"
OnClose="() => showVisibilityModal = false" />
OnClose="visibility.Close" />
}
@if (showCreateAlbumModal) {
@@ -422,27 +126,176 @@
[Parameter] public bool UnlinkedOnly { get; set; } = true;
public enum PickerMode { AddToAlbum, Maintenance }
// The shared sidebar + main browser layout, rendered by both the Embedded and Modal modes.
// Only the outer wrapper, the Modal loading gate, and the footer differ between the two.
RenderFragment PickerLayout => @<div class="picker-layout">
<div class="picker-sidebar @(sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header">
<span>@(isAdminOrCurator ? "Folders" : "Source")</span>
<button class="sidebar-toggle" @onclick="() => sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
<i class="bi @(sidebarCollapsed ? "bi-chevron-right" : "bi-chevron-left")"></i>
</button>
</div>
@if (!sidebarCollapsed) {
@if (groups != null && groups.Count > 0) {
@foreach (var group in groups) {
<button class="sidebar-item @(selectedGroup?.FolderId == group.FolderId ? "active" : "")"
@onclick="() => SelectGroup(group)">
<span class="sidebar-name">@TruncateName(group.Name)</span>
<span class="badge bg-secondary rounded-pill">@group.AssetCount</span>
</button>
}
} else {
<div class="text-muted small p-2">No folders found.</div>
}
}
</div>
<div class="picker-main">
@if (isAdminOrCurator && selectedGroup != null) {
<div class="picker-breadcrumb">
<button class="breadcrumb-item @(string.IsNullOrEmpty(currentPath) ? "active" : "")"
@onclick="NavigateToRoot">root</button>
@foreach (var item in GetBreadcrumbs()) {
<span class="breadcrumb-sep">/</span>
<button class="breadcrumb-item @(item.Path == currentPath ? "active" : "")"
@onclick="() => NavigateToPath(item.Path)">@item.Segment</button>
}
</div>
<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>
</div>
@if (browseResult?.Directories.Count > 0) {
<div class="picker-dir-list">
@foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) {
<button class="picker-dir-item" @onclick="() => NavigateToPath(JoinPath(currentPath, dir.Name))">
<i class="bi bi-folder"></i><span class="dir-name">@dir.Name</span>
</button>
}
</div>
}
@if (browseResult != null) {
@if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) {
<EmptyState Variant="info" Title="No assets at this level">
<p>@(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "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>
</div>
@if (assetPage == null || assetPage.Count == 0) {
<EmptyState Variant="info" Title="No assets found">
<p>@(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")</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="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
<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 assetPage) {
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="(e) => ToggleSelection(asset.Id, e.ShiftKey)">
@if (asset.HasThumbnail) {
<img 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>
}
<div class="picker-tile-check">
<i class="bi @(selectedIds.Contains(asset.Id) ? "bi-check-circle-fill" : "bi-circle")"></i>
</div>
</div>
}
</div>
}
<div class="picker-pagination">
<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>
}
} else if (isAdminOrCurator) {
<EmptyState Variant="info" Title="Select a folder">
<p>Choose a folder from the sidebar to browse assets.</p>
</EmptyState>
}
</div>
</div>;
List<AssetGroupDto>? groups;
AssetGroupDto? selectedGroup;
List<AssetPreviewDto>? assetPage;
AssetBrowseResultDto? browseResult;
string currentPath = "";
HashSet<Guid> selectedIds = [];
MultiSelect<Guid> selectedIds = new();
SelectionRange assetSelectionRange = new();
string searchQuery = string.Empty;
int currentPage;
bool isLoading;
bool hasMore = true;
Timer? searchTimer;
private Debounce searchDebounce = new();
private bool initializing;
private bool sidebarCollapsed;
private bool listView;
private bool showDeleteConfirm;
private string deleteConfirmMessage = "";
private DeleteConfirmation deleteConfirm = new();
private bool showVisibilityModal;
private EVisibility visibilitySelection = EVisibility.Public;
private string? visibilityError;
private VisibilityApplyState visibility = new();
private bool showCreateAlbumModal;
private string newAlbumName = "";
@@ -450,7 +303,7 @@
private List<PersonPreviewDto>? personSearchResults;
private Guid? selectedPersonId;
private string selectedPersonName = "";
private Timer? personSearchTimer;
private Debounce personSearchDebounce = new();
private EVisibility newAlbumVisibility = EVisibility.Protected;
const int PageSize = 150;
@@ -508,9 +361,10 @@
async Task LoadPrevious() { if (currentPage <= 0) return; currentPage--; await LoadContent(); }
async Task LoadNext() { if (!hasMore) return; currentPage++; await LoadContent(); }
async Task OnSearchChanged() {
searchTimer?.Dispose();
searchTimer = new Timer(async _ => { currentPage = 0; hasMore = true; await LoadContent(); await InvokeAsync(StateHasChanged); }, null, 300, Timeout.Infinite);
void OnSearchChanged() {
_ = searchDebounce.RunAsync(300, async () => {
currentPage = 0; hasMore = true; await LoadContent(); StateHasChanged();
});
}
List<(string Segment, string Path)> GetBreadcrumbs() {
@@ -543,39 +397,35 @@
async Task OnCancel() { selectedIds.Clear(); await OnClose.InvokeAsync(); }
async Task ConfirmDeleteSelected() {
deleteConfirmMessage = $"Delete {selectedIds.Count} selected asset(s)? This cannot be undone.";
showDeleteConfirm = true;
void ConfirmDeleteSelected() {
deleteConfirm.Confirm($"Delete {selectedIds.Count} selected asset(s)? This cannot be undone.", ExecuteDeleteSelected);
}
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
async Task ExecuteDeleteSelected() {
showDeleteConfirm = false;
if (selectedIds.Count == 0) return;
await assetService.BulkDeleteAssetsAsync([.. selectedIds]);
await assetService.BulkDeleteAssetsAsync(selectedIds.ToList());
selectedIds.Clear();
await LoadContent();
}
void OpenVisibilityModal() {
visibilitySelection = EVisibility.Public;
visibilityError = null;
showVisibilityModal = true;
}
void OpenVisibilityModal() => visibility.Open();
async Task ApplyVisibility() {
if (selectedIds.Count == 0) return;
visibilityError = null;
visibility.SetError(null);
try {
var ok = await assetService.BulkUpdateAssetsAsync([.. selectedIds], new AssetUpdateDto { Visibility = visibilitySelection });
var ok = await assetService.BulkUpdateAssetsAsync(selectedIds.ToList(), new AssetUpdateDto { Visibility = visibility.Selection });
if (!ok) {
visibilityError = "The visibility update failed. Please try again.";
visibility.SetError("The visibility update failed. Please try again.");
return;
}
showVisibilityModal = false;
visibility.MarkApplied();
selectedIds.Clear();
await LoadContent();
} catch (Exception) {
visibilityError = "The visibility update failed. Please try again.";
visibility.SetError("The visibility update failed. Please try again.");
}
}
@@ -589,18 +439,16 @@
showCreateAlbumModal = true;
}
async Task OnPersonSearchChanged() {
personSearchTimer?.Dispose();
personSearchTimer = new Timer(async _ => {
void OnPersonSearchChanged() {
_ = personSearchDebounce.RunAsync(300, async () => {
if (personSearchText.Length < 2) {
personSearchResults = null;
await InvokeAsync(StateHasChanged);
StateHasChanged();
return;
}
var results = await personService.GetAllAsync(search: personSearchText, pageSize: 10);
personSearchResults = results;
await InvokeAsync(StateHasChanged);
}, null, 300, Timeout.Infinite);
personSearchResults = await personService.GetAllAsync(search: personSearchText, pageSize: 10);
StateHasChanged();
});
}
void SelectPerson(PersonPreviewDto person) {
@@ -615,7 +463,7 @@
await albumService.CreateAlbumAsync(new AlbumCreateDto {
Name = newAlbumName.Trim(),
Person = selectedPersonId,
Assets = [.. selectedIds],
Assets = selectedIds.ToList(),
Visibility = newAlbumVisibility
});
showCreateAlbumModal = false;
@@ -624,8 +472,8 @@
}
public void Dispose() {
searchTimer?.Dispose();
personSearchTimer?.Dispose();
searchDebounce.Cancel();
personSearchDebounce.Cancel();
}
static string TruncateName(string name, int maxLen = 40) {
@@ -2,8 +2,6 @@
@using Butter.Dtos.Person
@using Butter.Types
@inject AlbumService albumService
@inject PersonService personService
@inject IJSRuntime JSRuntime
<ModalFrame Title="@(EditAlbum == null ? "New Album" : "Edit Album")" Show="Show" OnClose="HandleClose">
<Body>
@@ -15,34 +13,9 @@
<label class="form-label">Person</label>
<div class="d-flex gap-2">
<div class="position-relative flex-grow-1">
<input type="text" class="form-control"
placeholder="Search cosplayers…"
value="@filterText"
@ref="personInputRef"
@oninput="OnInput"
@onfocus="OnInputFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showPersonDropdown) {
<div class="dropdown-menu show person-dropdown-menu"
@ref="personDropdownRef">
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (filterText.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 cosplayers</span>
} else {
@foreach (var person in searchResults) {
<button type="button"
class="dropdown-item @(selectedPersonId == person.Id ? "active" : "")"
@onclick="() => SelectPerson(person)">
@person.Name
</button>
}
}
</div>
}
<PersonSearchDropdown @bind-Query="filterText"
SelectedPersonId="selectedPersonId"
OnPersonSelected="HandlePersonSelected" />
</div>
@if (EditAlbum?.Person != null && selectedPersonId != null) {
<button class="btn btn-sm btn-outline-warning flex-shrink-0" @onclick="UnlinkPerson" title="Remove cosplayer assignment">
@@ -106,14 +79,6 @@
bool showAssetPicker;
bool personRemoved;
string filterText = string.Empty;
bool showPersonDropdown;
CancellationTokenSource? blurCts;
List<PersonPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference personInputRef;
ElementReference personDropdownRef;
bool pendingPosition;
protected override async Task OnParametersSetAsync() {
if (Show && EditAlbum != null) {
@@ -133,71 +98,14 @@
albumAssetIds = AssetIds;
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showPersonDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", personInputRef, personDropdownRef);
}
}
void OnInputFocus() {
blurCts?.Cancel();
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
_ = Search(filterText);
}
}
async Task OnInput(ChangeEventArgs e) {
filterText = e.Value?.ToString() ?? string.Empty;
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
await Search(filterText);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await personService.GetAllAsync(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);
showPersonDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showPersonDropdown = false;
}
}
void SelectPerson(PersonPreviewDto person) {
void HandlePersonSelected(PersonPreviewDto person) {
selectedPersonId = person.Id;
filterText = person.Name;
showPersonDropdown = false;
personRemoved = false;
}
void UnlinkPerson() {
selectedPersonId = null;
filterText = string.Empty;
showPersonDropdown = false;
searchResults = null;
personRemoved = true;
}
@@ -5,9 +5,9 @@
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@if (isLoading) {
@if (controller!.IsLoading) {
<LoadSpinner/>
} else if (buffer.Count == 0 && !hasMore) {
} else if (controller!.Buffer.Count == 0 && !controller!.HasMore) {
<EmptyState Variant="warning" Title="No albums available">
@if (loginService.IsLoggedIn) {
<p>There are no albums yet.</p>
@@ -16,12 +16,12 @@
}
</EmptyState>
} else {
@if (isLoadingUp) {
@if (controller!.IsLoadingUp) {
<LoadSpinner/>
}
<div class="@("album-grid " + ViewMode)">
@foreach (var album in buffer.FlatList) {
@foreach (var album in controller!.Buffer.FlatList) {
<AlbumCard @key="album.Id"
Album="album"
SelectionMode="SelectionMode"
@@ -31,7 +31,7 @@
}
</div>
@if (isLoadingMore) {
@if (controller!.IsLoadingMore) {
<LoadSpinner/>
}
}
@@ -42,18 +42,13 @@
[Parameter] public bool SortAsc { get; set; }
[Parameter] public string ViewMode { get; set; } = "masonry";
[Parameter] public bool SelectionMode { get; set; }
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public IReadOnlyCollection<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<(Guid Id, bool Shift)> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
[Parameter] public EventCallback<List<AlbumPreviewDto>> OnDataLoaded { get; set; }
PageBuffer<AlbumPreviewDto> buffer = new(a => a.Id) { MaxPages = 10 };
bool hasMore = true;
bool hasMoreUp => buffer.MinPage > 0;
bool isLoading = true;
bool isLoadingMore;
bool isLoadingUp;
InfiniteScrollController<AlbumPreviewDto>? controller;
DotNetObjectReference<AlbumGrid>? dotNetRef;
int? prevLoadVersion;
@@ -63,6 +58,15 @@
string? prevViewMode;
bool masonrySetup;
protected override void OnInitialized() {
controller = new InfiniteScrollController<AlbumPreviewDto>(
fetchPage: page => FetchAlbums != null
? FetchAlbums(page, 30, SearchQuery, SortBy, SortAsc)
: albumService.GetAlbumsAsync(page, 30, SearchQuery, SortBy, SortAsc),
keySelector: a => a.Id,
onChanged: StateHasChanged);
}
protected override async Task OnParametersSetAsync() {
bool filterChanged = prevLoadVersion != LoadVersion
|| prevSearch != SearchQuery
@@ -87,12 +91,12 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!masonrySetup && ViewMode == "masonry" && buffer.Count > 0) {
if (!masonrySetup && ViewMode == "masonry" && controller!.Buffer.Count > 0) {
masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-grid.masonry");
}
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
if (!controller!.IsLoading && (controller!.Buffer.Count > 0 || controller!.HasMore) && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"albumObserver.setup", dotNetRef
@@ -102,64 +106,16 @@
async Task Reload() {
await DisposeObserver();
buffer.Clear();
hasMore = true;
isLoading = true;
StateHasChanged();
var items = FetchAlbums != null
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.AddPage(0, items);
}
hasMore = items?.Count == 30;
isLoading = false;
if (buffer.Count > 0)
await OnDataLoaded.InvokeAsync(buffer.FlatList);
await controller!.ReloadAsync();
if (controller.HasData)
await OnDataLoaded.InvokeAsync(controller.Buffer.FlatList);
}
[JSInvokable]
public async Task LoadMoreAlbums() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var nextPage = buffer.MaxPage + 1;
var items = FetchAlbums != null
? await FetchAlbums(nextPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.AddPage(nextPage, items);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
public Task LoadMoreAlbums() => controller!.LoadNextAsync();
[JSInvokable]
public async Task LoadPreviousAlbums() {
if (isLoadingUp || !hasMoreUp) return;
isLoadingUp = true;
StateHasChanged();
var prevPage = buffer.MinPage - 1;
if (prevPage < 0) {
isLoadingUp = false;
return;
}
var items = FetchAlbums != null
? await FetchAlbums(prevPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.PrependPage(prevPage, items);
}
isLoadingUp = false;
StateHasChanged();
}
public Task LoadPreviousAlbums() => controller!.LoadPreviousAsync();
async Task ToggleAlbumSelection((Guid Id, bool Shift) args) => await OnToggleSelection.InvokeAsync(args);
@@ -179,10 +135,10 @@
/// <summary>
/// Returns the IDs of all currently loaded albums.
/// </summary>
public List<Guid> GetCurrentAlbumIds() => buffer.FlatList.Select(a => a.Id).ToList();
public List<Guid> GetCurrentAlbumIds() => controller!.Buffer.FlatList.Select(a => a.Id).ToList();
/// <summary>
/// Returns the previews of all currently loaded albums.
/// </summary>
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. buffer.FlatList];
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. controller!.Buffer.FlatList];
}
@@ -8,9 +8,9 @@
@inject MediaService mediaService
@inject IJSRuntime jsRuntime
@if (isLoading) {
@if (controller!.IsLoading) {
<LoadSpinner/>
} else if (buffer.Count == 0 && !hasMore) {
} else if (controller!.Buffer.Count == 0 && !controller!.HasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
@@ -19,12 +19,12 @@
}
</EmptyState>
} else {
@if (isLoadingUp) {
@if (controller!.IsLoadingUp) {
<LoadSpinner/>
}
<div class="cosplayers-grid">
@foreach (var person in buffer.FlatList) {
@foreach (var person in controller!.Buffer.FlatList) {
<a href="/cosplayer/@person.Id" class="@GetCardClass(person)" @key="person.Id"
@onclick="(e) => HandleCardClick(person.Id, e.ShiftKey)"
@onclick:preventDefault="SelectionMode">
@@ -65,7 +65,7 @@
}
</div>
@if (isLoadingMore) {
@if (controller!.IsLoadingMore) {
<LoadSpinner/>
}
}
@@ -75,16 +75,11 @@
[Parameter] public string? SortBy { get; set; }
[Parameter] public bool SortAsc { get; set; } = true;
[Parameter] public bool SelectionMode { get; set; }
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public IReadOnlyCollection<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<(Guid Id, bool Shift)> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
PageBuffer<PersonPreviewDto> buffer = new(p => p.Id) { MaxPages = 10 };
bool hasMore = true;
bool hasMoreUp => buffer.MinPage > 0;
bool isLoading = true;
bool isLoadingMore;
bool isLoadingUp;
InfiniteScrollController<PersonPreviewDto>? controller;
DotNetObjectReference<CosplayerGrid>? dotNetRef;
int? prevLoadVersion;
@@ -92,6 +87,13 @@
string? prevSortBy;
bool prevSortAsc;
protected override void OnInitialized() {
controller = new InfiniteScrollController<PersonPreviewDto>(
fetchPage: page => personService.GetAllAsync(page, 30, SearchQuery, SortBy, SortAsc),
keySelector: p => p.Id,
onChanged: StateHasChanged);
}
protected override async Task OnParametersSetAsync() {
bool filterChanged = prevLoadVersion != LoadVersion
|| prevSearch != SearchQuery
@@ -108,7 +110,7 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
if (!controller!.IsLoading && (controller!.Buffer.Count > 0 || controller!.HasMore) && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.setup", dotNetRef
@@ -118,55 +120,14 @@
async Task Reload() {
await DisposeObserver();
buffer.Clear();
hasMore = true;
isLoading = true;
StateHasChanged();
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.AddPage(0, items);
}
hasMore = items?.Count == 30;
isLoading = false;
await controller!.ReloadAsync();
}
[JSInvokable]
public async Task LoadMoreCosplayers() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var nextPage = buffer.MaxPage + 1;
var items = await personService.GetAllAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.AddPage(nextPage, items);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
public Task LoadMoreCosplayers() => controller!.LoadNextAsync();
[JSInvokable]
public async Task LoadPreviousCosplayers() {
if (isLoadingUp || !hasMoreUp) return;
isLoadingUp = true;
StateHasChanged();
var prevPage = buffer.MinPage - 1;
if (prevPage < 0) {
isLoadingUp = false;
return;
}
var items = await personService.GetAllAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.PrependPage(prevPage, items);
}
isLoadingUp = false;
StateHasChanged();
}
public Task LoadPreviousCosplayers() => controller!.LoadPreviousAsync();
void HandleCardClick(Guid id, bool shift) {
if (SelectionMode) {
@@ -202,10 +163,10 @@
/// <summary>
/// Returns the IDs of all currently loaded cosplayers.
/// </summary>
public List<Guid> GetCurrentPersonIds() => buffer.FlatList.Select(p => p.Id).ToList();
public List<Guid> GetCurrentPersonIds() => controller!.Buffer.FlatList.Select(p => p.Id).ToList();
/// <summary>
/// Returns the previews of all currently loaded cosplayers.
/// </summary>
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. buffer.FlatList];
public List<PersonPreviewDto> GetCurrentPersonPreviews() => [.. controller!.Buffer.FlatList];
}
@@ -182,7 +182,7 @@
bool showVisibilityModal;
EVisibility visibilitySelection = EVisibility.Public;
Timer? searchTimer;
private Debounce searchDebounce = new();
const int PageSize = 150;
@@ -220,9 +220,10 @@
async Task LoadPrevious() { if (currentPage <= 0) return; currentPage--; await LoadContent(); }
async Task LoadNext() { if (!hasMore) return; currentPage++; await LoadContent(); }
async Task OnSearchChanged() {
searchTimer?.Dispose();
searchTimer = new Timer(async _ => { currentPage = 0; hasMore = true; await LoadContent(); await InvokeAsync(StateHasChanged); }, null, 300, Timeout.Infinite);
void OnSearchChanged() {
_ = searchDebounce.RunAsync(300, async () => {
currentPage = 0; hasMore = true; await LoadContent(); StateHasChanged();
});
}
List<(string Segment, string Path)> GetBreadcrumbs() {
@@ -280,7 +281,7 @@
await LoadContent();
}
public void Dispose() => searchTimer?.Dispose();
public void Dispose() => searchDebounce.Cancel();
static string TruncateName(string name, int maxLen = 40) {
if (name.Length <= maxLen) return name;
@@ -1,42 +1,13 @@
@using Butter.Dtos.Person
@inject PersonService personService
@inject IJSRuntime JSRuntime
<ModalFrame Title="Link to Cosplayer" Show="Show" OnClose="OnClose">
<Body>
<p class="text-muted">Select a cosplayer to assign to <strong>@AlbumCount</strong> album(s).</p>
<div class="mb-3">
<label class="form-label">Cosplayer</label>
<div class="position-relative">
<input type="text" class="form-control"
placeholder="Search cosplayers…"
value="@filterText"
@ref="personInputRef"
@oninput="OnInput"
@onfocus="OnInputFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showPersonDropdown) {
<div class="dropdown-menu show person-dropdown-menu"
@ref="personDropdownRef">
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (filterText.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 cosplayers</span>
} else {
@foreach (var person in searchResults) {
<button type="button"
class="dropdown-item @(selectedPersonId == person.Id ? "active" : "")"
@onclick="() => SelectPerson(person)">
@person.Name
</button>
}
}
</div>
}
</div>
<PersonSearchDropdown @bind-Query="filterText"
SelectedPersonId="selectedPersonId"
OnPersonSelected="HandlePersonSelected" />
</div>
@if (selectedPersonId.HasValue) {
<div class="alert alert-info py-2 mb-0">
@@ -75,74 +46,10 @@
[Parameter] public EventCallback OnClose { get; set; }
string filterText = string.Empty;
bool showPersonDropdown;
CancellationTokenSource? blurCts;
List<PersonPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
Guid? selectedPersonId;
ElementReference personInputRef;
ElementReference personDropdownRef;
bool pendingPosition;
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showPersonDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", personInputRef, personDropdownRef);
}
}
void OnInputFocus() {
blurCts?.Cancel();
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
_ = Search(filterText);
}
}
async Task OnInput(ChangeEventArgs e) {
filterText = e.Value?.ToString() ?? string.Empty;
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
await Search(filterText);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await personService.GetAllAsync(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);
showPersonDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showPersonDropdown = false;
}
}
void SelectPerson(PersonPreviewDto person) {
void HandlePersonSelected(PersonPreviewDto person) {
selectedPersonId = person.Id;
filterText = person.Name;
showPersonDropdown = false;
searchResults = null;
}
async Task LinkAlbums() {
@@ -0,0 +1,132 @@
@using Butter.Dtos.Person
@inject PersonService personService
@inject IJSRuntime JSRuntime
<div class="position-relative">
<input type="text" class="form-control"
placeholder="@Placeholder"
value="@Query"
@ref="personInputRef"
@oninput="OnInput"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showPersonDropdown) {
<div class="dropdown-menu show person-dropdown-menu"
@ref="personDropdownRef">
@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 cosplayers</span>
} else {
@foreach (var person in searchResults) {
<button type="button"
class="dropdown-item @(SelectedPersonId == person.Id ? "active" : "")"
@onclick="() => SelectPerson(person)">
@person.Name
</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 person ID, used to highlight the active result.
/// </summary>
[Parameter] public Guid? SelectedPersonId { get; set; }
/// <summary>
/// Gets or sets the callback fired when a person is selected.
/// </summary>
[Parameter] public EventCallback<PersonPreviewDto> OnPersonSelected { get; set; }
/// <summary>
/// Gets or sets the input placeholder text.
/// </summary>
[Parameter] public string Placeholder { get; set; } = "Search cosplayers…";
bool showPersonDropdown;
CancellationTokenSource? blurCts;
List<PersonPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference personInputRef;
ElementReference personDropdownRef;
bool pendingPosition;
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showPersonDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", personInputRef, personDropdownRef);
}
}
void OnFocus() {
blurCts?.Cancel();
showPersonDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
_ = Search(Query);
}
}
async Task OnInput(ChangeEventArgs e) {
Query = e.Value?.ToString() ?? string.Empty;
await QueryChanged.InvokeAsync(Query);
showPersonDropdown = 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 personService.GetAllAsync(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);
showPersonDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showPersonDropdown = false;
}
}
void SelectPerson(PersonPreviewDto person) {
Query = person.Name;
_ = QueryChanged.InvokeAsync(Query);
showPersonDropdown = false;
searchResults = null;
_ = OnPersonSelected.InvokeAsync(person);
}
}
@@ -0,0 +1,28 @@
@if (ChildContent is not null) {
@* Desktop: inline button group *@
<CascadingValue Value="ResponsiveActionVariant.Desktop">
<div class="d-none d-md-flex gap-1 align-items-center">
@ChildContent
</div>
</CascadingValue>
@* Mobile: dropdown menu *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<CascadingValue Value="ResponsiveActionVariant.Mobile">
@ChildContent
</CascadingValue>
</ul>
</div>
}
@code {
/// <summary>
/// The action buttons, defined once and rendered as a desktop inline group and a mobile dropdown.
/// Each button is an <see cref="ActionButton"/> whose styling adapts to the current variant.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,17 @@
namespace MilkStream.Client.Components.Shared;
/// <summary>
/// The render context for an <see cref="ActionButton"/> inside a <see cref="ResponsiveActionBar"/>.
/// Desktop renders a compact inline button; mobile renders a full-width dropdown item.
/// </summary>
public enum ResponsiveActionVariant {
/// <summary>
/// Rendered as an inline compact button in the desktop toolbar.
/// </summary>
Desktop,
/// <summary>
/// Rendered as a list item inside the mobile dropdown menu.
/// </summary>
Mobile
}
+34
View File
@@ -0,0 +1,34 @@
namespace MilkStream.Client;
/// <summary>
/// A reusable debounce utility. Each <see cref="RunAsync"/> call cancels any pending run and
/// starts a fresh delay, so only the most recent invocation executes after the delay elapses.
/// Safe to call from the UI thread; continuations resume on it.
/// </summary>
public sealed class Debounce {
CancellationTokenSource? cts;
/// <summary>
/// Runs <paramref name="action"/> once, at most <paramref name="delayMs"/> after the last call.
/// </summary>
/// <param name="delayMs">The debounce delay in milliseconds.</param>
/// <param name="action">The action to run after the delay.</param>
public async Task RunAsync(int delayMs, Func<Task> action) {
cts?.Cancel();
cts?.Dispose();
var my = new CancellationTokenSource();
cts = my;
try {
await Task.Delay(delayMs, my.Token);
} catch (OperationCanceledException) {
return;
}
if (my.IsCancellationRequested) return;
await action();
}
/// <summary>
/// Cancels any pending run.
/// </summary>
public void Cancel() => cts?.Cancel();
}
+46
View File
@@ -0,0 +1,46 @@
namespace MilkStream.Client;
/// <summary>
/// Encapsulates the shared delete-confirmation wiring for pages that use a <c>ConfirmDialog</c>:
/// the visible flag, the message, and the deferred action to run on confirmation.
/// </summary>
public sealed class DeleteConfirmation {
Func<Task>? action;
/// <summary>
/// Gets whether the confirmation dialog is visible.
/// </summary>
public bool Show { get; private set; }
/// <summary>
/// Gets the confirmation message to display.
/// </summary>
public string Message { get; private set; } = "";
/// <summary>
/// Opens the confirmation dialog with the given message and deferred action.
/// </summary>
/// <param name="message">The message shown in the dialog.</param>
/// <param name="action">The action executed when the user confirms.</param>
public void Confirm(string message, Func<Task> action) {
Message = message;
this.action = action;
Show = true;
}
/// <summary>
/// Dismisses the dialog without running the action.
/// </summary>
public void Cancel() => Show = false;
/// <summary>
/// Runs the deferred action (if any) and dismisses the dialog.
/// </summary>
public async Task ExecuteAsync() {
Show = false;
var pending = action;
action = null;
if (pending != null)
await pending();
}
}
@@ -0,0 +1,119 @@
namespace MilkStream.Client;
/// <summary>
/// Encapsulates the infinite-scroll load state machine shared by paged grids: a sliding
/// <see cref="PageBuffer{T}"/> plus the load flags and next/previous page loading. Pages are
/// fetched on demand via a caller-supplied delegate; each loaded page is added to the buffer.
/// </summary>
/// <typeparam name="T">The item type (a DTO class).</typeparam>
public sealed class InfiniteScrollController<T> where T : class {
readonly Func<int, Task<List<T>?>> fetchPage;
readonly Action? onChanged;
/// <summary>
/// Initializes a new instance with a page fetcher and a deduplication key selector.
/// </summary>
/// <param name="fetchPage">Fetches one zero-based page and returns its items, or null on failure.</param>
/// <param name="keySelector">Selects the unique key used to deduplicate items across pages.</param>
/// <param name="onChanged">Optional callback invoked whenever load state changes (e.g. the component's <c>StateHasChanged</c>).</param>
public InfiniteScrollController(Func<int, Task<List<T>?>> fetchPage, Func<T, Guid> keySelector, Action? onChanged = null) {
this.fetchPage = fetchPage;
this.onChanged = onChanged;
Buffer = new PageBuffer<T>(keySelector) { MaxPages = 10 };
}
/// <summary>
/// Gets the sliding page buffer holding the currently loaded items.
/// </summary>
public PageBuffer<T> Buffer { get; }
/// <summary>
/// Gets the number of items requested per page.
/// </summary>
public int PageSize { get; init; } = 30;
/// <summary>
/// Gets whether a further (forward) page is available.
/// </summary>
public bool HasMore { get; private set; } = true;
/// <summary>
/// Gets whether a previous (backward) page is available.
/// </summary>
public bool HasMoreUp => Buffer.MinPage > 0;
/// <summary>
/// Gets whether an initial/filtered load is in progress.
/// </summary>
public bool IsLoading { get; private set; }
/// <summary>
/// Gets whether a forward page load is in progress.
/// </summary>
public bool IsLoadingMore { get; private set; }
/// <summary>
/// Gets whether a backward page load is in progress.
/// </summary>
public bool IsLoadingUp { get; private set; }
/// <summary>
/// Gets whether any items are currently buffered.
/// </summary>
public bool HasData => Buffer.Count > 0;
/// <summary>
/// Reloads from the first page, clearing the buffer.
/// </summary>
public async Task ReloadAsync() {
IsLoading = true;
Buffer.Clear();
HasMore = true;
Notify();
var items = await fetchPage(0);
if (items?.Count > 0)
Buffer.AddPage(0, items);
HasMore = items?.Count == PageSize;
IsLoading = false;
Notify();
}
/// <summary>
/// Loads the next page and appends it to the buffer.
/// </summary>
public async Task LoadNextAsync() {
if (IsLoadingMore || !HasMore) return;
IsLoadingMore = true;
Notify();
var nextPage = Buffer.MaxPage + 1;
var items = await fetchPage(nextPage);
if (items?.Count > 0)
Buffer.AddPage(nextPage, items);
HasMore = items?.Count == PageSize;
IsLoadingMore = false;
Notify();
}
/// <summary>
/// Loads the previous page and prepends it to the buffer.
/// </summary>
public async Task LoadPreviousAsync() {
if (IsLoadingUp || !HasMoreUp) return;
IsLoadingUp = true;
Notify();
var prevPage = Buffer.MinPage - 1;
if (prevPage >= 0) {
var items = await fetchPage(prevPage);
if (items?.Count > 0)
Buffer.PrependPage(prevPage, items);
}
IsLoadingUp = false;
Notify();
}
void Notify() => onChanged?.Invoke();
}
+74
View File
@@ -0,0 +1,74 @@
namespace MilkStream.Client;
/// <summary>
/// Encapsulates a set of selected items and the select-all / toggle operations shared by pages.
/// Backed by a <see cref="HashSet{T}"/>.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
public sealed class MultiSelect<T> where T : notnull {
readonly HashSet<T> selected = [];
/// <summary>
/// Gets the currently selected items.
/// </summary>
public IReadOnlyCollection<T> Selected => selected;
/// <summary>
/// Gets the number of selected items.
/// </summary>
public int Count => selected.Count;
/// <summary>
/// Gets whether the given item is selected.
/// </summary>
public bool Contains(T item) => selected.Contains(item);
/// <summary>
/// Toggles the given item's selection state.
/// </summary>
public void Toggle(T item) {
if (!selected.Add(item)) selected.Remove(item);
}
/// <summary>
/// Selects the given item.
/// </summary>
public void Add(T item) => selected.Add(item);
/// <summary>
/// Deselects the given item.
/// </summary>
public void Remove(T item) => selected.Remove(item);
/// <summary>
/// Selects all of the given items.
/// </summary>
public void AddRange(IEnumerable<T> items) => selected.UnionWith(items);
/// <summary>
/// Clears the selection.
/// </summary>
public void Clear() => selected.Clear();
/// <summary>
/// Gets a list copy of the selected items.
/// </summary>
public List<T> ToList() => [.. selected];
/// <summary>
/// Gets whether all of the given items are selected. Assumes <paramref name="all"/> has no duplicates.
/// </summary>
public bool IsAllSelected(IReadOnlyCollection<T> all) => all.Count > 0 && selected.Count == all.Count;
/// <summary>
/// Selects all of the given items, or clears the selection when they are already all selected.
/// </summary>
public void ToggleAll(IReadOnlyCollection<T> all) {
if (IsAllSelected(all)) {
selected.Clear();
} else {
selected.Clear();
selected.UnionWith(all);
}
}
}
+3 -4
View File
@@ -11,11 +11,11 @@ public sealed class SelectionRange {
/// Applies a click to the given item. When shift is held, selects the inclusive range
/// between the last clicked item and this one; otherwise toggles the single item.
/// </summary>
/// <param name="selection">The selection set to mutate.</param>
/// <param name="selection">The selection to mutate.</param>
/// <param name="orderedIds">The currently visible items in render order.</param>
/// <param name="id">The clicked item ID.</param>
/// <param name="shift">Whether the Shift modifier is held.</param>
public void Toggle(HashSet<Guid> selection, IReadOnlyList<Guid> orderedIds, Guid id, bool shift) {
public void Toggle(MultiSelect<Guid> selection, IReadOnlyList<Guid> orderedIds, Guid id, bool shift) {
var anchorIndex = hasAnchor ? IndexOf(orderedIds, anchorId) : -1;
var index = IndexOf(orderedIds, id);
if (index < 0) {
@@ -29,8 +29,7 @@ public sealed class SelectionRange {
for (var i = start; i <= end; i++)
selection.Add(orderedIds[i]);
} else {
if (!selection.Remove(id))
selection.Add(id);
selection.Toggle(id);
}
anchorId = id;
+52
View File
@@ -0,0 +1,52 @@
using Butter.Types;
namespace MilkStream.Client;
/// <summary>
/// Encapsulates the shared visibility-modal wiring: visibility flag, selected <see cref="EVisibility"/>,
/// and an optional error message. The Apply logic itself stays in the owning page (it calls a service).
/// </summary>
public sealed class VisibilityApplyState {
/// <summary>
/// Gets whether the visibility modal is visible.
/// </summary>
public bool Show { get; private set; }
/// <summary>
/// Gets or sets the selected visibility.
/// </summary>
public EVisibility Selection { get; set; } = EVisibility.Public;
/// <summary>
/// Gets the current error message, if any.
/// </summary>
public string? Error { get; private set; }
/// <summary>
/// Opens the modal with a default <see cref="EVisibility.Public"/> selection and no error.
/// </summary>
public void Open() {
Selection = EVisibility.Public;
Error = null;
Show = true;
}
/// <summary>
/// Dismisses the modal.
/// </summary>
public void Close() => Show = false;
/// <summary>
/// Sets the error message shown in the modal.
/// </summary>
/// <param name="error">The error message, or null to clear it.</param>
public void SetError(string? error) => Error = error;
/// <summary>
/// Marks the apply as successful, dismissing the modal and clearing the error.
/// </summary>
public void MarkApplied() {
Show = false;
Error = null;
}
}