Files
mule-image/web/src/lib/components/timeline/BulkActionBar.svelte
dtoro ccf2c6b7c7 fix: bulk label apply, instant archive removal, per-photo state, drop sidebar counts
Issue 1 — colors/labels not applying in bulk:
- sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/
  purple/pink returned "invalid color" and rolled back the whole bulk txn.
  Add teal, blue, purple, pink to validColors.
- Add invalidateFacets() and call it on the success path of bulk marks,
  patchTargets, and single-photo edits so the Colors/Ratings/Notes facet
  sections refresh immediately instead of waiting out staleTime.

Issue 2 — archived photos linger in the grid:
- Add a UI-only removedIds set to the bulkAction store; archive/delete/
  restore/keep call markRemoved() on success so tiles vanish instantly,
  cleared once the server-reconcile refetch lands (no cache eviction).

Issue 3 — per-photo progress state:
- Wire startBulk/doneBulk/failBulk into all metadata applies, bulk
  (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/
  notes/dates/keywords show the spinner -> check -> X overlay.

Issue 4 — remove Left-sidebar count badges:
- Drop count badges from root folder, Archive, heaps, Notes, and the
  folder tree, plus the now-dead count queries and unused imports. Facet
  drill-panel counts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:21:04 +02:00

461 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
listHeaps,
removeFromHeap,
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import {
clearBulkToFirst,
clearSelection,
focusAfter,
selection,
setFocused
} from '$lib/stores/selection.svelte';
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,
markRemoved,
clearRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
const qc = useQueryClient();
let busy = $state(false);
let heapPickerOpen = $state(false);
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated()
}));
/**
* Targets of an action: the multi-selected set when one exists, else the
* focused tile alone. Mule-image's design treats focus as "implicit single
* selection" so the bar's actions always have something to operate on.
*/
function snapshotIds(): string[] {
if (selection.ids.size > 0) return Array.from(selection.ids);
if (selection.focused) return [selection.focused];
return [];
}
const targetCount = $derived(
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Filename for the single-focus label. Re-derives whenever
// selection.focused flips — cachedPhoto reads from the same query
// cache that drives the visible tiles, so the name resolves on the
// same tick the tile renders.
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
const focusedName = $derived(
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
// that's where path-derived dates are the most useful fix. Outside the
// tab the button stays hidden even if a selected photo would otherwise
// have a path-parseable date, to keep other tabs uncluttered.
const onExifStrippedTab = $derived(
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
);
// Surface the button only when EVERY targeted photo has a derivable
// suggestion — otherwise clicking it would silently approve some
// photos without a date fix, which contradicts the verb. A uid not in
// any cache also counts as "no suggestion" so we don't promise
// something we can't verify.
const allHaveSuggestion = $derived.by(() => {
if (!onExifStrippedTab) return false;
const ids =
selection.ids.size > 0
? Array.from(selection.ids)
: selection.focused
? [selection.focused]
: [];
if (ids.length === 0) return false;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return false;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return false;
}
}
return true;
});
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
// gridKeyNav mirrors the rerouting.
const isArchive = $derived(filters.section === 'archive');
function clearAll() {
// Mirror gridKeyNav's Esc: a multi-selection collapses back to its
// first member (the user keeps a single-focus reference) before
// the next Clear/Esc fully dismisses focus.
if (clearBulkToFirst()) return;
clearSelection();
setFocused(null);
}
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 {
const result = await fn();
if (bulk) {
doneBulk(bulk.doneLabel, bulk.ids);
await delay(1000);
}
return result;
} catch (e) {
if (bulk) failBulk(bulk.ids);
throw e;
} finally {
busy = false;
const settled = Promise.all([
qc.invalidateQueries({ queryKey: ['photos'] }),
qc.invalidateQueries({ queryKey: ['marks'] }),
qc.invalidateQueries({ queryKey: ['review-groups'] })
]);
// Clear the optimistic-removal overlay only once the refetch has
// landed, so tiles never flash back in before the fresh (archived-
// filtered) page replaces the old one.
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Keeping ${ids.length}…`);
await withBusy(async () => {
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`, { id: tid });
} else {
toast.success(`Kept ${ids.length}`, { id: tid });
}
// Approved photos leave the review section — hide them immediately.
markRemoved(ids);
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), {
ids,
label: 'Updating',
doneLabel: `Updated ${ids.length}`
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Archiving ${ids.length}…`);
await withBusy(async () => {
try {
await batchArchive(ids);
markRemoved(ids);
pushUndo(`Archived ${ids.length}`, async () => {
await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
focusAfter(ids);
clearSelection();
toast.success(`Archived ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
}
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
}
async function onDelete() {
const ids = snapshotIds();
if (ids.length === 0) return;
const msg =
ids.length === 1
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}…`);
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}` });
}
async function onRestore() {
const ids = snapshotIds();
if (ids.length === 0) return;
const tid = toast.loading(`Restoring ${ids.length}…`);
await withBusy(async () => {
try {
await batchRestore(ids);
markRemoved(ids);
pushUndo(`Restored ${ids.length}`, async () => {
await batchArchive(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
focusAfter(ids);
clearSelection();
toast.success(`Restored ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid });
}
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
}
async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds();
if (!ids.length) return;
heapPickerOpen = false;
const tid = toast.loading(`Adding ${ids.length}${heap.Title}…`);
startBulk(`Adding to ${heap.Title}…`, ids);
await withBusy(async () => {
try {
const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] });
if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, {
id: tid,
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}`, {
id: tid,
description: 'The rest were already in this heap.'
});
} else {
toast.success(`Added ${added.length}${heap.Title}`, { id: tid });
}
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added);
qc.invalidateQueries({ queryKey: ['heaps'] });
});
clearSelection();
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
}
});
}
</script>
{#if targetCount > 0}
<!--
Inline row at the bottom of the main content column (NOT fixed) so
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
bg-background/80 backdrop-blur visual so it reads as the timeline's
own footer.
-->
<div
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
{#if isBulk}
<span class="shrink-0 text-[11px] font-medium text-foreground">
{targetCount} selected
</span>
{:else}
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
<span
class="min-w-0 truncate text-[11px] font-medium text-foreground"
title={focusedName || undefined}
>
{focusedName || 'photo'}
</span>
{/if}
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
so the dropdown's `bottom-full` placement is clipped to zero
pixels above the 36px bar (it renders but is invisible). We
use `flex-wrap` instead so very narrow viewports get a second
row rather than a horizontal scroll, and the dropdown stays
free to escape upward.
-->
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
{#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, restore)
is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
</button>
{#if allHaveSuggestion}
<!-- Visible only when every selected photo has a path-
derivable date. Clicking applies each photo's
suggestion then approves it; mirrored by the bare
`a` shortcut in gridKeyNav. -->
<button
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
disabled={busy}
onclick={onAcceptDateAndKeep}
title="Accept the date suggested from the file/folder path, then keep"
>
📅 Accept date & Keep
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else if isArchive}
<!-- Archive section = mirror of review: Keep restores back
to the timeline; Delete is permanent and can't be
undone. X is repurposed from archive→delete since the
photo is already archived; the destructive styling
reinforces the irreversibility. -->
<button
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else}
<div class="relative">
<button
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
>S&nbsp;N</kbd
>
</button>
{#if heapPickerOpen}
<div
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
>
{#if heapsQuery.isPending}
<InlineLoader size="sm" label="Loading heaps…" />
{:else if (heapsQuery.data ?? []).length === 0}
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-accent"
onclick={() => onAddToHeap(heap)}
>
{#if i < 9}
<kbd
class="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
title={`S ${i + 1}`}
>
{i + 1}
</kbd>
{:else}
<span class="w-3 shrink-0"></span>
{/if}
<span class="flex-1 truncate">{heap.Title}</span>
<span class="shrink-0 text-muted-foreground">
({heap.PhotoCount ?? 0})
</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<button
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
</button>
</div>
</div>
{/if}