Files
mule-image/web/src/lib/components/timeline/BulkActionBar.svelte
dtoro 0d5f380948 preview: full-screen modal replaces inline split + tags route reorg
The old SplitGrid + InlinePreview pane is replaced by a full-screen
PreviewModal mounted once at the layout root. Open via Space on the
focused tile or double-click; close on Esc (or X / Space again).
Inside, PreviewPane renders the focused photo, RightSidebar carries
the metadata, BulkActionBar reuses the existing per-photo actions,
and PreviewCarousel windows ±50 thumbs around the focused index.

Selection contract matches the grid: plain click reduces, shift
extends the range, ⌘/Ctrl toggles, plain arrow drops the multi-
selection, shift-arrow extends. New clearBulkToFirst() helper makes
Esc / Clear collapse a bulk back to single-focus on its first member
before the next press fully dismisses (modal closes, grid clears
focus).

Tags route reorganised into /tags/[category]/[[value]] with its own
+layout and TagsBrowserSidebar; the old monolithic /tags/+page is
trimmed to a legacy redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:13:20 +02:00

341 lines
12 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 { 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 {
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';
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);
// 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');
// 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);
}
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
busy = true;
try {
return await fn();
} finally {
busy = false;
void qc.invalidateQueries({ queryKey: ['photos'] });
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
// 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));
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
}
focusAfter(ids);
clearSelection();
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
try {
await batchArchive(ids);
pushUndo(`Archived ${ids.length}`, async () => {
await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
// Advance focus to the photo immediately after the archived
// set before the multi-selection is dropped — lets the user
// keep stepping through the timeline with X.
focusAfter(ids);
clearSelection();
toast.success(`Archived ${ids.length}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
}
});
}
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;
await withBusy(async () => {
try {
await batchDelete(ids);
focusAfter(ids);
clearSelection();
toast.success(`Deleted ${ids.length}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
}
});
}
async function onRestore() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
try {
await batchRestore(ids);
pushUndo(`Restored ${ids.length}`, async () => {
await batchArchive(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
focusAfter(ids);
clearSelection();
toast.success(`Restored ${ids.length}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Restore failed');
}
});
}
async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds();
if (!ids.length) return;
heapPickerOpen = false;
await withBusy(async () => {
try {
const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] });
// PhotoPrism returns 200 even when nothing was added (UIDs
// already present or unknown to the index) — surface the
// real delta so the user isn't fooled by a green toast over
// a no-op.
if (added.length === 0) {
toast.error(`Nothing added to ${heap.Title}`, {
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
description: 'The rest were already in this heap.'
});
} else {
toast.success(`Added ${added.length}${heap.Title}`);
}
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added);
qc.invalidateQueries({ queryKey: ['heaps'] });
});
clearSelection();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
}
});
}
</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"
>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<!--
`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 border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border 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 border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">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 border border-border px-2 py-0.5 text-[11px] hover:bg-accent 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-muted px-1 text-[9px] font-medium text-muted-foreground"
>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}
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
{: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 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 border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
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}