diff --git a/web/src/lib/components/layout/IndexerStatusPill.svelte b/web/src/lib/components/layout/IndexerStatusPill.svelte
index 865e435..c262934 100644
--- a/web/src/lib/components/layout/IndexerStatusPill.svelte
+++ b/web/src/lib/components/layout/IndexerStatusPill.svelte
@@ -1,49 +1,8 @@
-
+
-{#if indexer.active || indexer.label}
-
- {#if indexer.active}
-
- {/if}
- {indexer.label}
- {#if basename}
-
-
- {basename}
-
- {/if}
-
-{/if}
+
diff --git a/web/src/lib/components/layout/StatusPill.svelte b/web/src/lib/components/layout/StatusPill.svelte
new file mode 100644
index 0000000..7b5f297
--- /dev/null
+++ b/web/src/lib/components/layout/StatusPill.svelte
@@ -0,0 +1,46 @@
+
+
+
+{#if active || label}
+
+ {#if active}
+
+ {/if}
+ {label}
+ {#if basename}
+
+ {basename}
+
+ {/if}
+
+{/if}
diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte
index 02809c5..8a07125 100644
--- a/web/src/lib/components/timeline/BulkActionBar.svelte
+++ b/web/src/lib/components/timeline/BulkActionBar.svelte
@@ -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(fn: () => Promise): Promise {
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+ interface BulkConfig {
+ ids: string[];
+ label: string;
+ doneLabel: string;
+ }
+
+ async function withBusy(fn: () => Promise, bulk?: BulkConfig): Promise {
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');
}
});
diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte
index 9ea5b2c..a76305f 100644
--- a/web/src/lib/components/timeline/PhotoTile.svelte
+++ b/web/src/lib/components/timeline/PhotoTile.svelte
@@ -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 | 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));