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:
@@ -27,6 +27,7 @@ import {
|
|||||||
toggle
|
toggle
|
||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.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';
|
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);
|
target = !(first?.Archived ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhotoPrism's photo PUT silently drops the Archived field — the
|
const opLabel = target ? 'Archiving' : 'Restoring';
|
||||||
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||||
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
const tid = toast.loading(`${opLabel} ${ids.length}…`);
|
||||||
// back, so the toast fired but nothing moved.
|
startBulk(`${opLabel}…`, ids);
|
||||||
try {
|
try {
|
||||||
if (target) await batchArchive(ids);
|
if (target) await batchArchive(ids);
|
||||||
else await batchRestore(ids);
|
else await batchRestore(ids);
|
||||||
} catch (err) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
// Move focus forward before the photos query refetches, so the
|
doneBulk(doneLabel, ids);
|
||||||
// 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.
|
|
||||||
focusAfter(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();
|
clearSelection();
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
toast.success(doneLabel, { id: tid });
|
||||||
toast.success(label);
|
pushUndo(doneLabel, async () => {
|
||||||
pushUndo(label, async () => {
|
|
||||||
if (target) await batchRestore(ids);
|
if (target) await batchRestore(ids);
|
||||||
else await batchArchive(ids);
|
else await batchArchive(ids);
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
@@ -232,16 +223,20 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
? 'Permanently delete this photo? This cannot be undone.'
|
? 'Permanently delete this photo? This cannot be undone.'
|
||||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
|
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||||
|
startBulk('Deleting…', ids);
|
||||||
try {
|
try {
|
||||||
await batchDelete(ids);
|
await batchDelete(ids);
|
||||||
} catch (err) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
doneBulk(`Deleted ${ids.length}`, ids);
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
invalidatePhotos(ids);
|
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
|
/** Approve cull targets — clears them out of the review pile by
|
||||||
@@ -257,21 +252,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||||
// Approve moves photos out of the review pile, so the same
|
startBulk('Keeping…', ids);
|
||||||
// stale-selection trap as archive/delete applies — advance focus
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||||
// past the approved set and drop the now-irrelevant selection
|
onProgress: (_done, _total, completedId) => {
|
||||||
// before invalidate refetches the (smaller) view.
|
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);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
invalidatePhotos(ids);
|
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) ────────────────────────────────────────────
|
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||||
@@ -297,25 +298,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||||
|
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||||
try {
|
try {
|
||||||
const { added } = await addToHeap(heap.UID, ids);
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
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) {
|
if (added.length === 0) {
|
||||||
|
failBulk(ids);
|
||||||
toast.error(`Nothing added to ${heap.Title}`, {
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
|
id: tid,
|
||||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||||
if (added.length < ids.length) {
|
if (added.length < ids.length) {
|
||||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
|
id: tid,
|
||||||
description: 'The rest were already in this heap.'
|
description: 'The rest were already in this heap.'
|
||||||
});
|
});
|
||||||
} else {
|
} 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 () => {
|
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||||
await removeFromHeap(heap.UID, added);
|
await removeFromHeap(heap.UID, added);
|
||||||
@@ -323,7 +327,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { batchArchive } from '$lib/services/photoprism';
|
import { batchArchive } from '$lib/services/photoprism';
|
||||||
|
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import { type ReviewGroup } from '$lib/services/adapters/review';
|
import { type ReviewGroup } from '$lib/services/adapters/review';
|
||||||
|
|
||||||
@@ -28,14 +29,19 @@
|
|||||||
if (busy || group.photos.length === 0) return;
|
if (busy || group.photos.length === 0) return;
|
||||||
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||||
return;
|
return;
|
||||||
|
const uids = group.photos.map((p) => p.UID);
|
||||||
|
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||||
busy = true;
|
busy = true;
|
||||||
|
startBulk(`Archiving…`, uids);
|
||||||
try {
|
try {
|
||||||
await batchArchive(group.photos.map((p) => p.UID));
|
await batchArchive(uids);
|
||||||
toast.success(`Archived ${group.photos.length}`);
|
doneBulk(`Archived ${uids.length}`, uids);
|
||||||
|
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,8 @@
|
|||||||
|
|
||||||
async function applyMarks(patch: PhotoMark, label: string) {
|
async function applyMarks(patch: PhotoMark, label: string) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const tid = toast.loading(`${label}…`);
|
||||||
await withBusy(async () => {
|
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) => {
|
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
|
||||||
const map = { ...(prev ?? {}) };
|
const map = { ...(prev ?? {}) };
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@@ -98,9 +95,9 @@
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await bulkSetMarks(ids, patch);
|
await bulkSetMarks(ids, patch);
|
||||||
toast.success(`${label} · ${ids.length}`);
|
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||||
} catch (err) {
|
} 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'] });
|
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,11 +144,8 @@
|
|||||||
async function onApprove() {
|
async function onApprove() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||||
await withBusy(async () => {
|
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), {
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||||
onProgress: (_done, _total, completedId) => {
|
onProgress: (_done, _total, completedId) => {
|
||||||
const p = cachedPhoto(completedId);
|
const p = cachedPhoto(completedId);
|
||||||
@@ -156,9 +153,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Kept ${ids.length}`);
|
toast.success(`Kept ${ids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -178,6 +175,7 @@
|
|||||||
async function onArchive() {
|
async function onArchive() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Archiving ${ids.length}…`);
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchArchive(ids);
|
await batchArchive(ids);
|
||||||
@@ -185,14 +183,11 @@
|
|||||||
await batchRestore(ids);
|
await batchRestore(ids);
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
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);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Archived ${ids.length}`);
|
toast.success(`Archived ${ids.length}`, { id: tid });
|
||||||
} catch (err) {
|
} 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}` });
|
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||||
}
|
}
|
||||||
@@ -205,14 +200,15 @@
|
|||||||
? 'Permanently delete this photo? This cannot be undone.'
|
? 'Permanently delete this photo? This cannot be undone.'
|
||||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
|
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchDelete(ids);
|
await batchDelete(ids);
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Deleted ${ids.length}`);
|
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||||
} catch (err) {
|
} 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}` });
|
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||||
}
|
}
|
||||||
@@ -220,6 +216,7 @@
|
|||||||
async function onRestore() {
|
async function onRestore() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Restoring ${ids.length}…`);
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchRestore(ids);
|
await batchRestore(ids);
|
||||||
@@ -229,9 +226,9 @@
|
|||||||
});
|
});
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Restored ${ids.length}`);
|
toast.success(`Restored ${ids.length}`, { id: tid });
|
||||||
} catch (err) {
|
} 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}` });
|
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||||||
}
|
}
|
||||||
@@ -240,18 +237,16 @@
|
|||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
heapPickerOpen = false;
|
heapPickerOpen = false;
|
||||||
|
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
const { added } = await addToHeap(heap.UID, ids);
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
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) {
|
if (added.length === 0) {
|
||||||
failBulk(ids);
|
failBulk(ids);
|
||||||
toast.error(`Nothing added to ${heap.Title}`, {
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
|
id: tid,
|
||||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -260,10 +255,11 @@
|
|||||||
await delay(400);
|
await delay(400);
|
||||||
if (added.length < ids.length) {
|
if (added.length < ids.length) {
|
||||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
|
id: tid,
|
||||||
description: 'The rest were already in this heap.'
|
description: 'The rest were already in this heap.'
|
||||||
});
|
});
|
||||||
} else {
|
} 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 () => {
|
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||||
await removeFromHeap(heap.UID, added);
|
await removeFromHeap(heap.UID, added);
|
||||||
@@ -272,7 +268,7 @@
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failBulk(ids);
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,11 @@ export async function patchTargets(
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const tid = toast.loading(`${label} · ${ids.length}…`);
|
||||||
|
|
||||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||||
const p = await freshPhoto(id);
|
const p = await freshPhoto(id);
|
||||||
const body = typeof patch === 'function' ? patch(p) : patch;
|
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;
|
if (Object.keys(body).length === 0) return p;
|
||||||
return updatePhoto(p, body);
|
return updatePhoto(p, body);
|
||||||
});
|
});
|
||||||
@@ -68,9 +69,9 @@ export async function patchTargets(
|
|||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
|
||||||
} else {
|
} else {
|
||||||
toast.success(`${label} · ${ids.length}`);
|
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inverses) {
|
if (inverses) {
|
||||||
|
|||||||
@@ -66,21 +66,20 @@ export function cachedPhoto(uid: string): PpPhoto | undefined {
|
|||||||
*/
|
*/
|
||||||
export async function dismissPhotos(uids: string[]): Promise<void> {
|
export async function dismissPhotos(uids: string[]): Promise<void> {
|
||||||
if (uids.length === 0) return;
|
if (uids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Dismissing ${uids.length}…`);
|
||||||
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id));
|
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);
|
focusAfter(uids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
invalidatePhotos(uids);
|
invalidatePhotos(uids);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
|
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
|
||||||
|
id: tid,
|
||||||
description: errors[0].message
|
description: errors[0].message
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Dismissed ${uids.length}`);
|
toast.success(`Dismissed ${uids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +92,7 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||||
if (uids.length === 0) return;
|
if (uids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Updating & keeping ${uids.length}…`);
|
||||||
const { updated, errors } = await batchEdit(uids, async (id) => {
|
const { updated, errors } = await batchEdit(uids, async (id) => {
|
||||||
const p = cachedPhoto(id);
|
const p = cachedPhoto(id);
|
||||||
if (p) {
|
if (p) {
|
||||||
@@ -113,11 +113,12 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||||
|
id: tid,
|
||||||
description: errors[0].message
|
description: errors[0].message
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Kept ${uids.length}`);
|
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,10 +126,11 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function archivePhotos(uids: string[]): Promise<void> {
|
export async function archivePhotos(uids: string[]): Promise<void> {
|
||||||
if (uids.length === 0) return;
|
if (uids.length === 0) return;
|
||||||
|
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||||
try {
|
try {
|
||||||
await batchArchive(uids);
|
await batchArchive(uids);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushUndo(`Archived ${uids.length}`, async () => {
|
pushUndo(`Archived ${uids.length}`, async () => {
|
||||||
@@ -140,5 +142,5 @@ export async function archivePhotos(uids: string[]): Promise<void> {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
invalidatePhotos(uids);
|
invalidatePhotos(uids);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
toast.success(`Archived ${uids.length}`);
|
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -714,6 +714,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emptyingArchive = true;
|
emptyingArchive = true;
|
||||||
|
const tid = toast.loading("Emptying archive…");
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -729,9 +730,9 @@
|
|||||||
await batchDelete(uids);
|
await batchDelete(uids);
|
||||||
total += uids.length;
|
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) {
|
} 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 {
|
} finally {
|
||||||
emptyingArchive = false;
|
emptyingArchive = false;
|
||||||
void qc.invalidateQueries({ queryKey: ["photos"] });
|
void qc.invalidateQueries({ queryKey: ["photos"] });
|
||||||
|
|||||||
Reference in New Issue
Block a user