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:
dtoro
2026-06-07 09:43:12 +02:00
parent 86e38e152d
commit da63ad769a
7 changed files with 177 additions and 56 deletions

View File

@@ -1,49 +1,8 @@
<!--
Compact status pill that appears in the header while PhotoPrism's
indexer is doing work. Driven by the indexer store, which subscribes
to PhotoPrism's WS channel. Renders nothing when idle so it never
steals header real estate from the user.
The `detail` (current path/file) is exposed via `title` rather than
rendered inline — the pill stays narrow even on slow flashes through
a deep library, and hover surfaces the detail for users who care.
-->
<!-- PhotoPrism indexer status pill. Driven by the indexer store, which
subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
<script lang="ts">
import { indexer } from '$lib/stores/indexer.svelte';
import { Loader2 } from 'lucide-svelte';
// PhotoPrism's `fileName` arrives as the full relative path
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
// recognition; the full path stays in the `title` for users who hover.
const basename = $derived.by(() => {
const d = indexer.detail;
if (!d) return '';
const i = d.lastIndexOf('/');
return i >= 0 ? d.slice(i + 1) : d;
});
import StatusPill from './StatusPill.svelte';
</script>
{#if indexer.active || indexer.label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={indexer.detail ?? indexer.label}
role="status"
aria-live="polite"
>
{#if indexer.active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{indexer.label}</span>
{#if basename}
<!-- Fixed-width slot so the pill stops shrinking/growing as
PhotoPrism rattles through files of different name lengths.
`w-[24ch]` locks the column; `truncate` ellipsises anything
longer. The full path remains in the parent's `title`. -->
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}
<StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />

View File

@@ -0,0 +1,46 @@
<!--
Generic status pill used in the header for both the PhotoPrism indexer
and bulk-action progress. Renders nothing when idle so it never steals
header real estate.
`detail` is a full path or filename; only the basename is shown inline
(fixed-width slot to stop the pill from resizing on every file). The
full string is exposed via `title` for hover.
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
active: boolean;
label: string;
detail?: string;
}
let { active, label, detail }: Props = $props();
const basename = $derived.by(() => {
if (!detail) return '';
const i = detail.lastIndexOf('/');
return i >= 0 ? detail.slice(i + 1) : detail;
});
</script>
{#if active || label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={detail ?? label}
role="status"
aria-live="polite"
>
{#if active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{label}</span>
{#if basename}
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}

View File

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

View File

@@ -16,6 +16,9 @@
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
import { fade } from "svelte/transition";
import { Loader2, Check, X } from "lucide-svelte";
interface Props {
photo: PpPhoto;
@@ -51,7 +54,7 @@
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onMouseEnter() {
if (!video || selected) return;
if (!video || selected || bulkState) return;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
hoverPlaying = true;
@@ -73,6 +76,7 @@
const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx));
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
</script>
<!--
@@ -150,6 +154,23 @@
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if bulkState === 'pending'}
<div class="pointer-events-none absolute inset-0 bg-black/50"></div>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<Loader2 class="h-5 w-5 animate-spin text-white/80 drop-shadow" />
</div>
{:else if bulkState === 'done'}
<div
transition:fade={{ duration: 200 }}
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-emerald-500/70"
>
<Check class="h-7 w-7 text-white drop-shadow-md" />
</div>
{:else if bulkState === 'error'}
<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" />
</div>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"

View File

@@ -13,7 +13,7 @@ export interface BatchResult<T> {
export interface BatchOptions {
concurrency?: number;
onProgress?: (done: number, total: number) => void;
onProgress?: (done: number, total: number, completedId: string) => void;
}
export async function batchEdit<T>(
@@ -38,7 +38,7 @@ export async function batchEdit<T>(
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
} finally {
done++;
opts.onProgress?.(done, ids.length);
opts.onProgress?.(done, ids.length, id);
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* Bulk-action status, written by BulkActionBar and read by the header
* StatusPill and individual PhotoTile overlays.
*
* State lifecycle:
* 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
* failBulk → tiles flash red, auto-clears after 2 s
*/
interface BulkActionState {
active: boolean;
label: string;
detail?: string;
}
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
export const bulkPhotoStates = $state(new Map<string, 'pending' | 'done' | 'error'>());
let doneTimer: ReturnType<typeof setTimeout> | null = null;
export function startBulk(label: string, ids: string[]): void {
if (doneTimer !== null) {
clearTimeout(doneTimer);
doneTimer = null;
}
bulkPhotoStates.clear();
for (const id of ids) bulkPhotoStates.set(id, 'pending');
bulkAction.active = true;
bulkAction.label = label;
bulkAction.detail = undefined;
}
export function setDetail(path: string): void {
bulkAction.detail = path;
}
export function doneBulk(label: string, ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'done');
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;
bulkAction.label = '';
bulkAction.detail = undefined;
setTimeout(() => {
for (const id of ids) bulkPhotoStates.delete(id);
}, 2000);
}

View File

@@ -13,7 +13,9 @@
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import { bulkAction } from '$lib/stores/bulkAction.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import StatusPill from '$lib/components/layout/StatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
@@ -72,6 +74,7 @@
<div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule>
<IndexerStatusPill />
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
</AnimatedMule>
<div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed}