feat: loading toasts for all photo actions

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.
This commit is contained in:
2026-06-07 21:40:18 +02:00
parent da63ad769a
commit 5da1022ed1
7 changed files with 88 additions and 80 deletions

View File

@@ -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 });
}
}