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:
2026-05-20 00:13:20 +02:00
parent 680fa90cbe
commit 0d5f380948
24 changed files with 1593 additions and 1068 deletions

View File

@@ -13,6 +13,7 @@ import {
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import {
clearBulkToFirst,
clearSelection,
focusAfter,
indexOf,
@@ -23,7 +24,7 @@ import {
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
/**
@@ -381,6 +382,24 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Modal owns arrow / Escape / Space while it's open — it handles
// its own linear nav, close-on-Esc, and close-on-Space. Action
// keys (X/S/U/A/Z) still pass through because they target the
// shared selection store and work the same in either context.
if (view.previewOpen) {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Escape' ||
e.key === ' ' ||
e.code === 'Space'
) {
return;
}
}
// S+digit chord. A digit 19 within the chord window consumes the key
// and fires add-to-heap-N. Any other key cancels the chord without
// firing the default active-heap action — the user switched intent —
@@ -398,6 +417,18 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
// Space on a focused tile opens the full-screen preview modal.
// Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers
// layouts where `e.key` is the dead-key combining mark.
if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
if (selection.focused) {
e.preventDefault();
openPreview();
return;
}
}
// ── Grid nav keys ────────────────────────────────────────────────────
switch (e.key) {
case 'ArrowLeft':
@@ -424,6 +455,11 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
}
return;
case 'Escape':
// First Esc collapses a multi-selection back to single-focus
// on its first member — the user's "starting photo" stays
// visible instead of vanishing. Only when there's no bulk
// does Esc fully dismiss focus.
if (clearBulkToFirst()) return;
clearSelection();
setFocused(null);
return;