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