From e36f1939c6b1ba8da02df908e73dba31bfc2071b Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 18 May 2026 22:46:02 +0200 Subject: [PATCH] feat(preview): inline split-pane preview + sidebar metadata pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/lib/actions/gridKeyNav.ts | 96 +++---- web/src/lib/actions/resizableVertical.ts | 75 ++++++ .../lib/components/layout/LeftSidebar.svelte | 21 +- .../lib/components/mule/AnimatedMule.svelte | 146 ++++++----- .../components/preview/InlinePreview.svelte | 89 +++++++ .../components/preview/PreviewOverlay.svelte | 161 ------------ .../lib/components/preview/SplitGrid.svelte | 59 +++++ .../lib/components/preview/VideoPlayer.svelte | 16 +- .../components/sidebar/RightSidebar.svelte | 121 ++++----- .../lib/components/timeline/PhotoGrid.svelte | 11 +- .../lib/components/timeline/PhotoTile.svelte | 239 +++++++++--------- web/src/lib/stores/preview.svelte.ts | 36 --- web/src/lib/stores/view.svelte.ts | 25 ++ web/src/routes/+layout.svelte | 21 -- web/src/routes/+page.svelte | 50 ++-- web/src/routes/map/+page.svelte | 8 +- web/src/routes/photo/[uid]/+page.svelte | 22 +- web/src/routes/review/+page.svelte | 59 +++-- web/src/routes/tags/+page.svelte | 76 ++++-- 19 files changed, 701 insertions(+), 630 deletions(-) create mode 100644 web/src/lib/actions/resizableVertical.ts create mode 100644 web/src/lib/components/preview/InlinePreview.svelte delete mode 100644 web/src/lib/components/preview/PreviewOverlay.svelte create mode 100644 web/src/lib/components/preview/SplitGrid.svelte delete mode 100644 web/src/lib/stores/preview.svelte.ts diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 2ea24ff..8d21680 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -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 + (1–9) 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 { diff --git a/web/src/lib/actions/resizableVertical.ts b/web/src/lib/actions/resizableVertical.ts new file mode 100644 index 0000000..31dfeff --- /dev/null +++ b/web/src/lib/actions/resizableVertical.ts @@ -0,0 +1,75 @@ +/** + * Drag-to-resize Svelte action for vertical splits. Sibling of `resizable` + * (which handles left/right edges); kept as its own file so each action's + * surface stays small and the call sites read obviously. + * + * edge: 'bottom' — handle on the bottom edge of the pane; drag down enlarges + * edge: 'top' — handle on the top edge of the pane; drag up enlarges + * + * Usage (handle on the bottom edge of the top preview pane): + *
view.previewPaneHeight, + * setHeight: setPreviewPaneHeight }} /> + */ +export interface ResizableVerticalParams { + edge: 'top' | 'bottom'; + getHeight: () => number; + setHeight: (px: number) => void; +} + +export function resizableVertical(node: HTMLElement, initial: ResizableVerticalParams) { + let params = initial; + let pointerId = -1; + let startY = 0; + let startHeight = 0; + + function onDown(e: PointerEvent) { + if (e.button !== 0) return; + pointerId = e.pointerId; + startY = e.clientY; + startHeight = params.getHeight(); + node.setPointerCapture(pointerId); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + node.addEventListener('pointermove', onMove); + node.addEventListener('pointerup', onUp); + node.addEventListener('pointercancel', onUp); + } + + function onMove(e: PointerEvent) { + if (e.pointerId !== pointerId) return; + const dy = e.clientY - startY; + // `edge: 'bottom'` — handle on the bottom edge of the controlled pane, + // drag down grows it. `edge: 'top'` — handle on the top edge of the + // controlled pane (i.e. the pane is below the handle), drag up grows + // it, so the delta is inverted. Mirrors the horizontal action. + const delta = params.edge === 'bottom' ? dy : -dy; + params.setHeight(startHeight + delta); + } + + function onUp(e: PointerEvent) { + if (pointerId === -1) return; + try { + node.releasePointerCapture(pointerId); + } catch { + // Pointer may already be released; ignore. + } + pointerId = -1; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + node.removeEventListener('pointermove', onMove); + node.removeEventListener('pointerup', onUp); + node.removeEventListener('pointercancel', onUp); + } + + node.addEventListener('pointerdown', onDown); + + return { + update(next: ResizableVerticalParams) { + params = next; + }, + destroy() { + node.removeEventListener('pointerdown', onDown); + } + }; +} diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index a41d220..160db5f 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -126,7 +126,7 @@ })); } - // One query per badge. Admins with no BasePath skip all of these + // One query per badge. Admins with no BasePath skip these // (enabled:false via `wantScoped`) and the configQuery numbers are // used directly — same chrome as before that fix, no extra // round-trips. @@ -134,10 +134,20 @@ const reviewCountQuery = scopedCountQuery('review', 'review:true'); const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true'); const archivedCountQuery = scopedCountQuery('archived', 'archived:true'); - const labelsCountQuery = scopedCountQuery('labels', 'all:true label:*'); + // Labels is special: `configQuery.count.labels` is the number of distinct + // label categories (PhotoPrism's roll-up), not the number of photos that + // carry a label. The Tags surface wants picture counts everywhere, so we + // always run a `countPhotos('label:*')` query regardless of the admin/ + // BasePath shape and never fall back to the category-count. + const labelsCountQuery = createQuery(() => ({ + queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser], + queryFn: () => countPhotos(scoped('all:true label:*')), + enabled: isAuthenticated(), + staleTime: 60_000 + })); function bucketCount( - key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels', + key: 'favorites' | 'review' | 'hidden' | 'archived', query: { data: number | undefined; isPending: boolean } ): number | undefined { if (wantScoped) { @@ -148,7 +158,6 @@ // (no extra round-trip). const c = configQuery.data?.count; if (!c) return undefined; - if (key === 'labels') return c.labels; return c[key]; } @@ -286,7 +295,9 @@ const reviewBadge = $derived(bucketCount('review', reviewCountQuery)); const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery)); const archivedBadge = $derived(bucketCount('archived', archivedCountQuery)); - const labelsBadge = $derived(bucketCount('labels', labelsCountQuery)); + const labelsBadge = $derived( + labelsCountQuery.isPending ? undefined : labelsCountQuery.data + ); const createMut = createMutation(() => ({ mutationFn: (title: string) => createHeap(title), diff --git a/web/src/lib/components/mule/AnimatedMule.svelte b/web/src/lib/components/mule/AnimatedMule.svelte index 43841f1..e28fad8 100644 --- a/web/src/lib/components/mule/AnimatedMule.svelte +++ b/web/src/lib/components/mule/AnimatedMule.svelte @@ -9,91 +9,99 @@ necessary here. --> -
-
- -
{MULIMAGO_ASCII}
-
+
+
+ +
{MULIMAGO_ASCII}
+
-
- {@render children?.()} -
+
+ {@render children?.()} +
diff --git a/web/src/lib/components/preview/InlinePreview.svelte b/web/src/lib/components/preview/InlinePreview.svelte new file mode 100644 index 0000000..0bcb338 --- /dev/null +++ b/web/src/lib/components/preview/InlinePreview.svelte @@ -0,0 +1,89 @@ + + + +
+ {#if uid === null} +

Select a photo to preview.

+ {:else if photoQuery.isPending} +

Loading…

+ {:else if photoQuery.isError} +

Failed to load photo.

+ {:else if photoQuery.data} + {@const pf = primaryFile(photoQuery.data)} + {#if currentIndex > 0} + + {/if} + {#if currentIndex >= 0 && currentIndex < order.length - 1} + + {/if} + + {#if isVideo(photoQuery.data)} + {@const vf = videoFile(photoQuery.data)} + + {#key vf.Hash} + + {/key} + {:else} + {photoQuery.data.OriginalName + {/if} + {/if} +
diff --git a/web/src/lib/components/preview/PreviewOverlay.svelte b/web/src/lib/components/preview/PreviewOverlay.svelte deleted file mode 100644 index d6f3ad2..0000000 --- a/web/src/lib/components/preview/PreviewOverlay.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - -{#if preview.uid !== null} - -{/if} diff --git a/web/src/lib/components/preview/SplitGrid.svelte b/web/src/lib/components/preview/SplitGrid.svelte new file mode 100644 index 0000000..5825743 --- /dev/null +++ b/web/src/lib/components/preview/SplitGrid.svelte @@ -0,0 +1,59 @@ + + + +
+ +
+ {@render preview()} + +
view.previewPaneHeight, + setHeight: setPreviewPaneHeight + }} + role="separator" + aria-orientation="horizontal" + aria-label="Resize preview pane" + > +
+
+
+ + +
+ {@render grid()} +
+
diff --git a/web/src/lib/components/preview/VideoPlayer.svelte b/web/src/lib/components/preview/VideoPlayer.svelte index 8af80e5..ed8938c 100644 --- a/web/src/lib/components/preview/VideoPlayer.svelte +++ b/web/src/lib/components/preview/VideoPlayer.svelte @@ -77,11 +77,21 @@ diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 02549d0..4640661 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -11,8 +11,9 @@ import { Aperture, Calendar, - Camera, ExternalLink, + File, + Folder, ImageIcon, Loader2, MapPin, @@ -34,7 +35,6 @@ } from '$lib/services/photoprism'; import { isAuthenticated } from '$lib/stores/session.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte'; - import { thumbUrl } from '$lib/stores/session.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import RelatedStrip from './RelatedStrip.svelte'; @@ -289,27 +289,18 @@