diff --git a/MilkStream.Client/Components/Pages/AlbumDetail.razor b/MilkStream.Client/Components/Pages/AlbumDetail.razor index 9f18a66..e2d91be 100644 --- a/MilkStream.Client/Components/Pages/AlbumDetail.razor +++ b/MilkStream.Client/Components/Pages/AlbumDetail.razor @@ -46,89 +46,26 @@
@if (loginService.CanEdit(album?.Person)) { - @* Desktop: inline buttons *@ -
+ @if (selectMode) { - - - - - + + + + + + } else { - - + + } - -
- @* Mobile: dropdown *@ -
- - -
+ + + }
@@ -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);
@@ -199,23 +136,24 @@ } -@if (showVisibilityModal) { +@if (visibility.Show) { + OnClose="visibility.Close" /> } - + OnConfirm="ExecuteDeletePending" + OnCancel="deleteConfirm.Cancel" /> @if (showAssetPicker && album != null) { ? pendingDeleteAction; - EVisibility selectedVisibility = EVisibility.Public; - HashSet selectedImageIds = []; + DeleteConfirmation deleteConfirm = new(); + VisibilityApplyState visibility = new(); + MultiSelect 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(); @@ -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(); } diff --git a/MilkStream.Client/Components/Pages/Albums.razor b/MilkStream.Client/Components/Pages/Albums.razor index 427a915..81a3bc1 100644 --- a/MilkStream.Client/Components/Pages/Albums.razor +++ b/MilkStream.Client/Components/Pages/Albums.razor @@ -21,98 +21,33 @@ SortOptions="@sortOptions"> @if (loginService.CanEditAll) { - @* Desktop: inline buttons *@ -
+ @if (selectMode) { - - - - - - + + + + + + } else { - + } -
- @* Mobile: dropdown *@ -
- - -
+ + + } - @if (loginService.CanEditAll) { - - }
@if (showVisibilityModal) { } @@ -179,8 +114,8 @@ bool cascadeToAssets; EVisibility selectedAlbumVisibility = EVisibility.Public; string? visibilityError; - HashSet selectedAlbumIds = []; - bool albumsAllSelected => albumGridRef != null && selectedAlbumIds.Count == albumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0; + MultiSelect 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."; diff --git a/MilkStream.Client/Components/Pages/CosplayerDetail.razor b/MilkStream.Client/Components/Pages/CosplayerDetail.razor index 1318a28..4d74f5e 100644 --- a/MilkStream.Client/Components/Pages/CosplayerDetail.razor +++ b/MilkStream.Client/Components/Pages/CosplayerDetail.razor @@ -103,100 +103,29 @@ } @if (loginService.CanEdit(Id)) { - @* Desktop: inline buttons *@ -
- + + @if (person.TotalAlbums > 0) { - if (selectMode) { - - - - - - + @if (selectMode) { + + + + + + } else { - + } + } - -
- @* Mobile: dropdown *@ -
- - -
+ + } @@ -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) { } - + OnConfirm="ExecuteDeletePending" + OnCancel="deleteConfirm.Cancel" /> @code { [Parameter] @@ -301,9 +230,7 @@ bool showAlbumVisibilityModal; bool showAlbumMergeModal; List albumMergeItems = []; - bool showDeleteConfirm; - string deleteConfirmMessage = ""; - Func? pendingDeleteAction; + DeleteConfirmation deleteConfirm = new(); bool cascadeToAssets; EVisibility selectedAlbumVisibility = EVisibility.Public; string? visibilityError; @@ -311,7 +238,7 @@ bool showAlbumAssigner; List? unassignedAlbums; HashSet assignAlbumIds = []; - HashSet selectedAlbumIds = []; + MultiSelect selectedAlbums = new(); SelectionRange albumSelectionRange = new(); List 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(); } diff --git a/MilkStream.Client/Components/Pages/Cosplayers.razor b/MilkStream.Client/Components/Pages/Cosplayers.razor index 120feb5..be1d153 100644 --- a/MilkStream.Client/Components/Pages/Cosplayers.razor +++ b/MilkStream.Client/Components/Pages/Cosplayers.razor @@ -20,80 +20,23 @@ SortOptions="@sortOptions"> @if (loginService.IsAdminOrCurator) { - @* Desktop: inline buttons *@ -
+ @if (cosplayerSelectMode) { - - - - - + + + + + } else { - + } - -
- @* Mobile: dropdown *@ -
- - -
+ + + }
@@ -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) { selectedCosplayerIds = []; + MultiSelect 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 } ); diff --git a/MilkStream.Client/Components/Pages/Home.razor b/MilkStream.Client/Components/Pages/Home.razor index aadbe24..03443bb 100644 --- a/MilkStream.Client/Components/Pages/Home.razor +++ b/MilkStream.Client/Components/Pages/Home.razor @@ -10,9 +10,9 @@ Home -@if (isLoading) { +@if (controller!.IsLoading) { -} else if (flatList.Count == 0) { +} else if (controller!.Buffer.Count == 0) { @if (loginService.IsLoggedIn) {

You are logged in, but there is no media available at the moment.

@@ -23,13 +23,13 @@ } else {
- @if (isLoadingUp) { + @if (controller!.IsLoadingUp) { }
@{ var idx = 0; } - @foreach (var asset in flatList) { + @foreach (var asset in controller!.Buffer.FlatList) { var capturedIndex = idx;
- @if (isLoadingMore) { + @if (controller!.IsLoadingMore) { } @@ -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> allAssetPages = []; - List flatList = []; - int currentPage = 1; - int loadedMinPage = 1; - int loadedMaxPage = 1; - bool hasMore = true; - bool hasMoreUp; - bool isLoading = true; - bool isLoadingMore; - bool isLoadingUp; + InfiniteScrollController? controller; AssetPreviewDto? selectedAsset; int selectedIndex = -1; ElementReference sentinelRef; @@ -137,14 +128,22 @@ DotNetObjectReference? dotNetRef; Guid randomSeed; - protected override async Task OnInitializedAsync() { + protected override void OnInitialized() { loginService.LoggedUserChanged += (_, _) => StateHasChanged(); loginService.AuthInfoChanged += (_, _) => StateHasChanged(); - await LoadFirstPage(); + controller = new InfiniteScrollController( + 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(); - 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; diff --git a/MilkStream.Client/Components/Shared/ActionButton.razor b/MilkStream.Client/Components/Shared/ActionButton.razor new file mode 100644 index 0000000..f240fec --- /dev/null +++ b/MilkStream.Client/Components/Shared/ActionButton.razor @@ -0,0 +1,81 @@ +@code { + [CascadingParameter] + public ResponsiveActionVariant Variant { get; set; } = ResponsiveActionVariant.Desktop; + + /// + /// Bootstrap icon class rendered before the label (e.g. "bi-trash"). + /// + [Parameter] + public string? Icon { get; set; } + + /// + /// The button label. May embed a dynamic value such as a selection count. + /// + [Parameter] + public string? Label { get; set; } + + /// + /// Extra classes for the desktop button (e.g. "btn-danger", "btn-outline-secondary"). + /// + [Parameter] + public string Class { get; set; } = ""; + + /// + /// Extra classes for the mobile dropdown item (e.g. "text-danger"). Defaults to . + /// + [Parameter] + public string MobileClass { get; set; } = ""; + + /// + /// Optional icon override for the mobile dropdown item. + /// + [Parameter] + public string? MobileIcon { get; set; } + + /// + /// Optional label override for the mobile dropdown item. + /// + [Parameter] + public string? MobileLabel { get; set; } + + /// + /// Gets or sets whether the button is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the callback invoked when the button is clicked. + /// + [Parameter] + public EventCallback OnClick { get; set; } + + /// + /// Gets or sets the tooltip text shown on hover (desktop). + /// + [Parameter] + public string Title { get; set; } = ""; +} + +@if (Variant == ResponsiveActionVariant.Mobile) { +
  • + +
  • +} else { + +} diff --git a/MilkStream.Client/Components/Shared/AlbumAssetPicker.razor b/MilkStream.Client/Components/Shared/AlbumAssetPicker.razor index 70526f6..a573274 100644 --- a/MilkStream.Client/Components/Shared/AlbumAssetPicker.razor +++ b/MilkStream.Client/Components/Shared/AlbumAssetPicker.razor @@ -12,155 +12,7 @@ @if (Embedded) {
    -
    -
    - - @if (!sidebarCollapsed) { - @if (groups != null && groups.Count > 0) { - @foreach (var group in groups) { - - } - } else { -
    No folders found.
    - } - } -
    -
    - @if (isAdminOrCurator && selectedGroup != null) { -
    - - @foreach (var item in GetBreadcrumbs()) { - / - - } -
    -
    - - -
    - @if (browseResult?.Directories.Count > 0) { -
    - @foreach (var dir in browseResult.Directories.OrderBy(d => d.Name)) { - - } -
    - } - @if (browseResult != null) { - @if (browseResult.Directories.Count == 0 && browseResult.Assets.Count == 0) { - -

    @(string.IsNullOrEmpty(searchQuery) ? "This directory contains no assets." : $"No assets matching \"{searchQuery}\".")

    -
    - } else if (browseResult.Assets.Count > 0) { - @if (listView) { -
    - @foreach (var asset in browseResult.Assets) { -
    - - @(asset.FileName ?? asset.Id.ToString()) -
    - } -
    - } else { -
    - @foreach (var asset in browseResult.Assets) { -
    - @if (asset.HasThumbnail) { - - - } else { -
    - } -
    - -
    -
    - } -
    - } -
    - - @(browseResult.TotalAssetCount) assets - -
    - } - } - } else if (!isAdminOrCurator && selectedGroup != null) { -
    - - -
    - @if (assetPage == null || assetPage.Count == 0) { - -

    @(string.IsNullOrEmpty(searchQuery) ? "No assets here." : $"No assets matching \"{searchQuery}\".")

    -
    - } else { - @if (listView) { -
    - @foreach (var asset in assetPage) { -
    - - @(asset.FileName ?? asset.Id.ToString()) -
    - } -
    - } else { -
    - @foreach (var asset in assetPage) { -
    - @if (asset.HasThumbnail) { - - - } else { -
    - } -
    - -
    -
    - } -
    - } -
    - - -
    - } - } else if (isAdminOrCurator) { - -

    Choose a folder from the sidebar to browse assets.

    -
    - } -
    -
    + @PickerLayout +
    @if (selectedPersonId.HasValue) {
    @@ -75,74 +46,10 @@ [Parameter] public EventCallback OnClose { get; set; } string filterText = string.Empty; - bool showPersonDropdown; - CancellationTokenSource? blurCts; - List? 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() { diff --git a/MilkStream.Client/Components/Shared/PersonSearchDropdown.razor b/MilkStream.Client/Components/Shared/PersonSearchDropdown.razor new file mode 100644 index 0000000..0aad780 --- /dev/null +++ b/MilkStream.Client/Components/Shared/PersonSearchDropdown.razor @@ -0,0 +1,132 @@ +@using Butter.Dtos.Person +@inject PersonService personService +@inject IJSRuntime JSRuntime + +
    + + @if (showPersonDropdown) { + + } +
    + +@code { + /// + /// Gets or sets the current search text (two-way bound so the owner can prefill/reset it). + /// + [Parameter] public string Query { get; set; } = ""; + + /// + /// Gets or sets the callback fired as changes. + /// + [Parameter] public EventCallback QueryChanged { get; set; } + + /// + /// Gets or sets the currently selected person ID, used to highlight the active result. + /// + [Parameter] public Guid? SelectedPersonId { get; set; } + + /// + /// Gets or sets the callback fired when a person is selected. + /// + [Parameter] public EventCallback OnPersonSelected { get; set; } + + /// + /// Gets or sets the input placeholder text. + /// + [Parameter] public string Placeholder { get; set; } = "Search cosplayers…"; + + bool showPersonDropdown; + CancellationTokenSource? blurCts; + List? 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); + } +} diff --git a/MilkStream.Client/Components/Shared/ResponsiveActionBar.razor b/MilkStream.Client/Components/Shared/ResponsiveActionBar.razor new file mode 100644 index 0000000..bbf2051 --- /dev/null +++ b/MilkStream.Client/Components/Shared/ResponsiveActionBar.razor @@ -0,0 +1,28 @@ +@if (ChildContent is not null) { + @* Desktop: inline button group *@ + +
    + @ChildContent +
    +
    + @* Mobile: dropdown menu *@ +
    + + +
    +} + +@code { + /// + /// The action buttons, defined once and rendered as a desktop inline group and a mobile dropdown. + /// Each button is an whose styling adapts to the current variant. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/MilkStream.Client/Components/Shared/ResponsiveActionVariant.cs b/MilkStream.Client/Components/Shared/ResponsiveActionVariant.cs new file mode 100644 index 0000000..aa09520 --- /dev/null +++ b/MilkStream.Client/Components/Shared/ResponsiveActionVariant.cs @@ -0,0 +1,17 @@ +namespace MilkStream.Client.Components.Shared; + +/// +/// The render context for an inside a . +/// Desktop renders a compact inline button; mobile renders a full-width dropdown item. +/// +public enum ResponsiveActionVariant { + /// + /// Rendered as an inline compact button in the desktop toolbar. + /// + Desktop, + + /// + /// Rendered as a list item inside the mobile dropdown menu. + /// + Mobile +} diff --git a/MilkStream.Client/Debounce.cs b/MilkStream.Client/Debounce.cs new file mode 100644 index 0000000..2338845 --- /dev/null +++ b/MilkStream.Client/Debounce.cs @@ -0,0 +1,34 @@ +namespace MilkStream.Client; + +/// +/// A reusable debounce utility. Each 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. +/// +public sealed class Debounce { + CancellationTokenSource? cts; + + /// + /// Runs once, at most after the last call. + /// + /// The debounce delay in milliseconds. + /// The action to run after the delay. + public async Task RunAsync(int delayMs, Func 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(); + } + + /// + /// Cancels any pending run. + /// + public void Cancel() => cts?.Cancel(); +} diff --git a/MilkStream.Client/DeleteConfirmation.cs b/MilkStream.Client/DeleteConfirmation.cs new file mode 100644 index 0000000..48b02cd --- /dev/null +++ b/MilkStream.Client/DeleteConfirmation.cs @@ -0,0 +1,46 @@ +namespace MilkStream.Client; + +/// +/// Encapsulates the shared delete-confirmation wiring for pages that use a ConfirmDialog: +/// the visible flag, the message, and the deferred action to run on confirmation. +/// +public sealed class DeleteConfirmation { + Func? action; + + /// + /// Gets whether the confirmation dialog is visible. + /// + public bool Show { get; private set; } + + /// + /// Gets the confirmation message to display. + /// + public string Message { get; private set; } = ""; + + /// + /// Opens the confirmation dialog with the given message and deferred action. + /// + /// The message shown in the dialog. + /// The action executed when the user confirms. + public void Confirm(string message, Func action) { + Message = message; + this.action = action; + Show = true; + } + + /// + /// Dismisses the dialog without running the action. + /// + public void Cancel() => Show = false; + + /// + /// Runs the deferred action (if any) and dismisses the dialog. + /// + public async Task ExecuteAsync() { + Show = false; + var pending = action; + action = null; + if (pending != null) + await pending(); + } +} diff --git a/MilkStream.Client/InfiniteScrollController.cs b/MilkStream.Client/InfiniteScrollController.cs new file mode 100644 index 0000000..3d41707 --- /dev/null +++ b/MilkStream.Client/InfiniteScrollController.cs @@ -0,0 +1,119 @@ +namespace MilkStream.Client; + +/// +/// Encapsulates the infinite-scroll load state machine shared by paged grids: a sliding +/// 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. +/// +/// The item type (a DTO class). +public sealed class InfiniteScrollController where T : class { + readonly Func?>> fetchPage; + readonly Action? onChanged; + + /// + /// Initializes a new instance with a page fetcher and a deduplication key selector. + /// + /// Fetches one zero-based page and returns its items, or null on failure. + /// Selects the unique key used to deduplicate items across pages. + /// Optional callback invoked whenever load state changes (e.g. the component's StateHasChanged). + public InfiniteScrollController(Func?>> fetchPage, Func keySelector, Action? onChanged = null) { + this.fetchPage = fetchPage; + this.onChanged = onChanged; + Buffer = new PageBuffer(keySelector) { MaxPages = 10 }; + } + + /// + /// Gets the sliding page buffer holding the currently loaded items. + /// + public PageBuffer Buffer { get; } + + /// + /// Gets the number of items requested per page. + /// + public int PageSize { get; init; } = 30; + + /// + /// Gets whether a further (forward) page is available. + /// + public bool HasMore { get; private set; } = true; + + /// + /// Gets whether a previous (backward) page is available. + /// + public bool HasMoreUp => Buffer.MinPage > 0; + + /// + /// Gets whether an initial/filtered load is in progress. + /// + public bool IsLoading { get; private set; } + + /// + /// Gets whether a forward page load is in progress. + /// + public bool IsLoadingMore { get; private set; } + + /// + /// Gets whether a backward page load is in progress. + /// + public bool IsLoadingUp { get; private set; } + + /// + /// Gets whether any items are currently buffered. + /// + public bool HasData => Buffer.Count > 0; + + /// + /// Reloads from the first page, clearing the buffer. + /// + 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(); + } + + /// + /// Loads the next page and appends it to the buffer. + /// + 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(); + } + + /// + /// Loads the previous page and prepends it to the buffer. + /// + 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(); +} diff --git a/MilkStream.Client/MultiSelect.cs b/MilkStream.Client/MultiSelect.cs new file mode 100644 index 0000000..47607ae --- /dev/null +++ b/MilkStream.Client/MultiSelect.cs @@ -0,0 +1,74 @@ +namespace MilkStream.Client; + +/// +/// Encapsulates a set of selected items and the select-all / toggle operations shared by pages. +/// Backed by a . +/// +/// The item type. +public sealed class MultiSelect where T : notnull { + readonly HashSet selected = []; + + /// + /// Gets the currently selected items. + /// + public IReadOnlyCollection Selected => selected; + + /// + /// Gets the number of selected items. + /// + public int Count => selected.Count; + + /// + /// Gets whether the given item is selected. + /// + public bool Contains(T item) => selected.Contains(item); + + /// + /// Toggles the given item's selection state. + /// + public void Toggle(T item) { + if (!selected.Add(item)) selected.Remove(item); + } + + /// + /// Selects the given item. + /// + public void Add(T item) => selected.Add(item); + + /// + /// Deselects the given item. + /// + public void Remove(T item) => selected.Remove(item); + + /// + /// Selects all of the given items. + /// + public void AddRange(IEnumerable items) => selected.UnionWith(items); + + /// + /// Clears the selection. + /// + public void Clear() => selected.Clear(); + + /// + /// Gets a list copy of the selected items. + /// + public List ToList() => [.. selected]; + + /// + /// Gets whether all of the given items are selected. Assumes has no duplicates. + /// + public bool IsAllSelected(IReadOnlyCollection all) => all.Count > 0 && selected.Count == all.Count; + + /// + /// Selects all of the given items, or clears the selection when they are already all selected. + /// + public void ToggleAll(IReadOnlyCollection all) { + if (IsAllSelected(all)) { + selected.Clear(); + } else { + selected.Clear(); + selected.UnionWith(all); + } + } +} diff --git a/MilkStream.Client/SelectionRange.cs b/MilkStream.Client/SelectionRange.cs index 481aa90..f869fdd 100644 --- a/MilkStream.Client/SelectionRange.cs +++ b/MilkStream.Client/SelectionRange.cs @@ -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. /// - /// The selection set to mutate. + /// The selection to mutate. /// The currently visible items in render order. /// The clicked item ID. /// Whether the Shift modifier is held. - public void Toggle(HashSet selection, IReadOnlyList orderedIds, Guid id, bool shift) { + public void Toggle(MultiSelect selection, IReadOnlyList 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; diff --git a/MilkStream.Client/VisibilityApplyState.cs b/MilkStream.Client/VisibilityApplyState.cs new file mode 100644 index 0000000..5fa905e --- /dev/null +++ b/MilkStream.Client/VisibilityApplyState.cs @@ -0,0 +1,52 @@ +using Butter.Types; + +namespace MilkStream.Client; + +/// +/// Encapsulates the shared visibility-modal wiring: visibility flag, selected , +/// and an optional error message. The Apply logic itself stays in the owning page (it calls a service). +/// +public sealed class VisibilityApplyState { + /// + /// Gets whether the visibility modal is visible. + /// + public bool Show { get; private set; } + + /// + /// Gets or sets the selected visibility. + /// + public EVisibility Selection { get; set; } = EVisibility.Public; + + /// + /// Gets the current error message, if any. + /// + public string? Error { get; private set; } + + /// + /// Opens the modal with a default selection and no error. + /// + public void Open() { + Selection = EVisibility.Public; + Error = null; + Show = true; + } + + /// + /// Dismisses the modal. + /// + public void Close() => Show = false; + + /// + /// Sets the error message shown in the modal. + /// + /// The error message, or null to clear it. + public void SetError(string? error) => Error = error; + + /// + /// Marks the apply as successful, dismissing the modal and clearing the error. + /// + public void MarkApplied() { + Show = false; + Error = null; + } +}