feat(preview): inline split-pane preview + sidebar metadata pass

Replace the fullscreen PreviewOverlay with an inline top pane above
each grid surface. SplitGrid + InlinePreview render the focused
photo/video inside the host page; a resizableVertical action drives
the divider and the height persists via the view store. Applied to
the timeline, /tags drill-in, /review cause tabs, /photo/[uid], and
/map. selection.focused is now the single source of truth for both
the inline pane and the right sidebar — preview.svelte store and
PreviewOverlay are removed.

Sidebar: drop the thumb; lead with icon-led filename and folder
rows that match the date/place rhythm. Move dims+size to the top
(below date) and camera/lens/exposure into the collapsible File
section. Read-only spans share the input padding so the text column
aligns across rows. Folder row sits between date and dims+size.

VideoPlayer: stop forcing width/height: 100% so videos honour their
intrinsic aspect ratio inside the pane. Key the player on file hash
in InlinePreview so navigating between videos remounts the element
and autoplay fires again.

Sidebar (LeftSidebar): switch the labels badge to a dedicated
countPhotos('label:*') query so it reports photos with a label
rather than PhotoPrism's category roll-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 22:46:02 +02:00
parent 2a75896274
commit e36f1939c6
19 changed files with 701 additions and 630 deletions

View File

@@ -12,7 +12,6 @@ import {
} from '$lib/services/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
import {
clearSelection,
focusAfter,
@@ -55,16 +54,14 @@ export interface GridKeyNavParams {
* - Window-level shortcuts mirroring mule-image's keyboard layer:
* x archive-toggle, u restore, s + (19) add to
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, space/enter opens preview,
* esc clears, ⌘Z undoes, ⌘A selects all visible.
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no
* keyboard shortcuts.
*
* Archive / restore target a synthesized "cull target list" — in priority:
* 1. preview overlay uid (when open) — applies to the visible preview
* photo even if the grid still shows a stale selection
* 2. multi-selection set
* 3. focused tile
* 1. multi-selection set
* 2. focused tile
*/
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
let scrollToIndex = params.scrollToIndex;
@@ -151,9 +148,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
}
}
/** Synthesize a target list. Preview wins, then multi, then focused. */
/** Synthesize a target list. Multi-selection wins, then focused. */
function cullTargets(): string[] {
if (preview.uid) return [preview.uid];
if (selection.ids.size > 0) return Array.from(selection.ids);
if (selection.focused) return [selection.focused];
return [];
@@ -376,17 +372,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await addCullTargetsToHeap(heap);
}
function openPreviewFromGrid() {
const id = selection.focused ?? selection.order[0];
if (!id) return;
openPreview(id, selection.order);
}
function togglePreview() {
if (preview.uid) closePreview();
else openPreviewFromGrid();
}
async function onKey(e: KeyboardEvent) {
// Don't hijack typing inside form fields.
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
@@ -408,47 +393,35 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
const inPreview = preview.uid !== null;
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
if (!inPreview) {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault();
if (onArrow) {
// Host owns the visual-row map (needed for grids with
// interleaved headers). The host calls setFocused +
// scrollToIndex + selectRange-on-shift itself.
onArrow(e.key, shift);
} else {
const delta =
e.key === 'ArrowLeft'
? -1
: e.key === 'ArrowRight'
? 1
: e.key === 'ArrowUp'
? -tilesPerRow()
: tilesPerRow();
moveFocus(delta, shift);
if (shift && selection.focused) selectRange(selection.focused);
}
return;
case 'Escape':
clearSelection();
setFocused(null);
return;
}
}
// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
// ── Grid nav keys ────────────────────────────────────────────────────
switch (e.key) {
case ' ':
case 'Enter':
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault();
togglePreview();
if (onArrow) {
// Host owns the visual-row map (needed for grids with
// interleaved headers). The host calls setFocused +
// scrollToIndex + selectRange-on-shift itself.
onArrow(e.key, shift);
} else {
const delta =
e.key === 'ArrowLeft'
? -1
: e.key === 'ArrowRight'
? 1
: e.key === 'ArrowUp'
? -tilesPerRow()
: tilesPerRow();
moveFocus(delta, shift);
if (shift && selection.focused) selectRange(selection.focused);
}
return;
case 'Escape':
clearSelection();
setFocused(null);
return;
case 'Tab':
// Tab in the grid context = mule-image's left-sidebar toggle.
@@ -459,7 +432,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return;
case 'i':
case 'I':
if (!meta && !shift && !inPreview) {
if (!meta && !shift) {
e.preventDefault();
toggleRightSidebar();
}
@@ -482,7 +455,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return;
case 'a':
case 'A':
if (meta && !inPreview) {
if (meta) {
e.preventDefault();
for (const id of selection.order) selection.ids.add(id);
}
@@ -560,8 +533,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
node.addEventListener('click', onClick);
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
// immediately on page load regardless of which element holds focus.
// The filters inside `onKey` keep form-field typing and preview mode
// safe (preview owns its own Arrow/Esc).
// The filter inside `onKey` keeps form-field typing safe.
window.addEventListener('keydown', onKey);
return {