Previously the modal's aside was gated on `focusedPhotoQuery.data`, so each arrow-skim unmounted the sidebar until the next photo's metadata arrived — which reflowed the preview pane sideways. Now the aside is always mounted while the modal is open; its contents swap between the metadata panel and a small InlineLoader the same way the timeline's right-aside does. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.7 KiB
Svelte
179 lines
6.7 KiB
Svelte
<!--
|
|
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';
|
|
import { InlineLoader } from '$lib/components/feedback';
|
|
|
|
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).
|
|
BulkActionBar lives inside the main column — same shape as the
|
|
timeline (+page.svelte) so the bar stays bounded by the
|
|
column's width and doesn't stretch under the metadata
|
|
sidebar. -->
|
|
<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>
|
|
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
|
<BulkActionBar />
|
|
</div>
|
|
<!-- Sidebar stays mounted across photo changes so the preview
|
|
pane doesn't reflow on arrow-skim; contents swap between
|
|
the metadata panel and a small loader the same way the
|
|
timeline's right-aside does. -->
|
|
<aside
|
|
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
|
>
|
|
{#if focusedPhotoQuery.data}
|
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
|
{:else if focusedPhotoQuery.isFetching}
|
|
<InlineLoader size="sm" label="Loading metadata…" />
|
|
{/if}
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- Bottom filmstrip across selection.order. -->
|
|
<PreviewCarousel />
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|