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>
341 lines
12 KiB
Svelte
341 lines
12 KiB
Svelte
<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 1–9 picks a heap)"
|
||
>
|
||
+ Add to heap
|
||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
||
>S 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}
|