feat: bulk action progress — header pill + per-thumbnail states
- New bulkAction store: tracks active/label/detail state for the pill and a Map<uid, pending|done|error> for per-tile overlays - Extract StatusPill.svelte from IndexerStatusPill (generic active/label/detail props); IndexerStatusPill becomes a one-line wrapper - +layout.svelte: render a second StatusPill driven by bulkAction store, alongside the indexer pill in the AnimatedMule header - BulkActionBar: extend withBusy with optional BulkConfig (ids/label/doneLabel); pending tiles dim + spinner on start, green checkmark flashes for 400ms before cache invalidation removes them; red overlay on error, auto-clears after 2s - onApprove/batchEdit: wire onProgress callback to setDetail so the pill shows the filename currently being processed during fan-out keep operations - batch.ts: add completedId as third arg to onProgress (backwards-compatible) - PhotoTile: derive bulkState from store; pending/done/error overlays sit above the selection tint; hover-video guarded against pending tiles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { startBulk, setDetail, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
@@ -113,10 +114,27 @@
|
||||
setFocused(null);
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
interface BulkConfig {
|
||||
ids: string[];
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||
busy = true;
|
||||
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
if (bulk) {
|
||||
doneBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(400);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (bulk) failBulk(bulk.ids);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
@@ -131,7 +149,12 @@
|
||||
// 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) => {
|
||||
const p = cachedPhoto(completedId);
|
||||
setDetail(p?.FileName ?? completedId);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||
} else {
|
||||
@@ -139,13 +162,17 @@
|
||||
}
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
});
|
||||
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAcceptDateAndKeep() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(() => acceptDateAndKeep(ids));
|
||||
await withBusy(() => acceptDateAndKeep(ids), {
|
||||
ids,
|
||||
label: 'Updating',
|
||||
doneLabel: `Updated ${ids.length}`
|
||||
});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
@@ -167,7 +194,7 @@
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
@@ -187,7 +214,7 @@
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
@@ -206,13 +233,14 @@
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAddToHeap(heap: PpAlbum) {
|
||||
const ids = snapshotIds();
|
||||
if (!ids.length) return;
|
||||
heapPickerOpen = false;
|
||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
const { added } = await addToHeap(heap.UID, ids);
|
||||
@@ -222,11 +250,14 @@
|
||||
// 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}`, {
|
||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||
await delay(400);
|
||||
if (added.length < ids.length) {
|
||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||
description: 'The rest were already in this heap.'
|
||||
@@ -240,6 +271,7 @@
|
||||
});
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user