feat(archive): red-cross flash + instant tile removal on archive/delete
Archive/delete now flash a red cross then drop tiles from the grid immediately, instead of a green check that lingered until the slow server-reconcile refetch landed. Keyboard `x` archive previously never called markRemoved, so tiles only vanished on refetch — that lag is gone. - Add 'removed' bulk state + removedBulk() helper (red cross overlay) - gridKeyNav archive/delete: removedBulk -> 500ms flash -> markRemoved, clearRemoved once refetch settles; restore stays green check - BulkActionBar: BulkConfig.removing routes archive/delete through the red flash; approve/restore/label/note unchanged Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,15 @@ 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 {
|
||||||
|
startBulk,
|
||||||
|
doneBulk,
|
||||||
|
removedBulk,
|
||||||
|
failBulk,
|
||||||
|
setDetail,
|
||||||
|
markRemoved,
|
||||||
|
clearRemoved
|
||||||
|
} 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,6 +171,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||||
const ids = cullTargets();
|
const ids = cullTargets();
|
||||||
if (ids.length === 0) {
|
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 });
|
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
doneBulk(doneLabel, ids);
|
if (target) {
|
||||||
focusAfter(ids);
|
// Destructive removal: flash a red cross, then pull the tiles out of
|
||||||
clearSelection();
|
// the grid immediately (markRemoved) rather than waiting on the slow
|
||||||
invalidatePhotos(ids);
|
// server-reconcile refetch. clearRemoved once the refetch settles so
|
||||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
// 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 });
|
toast.success(doneLabel, { id: tid });
|
||||||
pushUndo(doneLabel, async () => {
|
pushUndo(doneLabel, async () => {
|
||||||
if (target) await batchRestore(ids);
|
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 });
|
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
doneBulk(`Deleted ${ids.length}`, ids);
|
// Destructive removal — same red-cross flash then immediate hide as archive.
|
||||||
|
removedBulk(`Deleted ${ids.length}`, ids);
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
await delay(500);
|
||||||
|
markRemoved(ids);
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
|
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
|
void settled.then(() => clearRemoved(ids));
|
||||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
startBulk,
|
startBulk,
|
||||||
setDetail,
|
setDetail,
|
||||||
doneBulk,
|
doneBulk,
|
||||||
|
removedBulk,
|
||||||
failBulk,
|
failBulk,
|
||||||
markRemoved,
|
markRemoved,
|
||||||
clearRemoved
|
clearRemoved
|
||||||
@@ -127,6 +128,9 @@
|
|||||||
ids: string[];
|
ids: string[];
|
||||||
label: string;
|
label: string;
|
||||||
doneLabel: 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<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||||
@@ -135,8 +139,15 @@
|
|||||||
try {
|
try {
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
if (bulk) {
|
if (bulk) {
|
||||||
doneBulk(bulk.doneLabel, bulk.ids);
|
if (bulk.removing) {
|
||||||
await delay(1000);
|
// 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;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -196,7 +207,6 @@
|
|||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchArchive(ids);
|
await batchArchive(ids);
|
||||||
markRemoved(ids);
|
|
||||||
pushUndo(`Archived ${ids.length}`, async () => {
|
pushUndo(`Archived ${ids.length}`, async () => {
|
||||||
await batchRestore(ids);
|
await batchRestore(ids);
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
@@ -207,7 +217,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
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() {
|
async function onDelete() {
|
||||||
@@ -222,14 +232,13 @@
|
|||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchDelete(ids);
|
await batchDelete(ids);
|
||||||
markRemoved(ids);
|
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
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() {
|
async function onRestore() {
|
||||||
|
|||||||
@@ -166,6 +166,13 @@
|
|||||||
>
|
>
|
||||||
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if bulkState === 'removed'}
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/70"
|
||||||
|
>
|
||||||
|
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
{:else if bulkState === 'error'}
|
{:else if bulkState === 'error'}
|
||||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
||||||
<X class="h-7 w-7 text-white drop-shadow-md" />
|
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* startBulk → pill spins, all target tiles go "pending"
|
* startBulk → pill spins, all target tiles go "pending"
|
||||||
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
||||||
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
|
* 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
|
* failBulk → tiles flash red, auto-clears after 2 s
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
|||||||
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
|
// 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
|
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
|
||||||
// re-rendering the timeline tiles' overlay.
|
// re-rendering the timeline tiles' overlay.
|
||||||
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
|
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error' | 'removed'>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UIDs hidden from the timeline grid the instant a removing action (archive /
|
* 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);
|
}, 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 {
|
export function failBulk(ids: string[]): void {
|
||||||
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
||||||
bulkAction.active = false;
|
bulkAction.active = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user