From 5da1022ed12ec78d33fad8ea6b7e1a48e8c4d31f Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 7 Jun 2026 21:40:18 +0200 Subject: [PATCH] feat: loading toasts for all photo actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add loading→success/error toast transition to every bulk operation (archive, restore, delete, approve, add-to-heap, metadata patch). Also wires gridKeyNav + CauseGroupCard into the bulkAction store so keyboard-triggered actions show the same per-tile pending/done/error feedback as BulkActionBar buttons. --- web/src/lib/actions/gridKeyNav.ts | 81 ++++++++++--------- .../components/review/CauseGroupCard.svelte | 12 ++- .../sidebar/BulkMetadataSidebar.svelte | 9 +-- .../components/timeline/BulkActionBar.svelte | 38 ++++----- web/src/lib/services/bulk.ts | 7 +- web/src/lib/services/photoActions.ts | 16 ++-- web/src/routes/+page.svelte | 5 +- 7 files changed, 88 insertions(+), 80 deletions(-) diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 7f553ae..47bd6a2 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -27,6 +27,7 @@ import { toggle } from '$lib/stores/selection.svelte'; import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; +import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; /** @@ -180,34 +181,24 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { target = !(first?.Archived ?? false); } - // PhotoPrism's photo PUT silently drops the Archived field — the - // only working path is /api/v1/batch/photos/{archive,restore}. The - // previous patchTargets call PUT'd `{Archived: true}` and got a 200 - // back, so the toast fired but nothing moved. + const opLabel = target ? 'Archiving' : 'Restoring'; + const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; + const tid = toast.loading(`${opLabel} ${ids.length}…`); + startBulk(`${opLabel}…`, ids); try { if (target) await batchArchive(ids); else await batchRestore(ids); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Archive failed'); + failBulk(ids); + toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid }); return; } - // Move focus forward before the photos query refetches, so the - // user can keep X-ing through the timeline without their cursor - // snapping back to photo[0]. Walks past every uid we just - // archived/restored — relevant when the cull targets came from a - // multi-selection rather than the single focused tile. + doneBulk(doneLabel, ids); focusAfter(ids); - // Drop the now-stale selection set. The archived UIDs are about - // to leave the timeline on refetch, but the SvelteSet membership - // keeps the selection ring on them until then — confusing for - // the user and a footgun if they Ctrl-click to add more and end - // up re-archiving the same photos. The BulkActionBar button path - // clears for the same reason; mirror it here. clearSelection(); invalidatePhotos(ids); - const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; - toast.success(label); - pushUndo(label, async () => { + toast.success(doneLabel, { id: tid }); + pushUndo(doneLabel, async () => { if (target) await batchRestore(ids); else await batchArchive(ids); invalidatePhotos(ids); @@ -232,16 +223,20 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { ? 'Permanently delete this photo? This cannot be undone.' : `Permanently delete ${ids.length} photos? This cannot be undone.`; if (!confirm(msg)) return; + const tid = toast.loading(`Deleting ${ids.length}…`); + startBulk('Deleting…', ids); try { await batchDelete(ids); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Delete failed'); + failBulk(ids); + toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid }); return; } + doneBulk(`Deleted ${ids.length}`, ids); focusAfter(ids); clearSelection(); invalidatePhotos(ids); - toast.success(`Deleted ${ids.length}`); + toast.success(`Deleted ${ids.length}`, { id: tid }); } /** Approve cull targets — clears them out of the review pile by @@ -257,21 +252,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { }); return; } - const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id)); - // Approve moves photos out of the review pile, so the same - // stale-selection trap as archive/delete applies — advance focus - // past the approved set and drop the now-irrelevant selection - // before invalidate refetches the (smaller) view. + const tid = toast.loading(`Keeping ${ids.length}…`); + startBulk('Keeping…', ids); + const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), { + onProgress: (_done, _total, completedId) => { + const p = cachedPhoto(completedId); + if (p) setDetail(p.FileName ?? completedId); + } + }); + if (errors.length) { + failBulk(ids); + toast.error(`Kept ${updated.length}; ${errors.length} failed`, { + id: tid, + description: errors[0].message + }); + } else { + doneBulk(`Kept ${ids.length}`, ids); + toast.success(`Kept ${ids.length}`, { id: tid }); + } focusAfter(ids); clearSelection(); invalidatePhotos(ids); - if (errors.length) { - toast.error(`Kept ${updated.length}; ${errors.length} failed`, { - description: errors[0].message - }); - return; - } - toast.success(`Kept ${ids.length}`); } // ── S chord (add-to-heap) ──────────────────────────────────────────── @@ -297,25 +298,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { }); return; } + const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`); + startBulk(`Adding to ${heap.Title}…`, ids); try { const { added } = await addToHeap(heap.UID, ids); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); - // PhotoPrism returns 200 even when nothing was added — distinguish - // "really added N" from "skipped all N" so the toast tells the - // truth. if (added.length === 0) { + failBulk(ids); toast.error(`Nothing added to ${heap.Title}`, { + id: tid, description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).` }); return; } + doneBulk(`Added ${added.length} → ${heap.Title}`, ids); if (added.length < ids.length) { toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { + id: tid, description: 'The rest were already in this heap.' }); } else { - toast.success(`Added ${added.length} → ${heap.Title}`); + toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid }); } pushUndo(`Added ${added.length} to ${heap.Title}`, async () => { await removeFromHeap(heap.UID, added); @@ -323,7 +327,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { void queryClient.invalidateQueries({ queryKey: ['photos'] }); }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Add-to-heap failed'); + failBulk(ids); + toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid }); } } diff --git a/web/src/lib/components/review/CauseGroupCard.svelte b/web/src/lib/components/review/CauseGroupCard.svelte index 71f6009..7d2f72d 100644 --- a/web/src/lib/components/review/CauseGroupCard.svelte +++ b/web/src/lib/components/review/CauseGroupCard.svelte @@ -13,6 +13,7 @@ import { useQueryClient } from '@tanstack/svelte-query'; import { toast } from 'svelte-sonner'; import { batchArchive } from '$lib/services/photoprism'; + import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; import { type ReviewGroup } from '$lib/services/adapters/review'; @@ -28,14 +29,19 @@ if (busy || group.photos.length === 0) return; if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`)) return; + const uids = group.photos.map((p) => p.UID); + const tid = toast.loading(`Archiving ${uids.length}…`); busy = true; + startBulk(`Archiving…`, uids); try { - await batchArchive(group.photos.map((p) => p.UID)); - toast.success(`Archived ${group.photos.length}`); + await batchArchive(uids); + doneBulk(`Archived ${uids.length}`, uids); + toast.success(`Archived ${uids.length}`, { id: tid }); void qc.invalidateQueries({ queryKey: ['review-groups'] }); void qc.invalidateQueries({ queryKey: ['photos'] }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Archive all failed'); + failBulk(uids); + toast.error(err instanceof Error ? err.message : 'Archive all failed', { id: tid }); } finally { busy = false; } diff --git a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte index f31352e..1ee993e 100644 --- a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte +++ b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte @@ -80,11 +80,8 @@ async function applyMarks(patch: PhotoMark, label: string) { if (busy) return; + const tid = toast.loading(`${label}…`); await withBusy(async () => { - // Optimistic: patch every selected photo's mark in the local - // cache before round-tripping. Sidecar bulk endpoint is - // authoritative; on failure we just invalidate so the next - // list query overrides. qc.setQueryData(['marks'], (prev) => { const map = { ...(prev ?? {}) }; for (const id of ids) { @@ -98,9 +95,9 @@ }); try { await bulkSetMarks(ids, patch); - toast.success(`${label} · ${ids.length}`); + toast.success(`${label} · ${ids.length}`, { id: tid }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Save failed'); + toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid }); void qc.invalidateQueries({ queryKey: ['marks'] }); } }); diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index 8a07125..8b34486 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -144,11 +144,8 @@ async function onApprove() { const ids = snapshotIds(); if (ids.length === 0) return; + const tid = toast.loading(`Keeping ${ids.length}…`); await withBusy(async () => { - // PhotoPrism's approve is one-way (Quality jumps to 3+); there's - // 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), { onProgress: (_done, _total, completedId) => { const p = cachedPhoto(completedId); @@ -156,9 +153,9 @@ } }); if (errors.length) { - toast.error(`Kept ${updated.length}; ${errors.length} failed`); + toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid }); } else { - toast.success(`Kept ${ids.length}`); + toast.success(`Kept ${ids.length}`, { id: tid }); } focusAfter(ids); clearSelection(); @@ -178,6 +175,7 @@ async function onArchive() { const ids = snapshotIds(); if (ids.length === 0) return; + const tid = toast.loading(`Archiving ${ids.length}…`); await withBusy(async () => { try { await batchArchive(ids); @@ -185,14 +183,11 @@ await batchRestore(ids); void qc.invalidateQueries({ queryKey: ['photos'] }); }); - // Advance focus to the photo immediately after the archived - // set before the multi-selection is dropped — lets the user - // keep stepping through the timeline with X. focusAfter(ids); clearSelection(); - toast.success(`Archived ${ids.length}`); + toast.success(`Archived ${ids.length}`, { id: tid }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Archive failed'); + toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid }); } }, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` }); } @@ -205,14 +200,15 @@ ? 'Permanently delete this photo? This cannot be undone.' : `Permanently delete ${ids.length} photos? This cannot be undone.`; if (!confirm(msg)) return; + const tid = toast.loading(`Deleting ${ids.length}…`); await withBusy(async () => { try { await batchDelete(ids); focusAfter(ids); clearSelection(); - toast.success(`Deleted ${ids.length}`); + toast.success(`Deleted ${ids.length}`, { id: tid }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Delete failed'); + toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid }); } }, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` }); } @@ -220,6 +216,7 @@ async function onRestore() { const ids = snapshotIds(); if (ids.length === 0) return; + const tid = toast.loading(`Restoring ${ids.length}…`); await withBusy(async () => { try { await batchRestore(ids); @@ -229,9 +226,9 @@ }); focusAfter(ids); clearSelection(); - toast.success(`Restored ${ids.length}`); + toast.success(`Restored ${ids.length}`, { id: tid }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Restore failed'); + toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid }); } }, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` }); } @@ -240,18 +237,16 @@ const ids = snapshotIds(); if (!ids.length) return; heapPickerOpen = false; + const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`); startBulk(`Adding to ${heap.Title}…`, ids); await withBusy(async () => { try { const { added } = await addToHeap(heap.UID, ids); qc.invalidateQueries({ queryKey: ['heaps'] }); - // PhotoPrism returns 200 even when nothing was added (UIDs - // already present or unknown to the index) — surface the - // 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}`, { + id: tid, description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).` }); return; @@ -260,10 +255,11 @@ await delay(400); if (added.length < ids.length) { toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { + id: tid, description: 'The rest were already in this heap.' }); } else { - toast.success(`Added ${added.length} → ${heap.Title}`); + toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid }); } pushUndo(`Added ${added.length} to ${heap.Title}`, async () => { await removeFromHeap(heap.UID, added); @@ -272,7 +268,7 @@ clearSelection(); } catch (err) { failBulk(ids); - toast.error(err instanceof Error ? err.message : 'Add-to-heap failed'); + toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid }); } }); } diff --git a/web/src/lib/services/bulk.ts b/web/src/lib/services/bulk.ts index 97621bc..127b83c 100644 --- a/web/src/lib/services/bulk.ts +++ b/web/src/lib/services/bulk.ts @@ -57,10 +57,11 @@ export async function patchTargets( ) : null; + const tid = toast.loading(`${label} · ${ids.length}…`); + const { updated, errors } = await batchEdit(ids, async (id) => { const p = await freshPhoto(id); const body = typeof patch === 'function' ? patch(p) : patch; - // An empty body is a no-op signal — e.g. "keyword already present". if (Object.keys(body).length === 0) return p; return updatePhoto(p, body); }); @@ -68,9 +69,9 @@ export async function patchTargets( invalidatePhotos(ids); if (errors.length) { - toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`); + toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid }); } else { - toast.success(`${label} · ${ids.length}`); + toast.success(`${label} · ${ids.length}`, { id: tid }); } if (inverses) { diff --git a/web/src/lib/services/photoActions.ts b/web/src/lib/services/photoActions.ts index c8b5eeb..13b02ac 100644 --- a/web/src/lib/services/photoActions.ts +++ b/web/src/lib/services/photoActions.ts @@ -66,21 +66,20 @@ export function cachedPhoto(uid: string): PpPhoto | undefined { */ export async function dismissPhotos(uids: string[]): Promise { if (uids.length === 0) return; + const tid = toast.loading(`Dismissing ${uids.length}…`); const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id)); - // Advance focus past the dismissed set before the timeline refetches - // so the cursor doesn't snap back to photo[0]; clear the now-stale - // selection ring for the same reason. focusAfter(uids); clearSelection(); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); if (errors.length) { toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, { + id: tid, description: errors[0].message }); return; } - toast.success(`Dismissed ${uids.length}`); + toast.success(`Dismissed ${uids.length}`, { id: tid }); } /** @@ -93,6 +92,7 @@ export async function dismissPhotos(uids: string[]): Promise { */ export async function acceptDateAndKeep(uids: string[]): Promise { if (uids.length === 0) return; + const tid = toast.loading(`Updating & keeping ${uids.length}…`); const { updated, errors } = await batchEdit(uids, async (id) => { const p = cachedPhoto(id); if (p) { @@ -113,11 +113,12 @@ export async function acceptDateAndKeep(uids: string[]): Promise { void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); if (errors.length) { toast.error(`Kept ${updated.length}; ${errors.length} failed`, { + id: tid, description: errors[0].message }); return; } - toast.success(`Kept ${uids.length}`); + toast.success(`Kept ${uids.length}`, { id: tid }); } /** @@ -125,10 +126,11 @@ export async function acceptDateAndKeep(uids: string[]): Promise { */ export async function archivePhotos(uids: string[]): Promise { if (uids.length === 0) return; + const tid = toast.loading(`Archiving ${uids.length}…`); try { await batchArchive(uids); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Archive failed'); + toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid }); return; } pushUndo(`Archived ${uids.length}`, async () => { @@ -140,5 +142,5 @@ export async function archivePhotos(uids: string[]): Promise { clearSelection(); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); - toast.success(`Archived ${uids.length}`); + toast.success(`Archived ${uids.length}`, { id: tid }); } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 14af278..35a88d1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -714,6 +714,7 @@ return; } emptyingArchive = true; + const tid = toast.loading("Emptying archive…"); let total = 0; try { while (true) { @@ -729,9 +730,9 @@ await batchDelete(uids); total += uids.length; } - toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`); + toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`, { id: tid }); } catch (err) { - toast.error(err instanceof Error ? err.message : "Empty archive failed"); + toast.error(err instanceof Error ? err.message : "Empty archive failed", { id: tid }); } finally { emptyingArchive = false; void qc.invalidateQueries({ queryKey: ["photos"] });