diff --git a/web/src/lib/components/layout/IndexerStatusPill.svelte b/web/src/lib/components/layout/IndexerStatusPill.svelte index 865e435..c262934 100644 --- a/web/src/lib/components/layout/IndexerStatusPill.svelte +++ b/web/src/lib/components/layout/IndexerStatusPill.svelte @@ -1,49 +1,8 @@ - + -{#if indexer.active || indexer.label} -
- {#if indexer.active} - - {/if} - {indexer.label} - {#if basename} - - - {basename} - - {/if} -
-{/if} + diff --git a/web/src/lib/components/layout/StatusPill.svelte b/web/src/lib/components/layout/StatusPill.svelte new file mode 100644 index 0000000..7b5f297 --- /dev/null +++ b/web/src/lib/components/layout/StatusPill.svelte @@ -0,0 +1,46 @@ + + + +{#if active || label} +
+ {#if active} + + {/if} + {label} + {#if basename} + + {basename} + + {/if} +
+{/if} diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index 02809c5..8a07125 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -26,6 +26,7 @@ import { filters } from '$lib/stores/filters.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte'; + import { startBulk, setDetail, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte'; import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { Layers } from 'lucide-svelte'; @@ -113,10 +114,27 @@ setFocused(null); } - async function withBusy(fn: () => Promise): Promise { + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + interface BulkConfig { + ids: string[]; + label: string; + doneLabel: string; + } + + async function withBusy(fn: () => Promise, bulk?: BulkConfig): Promise { busy = true; + if (bulk) startBulk(`${bulk.label}…`, bulk.ids); try { - return await fn(); + const result = await fn(); + if (bulk) { + doneBulk(bulk.doneLabel, bulk.ids); + await delay(400); + } + return result; + } catch (e) { + if (bulk) failBulk(bulk.ids); + throw e; } finally { busy = false; void qc.invalidateQueries({ queryKey: ['photos'] }); @@ -131,7 +149,12 @@ // no /unapprove route. We fan out per-photo because there's no // batch endpoint either. Errors are tallied rather than aborting // the loop so a single bad UID doesn't block the rest. - const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id)); + const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), { + onProgress: (_done, _total, completedId) => { + const p = cachedPhoto(completedId); + setDetail(p?.FileName ?? completedId); + } + }); if (errors.length) { toast.error(`Kept ${updated.length}; ${errors.length} failed`); } else { @@ -139,13 +162,17 @@ } focusAfter(ids); clearSelection(); - }); + }, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` }); } async function onAcceptDateAndKeep() { const ids = snapshotIds(); if (ids.length === 0) return; - await withBusy(() => acceptDateAndKeep(ids)); + await withBusy(() => acceptDateAndKeep(ids), { + ids, + label: 'Updating', + doneLabel: `Updated ${ids.length}` + }); } async function onArchive() { @@ -167,7 +194,7 @@ } catch (err) { toast.error(err instanceof Error ? err.message : 'Archive failed'); } - }); + }, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` }); } async function onDelete() { @@ -187,7 +214,7 @@ } catch (err) { toast.error(err instanceof Error ? err.message : 'Delete failed'); } - }); + }, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` }); } async function onRestore() { @@ -206,13 +233,14 @@ } catch (err) { toast.error(err instanceof Error ? err.message : 'Restore failed'); } - }); + }, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` }); } async function onAddToHeap(heap: PpAlbum) { const ids = snapshotIds(); if (!ids.length) return; heapPickerOpen = false; + startBulk(`Adding to ${heap.Title}…`, ids); await withBusy(async () => { try { const { added } = await addToHeap(heap.UID, ids); @@ -222,11 +250,14 @@ // real delta so the user isn't fooled by a green toast over // a no-op. if (added.length === 0) { + failBulk(ids); toast.error(`Nothing added to ${heap.Title}`, { description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).` }); return; } + doneBulk(`Added ${added.length} → ${heap.Title}`, ids); + await delay(400); if (added.length < ids.length) { toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { description: 'The rest were already in this heap.' @@ -240,6 +271,7 @@ }); clearSelection(); } catch (err) { + failBulk(ids); toast.error(err instanceof Error ? err.message : 'Add-to-heap failed'); } }); diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte index 9ea5b2c..a76305f 100644 --- a/web/src/lib/components/timeline/PhotoTile.svelte +++ b/web/src/lib/components/timeline/PhotoTile.svelte @@ -16,6 +16,9 @@ import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte"; import { view } from "$lib/stores/view.svelte"; import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism"; + import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte"; + import { fade } from "svelte/transition"; + import { Loader2, Check, X } from "lucide-svelte"; interface Props { photo: PpPhoto; @@ -51,7 +54,7 @@ let hoverTimer: ReturnType | null = null; function onMouseEnter() { - if (!video || selected) return; + if (!video || selected || bulkState) return; if (hoverTimer) clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { hoverPlaying = true; @@ -73,6 +76,7 @@ const tilePx = $derived(view.thumbnailSize); const src1x = $derived(thumbSrc(hash, tilePx)); const srcset = $derived(thumbSrcSet(hash, tilePx)); + const bulkState = $derived(bulkPhotoStates.get(photo.UID));