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>
This commit is contained in:
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
Full-screen preview modal. Mounted once at the layout root so every
|
||||
route (timeline, /tags, future grids) can open it via Space or a
|
||||
double-click on a tile. Reads from the global selection store:
|
||||
|
||||
- selection.focused → which photo to display
|
||||
- selection.order → walked left/right + drives PreviewCarousel
|
||||
|
||||
Layout: preview pane (left, fills) + RightSidebar (right) + action
|
||||
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
|
||||
Esc) is owned here; gridKeyNav bails out of those keys while the modal
|
||||
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
|
||||
to flow through gridKeyNav since they target selection state, which
|
||||
the modal shares.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { closePreview, view } from '$lib/stores/view.svelte';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import PreviewPane from './PreviewPane.svelte';
|
||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
// Fetch the focused photo so the RightSidebar always has full
|
||||
// metadata even when the user jumped here from a list that only
|
||||
// hydrated thumbnails.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', focusedUid ?? ''],
|
||||
queryFn: () => getPhoto(focusedUid as string),
|
||||
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
|
||||
}));
|
||||
|
||||
function step(delta: number, extending: boolean) {
|
||||
const order = selection.order;
|
||||
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
|
||||
if (cur < 0) return;
|
||||
const next = order[cur + delta];
|
||||
if (!next) return;
|
||||
// Plain arrow drops any prior multi-selection down to the cursor —
|
||||
// otherwise the previously selected tiles keep their blue ring and
|
||||
// the carousel reads as two simultaneously selected images (the old
|
||||
// ones still in ids, plus the freshly focused one). Mirrors
|
||||
// gridKeyNav.moveFocus's contract.
|
||||
if (!extending) clearSelection();
|
||||
setFocused(next);
|
||||
if (extending) selectRange(next);
|
||||
else setAnchor(next);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Don't steal keys from inputs inside the sidebar (editable
|
||||
// metadata fields, keyword chips, etc.).
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
// stopImmediatePropagation prevents gridKeyNav's window-level
|
||||
// handler from firing afterwards — without it, closing on Space
|
||||
// would set previewOpen=false and then gridKeyNav's Space branch
|
||||
// would re-open the modal because selection.focused is still set.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
// First Esc collapses a bulk back to single-focus on its first
|
||||
// member, keeping the modal open so the user can keep working.
|
||||
// Second Esc (no bulk left) closes the modal.
|
||||
if (clearBulkToFirst()) return;
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(-1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(1, e.shiftKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind a window-level handler only while the modal is open. The
|
||||
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
|
||||
// keys we own (arrows, Esc, Space) without having to coordinate
|
||||
// listener order.
|
||||
$effect(() => {
|
||||
if (!view.previewOpen) return;
|
||||
const handler = (e: KeyboardEvent) => onKeydown(e);
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={view.previewOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closePreview();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
>
|
||||
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
|
||||
onclick={closePreview}
|
||||
aria-label="Close preview"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
</div>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user