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

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

View File

@@ -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<PhotoMarksMap>(['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'] });
}
});

View File

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