Files
mule-image/web/src/lib/components/timeline/BulkActionBar.svelte
dtoro 6d9b236ef6 feat(library): reindex button, live grid, archive-reappear fix, bigger carets
- Add one-click "reindex new files" button to the Library sidebar header
  (RefreshCw, calls startIndex rescan:false), spins + disables while active.
- Refresh the photos grid from the indexer WS stream (throttled during the
  scan + once on completion) so newly indexed files appear live.
- Fix archived photos flashing back into the grid when archiving others:
  drop the per-action settle-driven clearRemoved and reconcile removedIds
  against the actual cache instead (clears an id only once it's gone from
  the deduped pages). Covers archive, delete, and bulk-bar removals.
- Replace the tiny Unicode caret triangles with a 16px Lucide ChevronRight
  that rotates 90deg on expand, across folder tree rows, root, Tags, Review.

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

478 lines
17 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 { openMove } from '$lib/stores/moveDialog.svelte';
import {
startBulk,
setDetail,
doneBulk,
removedBulk,
failBulk,
markRemoved
} 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;
/** 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> {
busy = true;
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
try {
const result = await fn();
if (bulk) {
if (bulk.removing) {
// 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;
} catch (e) {
if (bulk) failBulk(bulk.ids);
throw e;
} finally {
busy = false;
void qc.invalidateQueries({ queryKey: ['photos'] });
void qc.invalidateQueries({ queryKey: ['marks'] });
void qc.invalidateQueries({ queryKey: ['review-groups'] });
// The optimistic-removal overlay (removedIds) is reconciled against
// the cache in +page.svelte — each id drops once the fresh, archived-
// filtered page has actually replaced it. Clearing here off this
// action's own settle raced other in-flight removals and flashed
// tiles back in.
}
}
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);
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}`, removing: true });
}
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);
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}`, removing: true });
}
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>
<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={() => openMove({ kind: 'photos', uids: snapshotIds() })}
title="Move selected photos to a folder"
>
Move to folder
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">M</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}