diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 8d70ad7..55eb4ed 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -27,7 +27,15 @@ 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 { + startBulk, + doneBulk, + removedBulk, + failBulk, + setDetail, + markRemoved, + clearRemoved +} from '$lib/stores/bulkAction.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; /** @@ -163,6 +171,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { return []; } + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') { const ids = cullTargets(); if (ids.length === 0) { @@ -193,11 +203,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid }); return; } - doneBulk(doneLabel, ids); - focusAfter(ids); - clearSelection(); - invalidatePhotos(ids); - void queryClient.invalidateQueries({ queryKey: ['marks'] }); + if (target) { + // Destructive removal: flash a red cross, then pull the tiles out of + // the grid immediately (markRemoved) rather than waiting on the slow + // server-reconcile refetch. clearRemoved once the refetch settles so + // the archived-filtered page replaces the optimistic hide. + removedBulk(doneLabel, ids); + focusAfter(ids); + clearSelection(); + await delay(500); + markRemoved(ids); + invalidatePhotos(ids); + const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); + void queryClient.invalidateQueries({ queryKey: ['marks'] }); + void settled.then(() => clearRemoved(ids)); + } else { + doneBulk(doneLabel, ids); + focusAfter(ids); + clearSelection(); + invalidatePhotos(ids); + void queryClient.invalidateQueries({ queryKey: ['marks'] }); + } toast.success(doneLabel, { id: tid }); pushUndo(doneLabel, async () => { if (target) await batchRestore(ids); @@ -233,11 +259,16 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid }); return; } - doneBulk(`Deleted ${ids.length}`, ids); + // Destructive removal — same red-cross flash then immediate hide as archive. + removedBulk(`Deleted ${ids.length}`, ids); focusAfter(ids); clearSelection(); + await delay(500); + markRemoved(ids); invalidatePhotos(ids); + const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] }); + void settled.then(() => clearRemoved(ids)); toast.success(`Deleted ${ids.length}`, { id: tid }); } diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index cfee8c4..1df27b6 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -30,6 +30,7 @@ startBulk, setDetail, doneBulk, + removedBulk, failBulk, markRemoved, clearRemoved @@ -127,6 +128,9 @@ ids: string[]; label: string; doneLabel: string; + /** Destructive removal (archive / delete): flash a red cross, then hide + * the tiles via markRemoved after the flash instead of green check. */ + removing?: boolean; } async function withBusy(fn: () => Promise, bulk?: BulkConfig): Promise { @@ -135,8 +139,15 @@ try { const result = await fn(); if (bulk) { - doneBulk(bulk.doneLabel, bulk.ids); - await delay(1000); + if (bulk.removing) { + // Destructive: red-cross flash, then pull tiles from the grid. + removedBulk(bulk.doneLabel, bulk.ids); + await delay(500); + markRemoved(bulk.ids); + } else { + doneBulk(bulk.doneLabel, bulk.ids); + await delay(1000); + } } return result; } catch (e) { @@ -196,7 +207,6 @@ await withBusy(async () => { try { await batchArchive(ids); - markRemoved(ids); pushUndo(`Archived ${ids.length}`, async () => { await batchRestore(ids); void qc.invalidateQueries({ queryKey: ['photos'] }); @@ -207,7 +217,7 @@ } catch (err) { 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}`, removing: true }); } async function onDelete() { @@ -222,14 +232,13 @@ await withBusy(async () => { try { await batchDelete(ids); - markRemoved(ids); focusAfter(ids); clearSelection(); toast.success(`Deleted ${ids.length}`, { id: tid }); } catch (err) { 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}`, removing: true }); } async function onRestore() { diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte index a76305f..fd36df1 100644 --- a/web/src/lib/components/timeline/PhotoTile.svelte +++ b/web/src/lib/components/timeline/PhotoTile.svelte @@ -166,6 +166,13 @@ > + {:else if bulkState === 'removed'} +
+ +
{:else if bulkState === 'error'}
diff --git a/web/src/lib/stores/bulkAction.svelte.ts b/web/src/lib/stores/bulkAction.svelte.ts index 90e9ad1..fafcef4 100644 --- a/web/src/lib/stores/bulkAction.svelte.ts +++ b/web/src/lib/stores/bulkAction.svelte.ts @@ -6,6 +6,8 @@ * startBulk → pill spins, all target tiles go "pending" * setDetail → pill shows the filename currently being processed (fan-out ops) * doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s + * removedBulk→ destructive completion (archive / delete): tiles flash a red cross, + * then the caller hides them via markRemoved; map auto-clears after 3 s * failBulk → tiles flash red, auto-clears after 2 s */ @@ -21,7 +23,7 @@ export const bulkAction = $state({ active: false, label: '' }); // SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile // reliably re-runs when the entry flips — the plain-Map proxy form wasn't // re-rendering the timeline tiles' overlay. -export const bulkPhotoStates = new SvelteMap(); +export const bulkPhotoStates = new SvelteMap(); /** * UIDs hidden from the timeline grid the instant a removing action (archive / @@ -71,6 +73,24 @@ export function doneBulk(label: string, ids: string[]): void { }, 3000); } +/** + * Destructive completion (archive / permanent delete): flash a red cross on the + * target tiles instead of the green check. The caller hides the tiles via + * markRemoved shortly after the flash; this timer only cleans up the state map. + */ +export function removedBulk(label: string, ids: string[]): void { + for (const id of ids) bulkPhotoStates.set(id, 'removed'); + bulkAction.active = false; + bulkAction.label = label; + bulkAction.detail = undefined; + if (doneTimer !== null) clearTimeout(doneTimer); + doneTimer = setTimeout(() => { + bulkAction.label = ''; + bulkPhotoStates.clear(); + doneTimer = null; + }, 3000); +} + export function failBulk(ids: string[]): void { for (const id of ids) bulkPhotoStates.set(id, 'error'); bulkAction.active = false;