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 {

View File

@@ -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):
* <div use:resizableVertical={{ edge: 'bottom',
* getHeight: () => 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);
}
};
}

View File

@@ -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<number>(() => ({
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<number | undefined>(
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
);
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),

View File

@@ -9,91 +9,99 @@
necessary here.
-->
<script lang="ts">
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
interface Props {
children?: import("svelte").Snippet;
}
let { children }: Props = $props();
</script>
<header class="mule-header relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4">
<div class="relative flex items-center gap-3">
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
<pre
aria-label="Mulimago"
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
style="letter-spacing: 0;"
>{MULIMAGO_ASCII}</pre>
</div>
<header
class="mule-header relative flex h-14 items-center justify-between overflow-hidden border-b border-border px-4"
>
<div class="relative flex items-center gap-3">
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
<pre
aria-label="Mulimago"
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
style="letter-spacing: 0;">{MULIMAGO_ASCII}</pre>
</div>
<div class="relative flex items-center gap-3">
{@render children?.()}
</div>
<div class="relative flex items-center gap-3">
{@render children?.()}
</div>
</header>
<style>
/*
/*
* Two layers in the background: tiled desert.png on top scrolling
* right→left, dusk-sky gradient underneath. The 200px tile width is
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
* loops seamlessly.
*/
.mule-header {
background-image:
url('/mule/desert.png'),
linear-gradient(to bottom, #2b3a5c 0%, #6b6b8a 35%, #d68a5c 75%, #f0c188 100%);
background-repeat: repeat-x, no-repeat;
background-size:
200px 100%,
100% 100%;
background-position: 0 bottom, 0 0;
image-rendering: pixelated;
animation: desert-scroll 24s linear infinite;
}
.mule-header {
background-image: url("/mule/desert.png"),
linear-gradient(
to bottom,
#2b3a5c 0%,
#6b6b8a 35%,
#d68a5c 75%,
#f0c188 100%
);
background-repeat: repeat-x, no-repeat;
background-size:
200px 100%,
100% 100%;
background-position:
0 bottom,
0 0;
image-rendering: pixelated;
animation: desert-scroll 24s linear infinite;
}
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
* snap (no interpolation between frames). */
.mule-sprite {
background-image: url('/mule/mule-sprites.png');
background-size: 300% 200%;
background-repeat: no-repeat;
image-rendering: pixelated;
animation: mule-walk 0.6s steps(1) infinite;
}
.mule-sprite {
background-image: url("/mule/mule-sprites.png");
background-size: 300% 200%;
background-repeat: no-repeat;
image-rendering: pixelated;
animation: mule-walk 0.6s steps(1) infinite;
}
@keyframes mule-walk {
0% {
background-position: 0% 0%;
}
16.66% {
background-position: 50% 0%;
}
33.33% {
background-position: 100% 0%;
}
50% {
background-position: 0% 100%;
}
66.66% {
background-position: 50% 100%;
}
83.33% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes mule-walk {
0% {
background-position: 0% 0%;
}
16.66% {
background-position: 50% 0%;
}
33.33% {
background-position: 100% 0%;
}
50% {
background-position: 0% 100%;
}
66.66% {
background-position: 50% 100%;
}
83.33% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes desert-scroll {
from {
background-position-x: 0px, 0px;
}
to {
background-position-x: -200px, 0px;
}
}
@keyframes desert-scroll {
from {
background-position-x: 0px, 0px;
}
to {
background-position-x: -200px, 0px;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<!--
Inline preview pane: replaces the old fullscreen `PreviewOverlay`.
Renders the focused photo or video inside its host pane so the grid
stays visible below. Prev/next move `selection.focused` directly so
the right metadata sidebar tracks in lockstep.
-->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
uid: string | null;
/** Ordered uid list for prev/next chevrons. The host page mirrors
* whatever list the user is currently looking at into this prop so
* navigation stays in context (timeline order, drill-in order, etc). */
order: string[];
}
let { uid, order }: Props = $props();
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''],
queryFn: () => getPhoto(uid as string),
enabled: Boolean(uid)
}));
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
function focusAt(i: number) {
const next = order[i];
if (!next) return;
setFocused(next);
setAnchor(next);
}
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
{#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
{:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p>
{:else if photoQuery.isError}
<p class="text-sm text-destructive">Failed to load photo.</p>
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
{#if currentIndex > 0}
<button
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={() => focusAt(currentIndex - 1)}
aria-label="Previous photo"
>
</button>
{/if}
{#if currentIndex >= 0 && currentIndex < order.length - 1}
<button
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={() => focusAt(currentIndex + 1)}
aria-label="Next photo"
>
</button>
{/if}
{#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)}
<!-- Key on the video hash so navigating to a new video remounts the
player. Without this the <media-player> element keeps the
previous src bound and `autoplay` doesn't re-fire — clicking
a video tile would leave the pane idle on its poster. -->
{#key vf.Hash}
<VideoPlayer
src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1920')}
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{/key}
{:else}
<img
src={thumbUrl(pf.Hash, 'fit_1920')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
{/if}
{/if}
</div>

View File

@@ -1,161 +0,0 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import {
closePreview,
preview,
previewNext,
previewPrev
} from '$lib/stores/preview.svelte';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', preview.uid ?? ''],
queryFn: () => getPhoto(preview.uid as string),
enabled: Boolean(preview.uid)
}));
// Mirror the currently-shown photo into selection.focused. The
// timeline's tile uses `selection.focused === photo.UID` to draw the
// blue ring, so this keeps the selection in lockstep with whatever
// the user is paging through in preview. The host page (+page.svelte)
// owns the matching scroll-into-view on close so the tile actually
// mounts (it can be windowed out if the user navigated far).
$effect(() => {
if (preview.uid !== null) setFocused(preview.uid);
});
// Keyboard handling lives at the document level so it works regardless
// of focus location. Form fields inside the sidebar still keep their
// own arrow-key behaviour because we ignore events whose target is an
// input/textarea.
$effect(() => {
function onKey(e: KeyboardEvent) {
if (preview.uid === null) return;
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
const inField = tag === 'input' || tag === 'textarea' || tag === 'select';
switch (e.key) {
case 'Escape':
e.preventDefault();
closePreview();
break;
case 'ArrowLeft':
if (inField) return;
e.preventDefault();
previewPrev();
break;
case 'ArrowRight':
if (inField) return;
e.preventDefault();
previewNext();
break;
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
// Prevent body scroll while the overlay is up.
$effect(() => {
if (typeof document === 'undefined') return;
const prev = document.body.style.overflow;
if (preview.uid !== null) document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
});
function onBackdrop(e: MouseEvent) {
// Clicking the dimmed area (but not the image or sidebar) closes.
if (e.target === e.currentTarget) closePreview();
}
const currentIndex = $derived(
preview.uid ? preview.order.indexOf(preview.uid) : -1
);
</script>
{#if preview.uid !== null}
<div
class="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Photo preview"
onclick={onBackdrop}
onkeydown={(e) => {
if (e.key === 'Escape') closePreview();
}}
tabindex="-1"
>
<!-- Left: image area -->
<div
class="relative flex flex-1 items-center justify-center p-6"
onclick={onBackdrop}
role="presentation"
>
<button
class="absolute left-4 top-4 z-10 rounded-md bg-background/80 px-2.5 py-1.5 text-xs hover:bg-background"
onclick={closePreview}
aria-label="Close preview"
>
✕ Close
</button>
{#if currentIndex > 0}
<button
class="absolute left-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={previewPrev}
aria-label="Previous photo"
>
</button>
{/if}
{#if currentIndex >= 0 && currentIndex < preview.order.length - 1}
<button
class="absolute right-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={previewNext}
aria-label="Next photo"
>
</button>
{/if}
{#if photoQuery.isPending}
<p class="text-sm text-white/80">Loading…</p>
{:else if photoQuery.isError}
<p class="text-sm text-red-300">Failed to load photo.</p>
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
{#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)}
<VideoPlayer
src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1920')}
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{:else}
<img
src={thumbUrl(pf.Hash, 'fit_1920')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
{/if}
{/if}
</div>
<!-- Right: metadata sidebar -->
<aside
class="w-[360px] shrink-0 overflow-y-auto border-l border-border bg-background p-4"
>
{#if photoQuery.data}
<RightSidebar photo={photoQuery.data} />
{:else}
<p class="text-sm text-muted-foreground">Loading metadata…</p>
{/if}
</aside>
</div>
{/if}

View File

@@ -0,0 +1,59 @@
<!--
Vertical split layout: a top pane (preview) sized by
`view.previewPaneHeight`, a draggable divider, and a flex-1 bottom
pane (the grid). Mirrors the horizontal sidebar resize pattern.
Usage:
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main>…</main>
{/snippet}
</SplitGrid>
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { resizableVertical } from '$lib/actions/resizableVertical';
import { setPreviewPaneHeight, view } from '$lib/stores/view.svelte';
interface Props {
preview: Snippet;
grid: Snippet;
}
let { preview, grid }: Props = $props();
</script>
<div class="flex min-h-0 flex-1 flex-col">
<!-- Top: preview pane. Fixed pixel height controlled by the divider;
overflow-hidden so videos / images can't push the divider off-screen. -->
<div
class="relative shrink-0 overflow-hidden border-b border-border"
style="height: {view.previewPaneHeight}px;"
>
{@render preview()}
<!-- Divider sits on the bottom edge of the preview pane.
`edge: 'bottom'` matches the convention: positive dy = pane grows. -->
<div
class="group absolute -bottom-1.5 left-0 z-20 h-3 w-full cursor-row-resize"
use:resizableVertical={{
edge: 'bottom',
getHeight: () => view.previewPaneHeight,
setHeight: setPreviewPaneHeight
}}
role="separator"
aria-orientation="horizontal"
aria-label="Resize preview pane"
>
<div
class="mt-1 h-0.5 w-full bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</div>
<!-- Bottom: grid container; the caller's snippet handles its own scroll. -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
{@render grid()}
</div>
</div>

View File

@@ -77,11 +77,21 @@
<style>
:global(.vds-player) {
width: 100%;
height: 100%;
/* Let the player size to the video's intrinsic aspect ratio,
capped by the host pane. Don't set width/height to 100% — that
was stretching widescreen video into the square inline-preview
pane. The default vidstack layout reads the loaded media's
aspect and sizes the box accordingly; we just clamp the upper
bound so it can't escape the SplitGrid top pane. */
max-width: 100%;
max-height: 100%;
aspect-ratio: auto;
--media-brand: #3b82f6;
--media-focus-ring-color: #3b82f6;
}
:global(.vds-player video),
:global(.vds-player media-provider) {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@@ -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 @@
</script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Header strip — thumb + path (read-only) over editable basename. The
sidecar's rename endpoint only accepts a bare basename and preserves
the directory on disk, so the split UI mirrors that contract. -->
<div class="flex items-start gap-2">
<img
src={thumbUrl(pf.Hash, 'tile_100')}
alt=""
class="h-10 w-10 shrink-0 rounded object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
{#if dirPath}
<div
class="truncate text-[10px] text-muted-foreground"
title={dirPath}
>
{dirPath}/
</div>
{/if}
<!-- Compact info rows. Filename / folder lead the stack as icon-led
rows so they read in the same rhythm as the date / place / camera
rows below. The sidecar's rename endpoint only accepts a bare
basename and preserves the directory on disk, so the basename row
is editable while the folder row stays read-only. -->
<dl class="space-y-1">
<!-- Filename (editable) -->
<div class="flex items-center gap-2">
<File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="text"
class="min-w-0 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium leading-snug break-all hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={basename}
disabled={renaming}
onblur={commitFilename}
@@ -321,16 +312,11 @@
}}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
{#if renaming}
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
</div>
<!-- Inline spinner so the user sees the rename in flight without
scanning to the bottom of the sidebar. -->
{#if renaming}
<Loader2 class="mt-1 h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
</div>
<!-- Compact info rows -->
<dl class="space-y-1">
<!-- Taken at -->
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -346,10 +332,31 @@
/>
</div>
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
padding on filename / date so the read-only text starts at the
same x-offset as the editable rows above — otherwise spans
hug the icon while inputs sit 4px in. -->
{#if dirPath}
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
{dirPath}/
</span>
</div>
{/if}
<!-- Dimensions + file size -->
<div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{dims} · {sizeStr}
</span>
</div>
<!-- Location -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-muted-foreground">
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if mapsHref}
@@ -364,33 +371,6 @@
</a>
{/if}
</div>
<!-- Camera / lens — only render if something to show -->
{#if cameraStr || lensStr || exposureParts.iso || exposureParts.fnum}
<div class="flex items-start gap-2">
<Camera class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1 space-y-0.5 text-muted-foreground">
{#if cameraStr}<div class="truncate">{cameraStr}</div>{/if}
{#if lensStr && lensStr !== cameraStr}<div class="truncate">{lensStr}</div>{/if}
{#if exposureParts.iso || exposureParts.fnum || exposureParts.focal || exposureParts.exp}
<div class="flex flex-wrap gap-x-2 text-[10px]">
{#if exposureParts.fnum}
<span class="flex items-center gap-0.5">
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
</span>
{/if}
{#if exposureParts.exp}
<span class="flex items-center gap-0.5">
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
</span>
{/if}
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
</div>
{/if}
</div>
</div>
{/if}
</dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
@@ -603,8 +583,31 @@
</span>
</summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
<dt class="text-muted-foreground">Size</dt>
<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
{#if cameraStr}
<dt class="text-muted-foreground">Camera</dt>
<dd class="text-foreground/80">{cameraStr}</dd>
{/if}
{#if lensStr && lensStr !== cameraStr}
<dt class="text-muted-foreground">Lens</dt>
<dd class="text-foreground/80">{lensStr}</dd>
{/if}
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
<dt class="text-muted-foreground">Exposure</dt>
<dd class="flex flex-wrap gap-x-2 text-foreground/80">
{#if exposureParts.fnum}
<span class="flex items-center gap-0.5">
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
</span>
{/if}
{#if exposureParts.exp}
<span class="flex items-center gap-0.5">
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
</span>
{/if}
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
</dd>
{/if}
<dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
<dt class="text-muted-foreground">Hash</dt>

View File

@@ -21,7 +21,6 @@
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { view } from '$lib/stores/view.svelte';
import { type PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
@@ -77,11 +76,17 @@
function onDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(uid, order);
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
function onOpenPreview(uid: string) {
openPreview(uid, order);
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
</script>

View File

@@ -15,56 +15,56 @@
not-yet-discovered dblclick gesture)
-->
<script lang="ts">
import { Maximize2 } from 'lucide-svelte';
import { thumbSrc, thumbSrcSet, videoUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { Maximize2 } from "lucide-svelte";
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
interface Props {
photo: PpPhoto;
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
onOpenPreview: () => void;
}
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
interface Props {
photo: PpPhoto;
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
onOpenPreview: () => void;
}
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo));
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo));
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
// before mounting the <video> so a cursor skimming across the grid
// doesn't kick off N HTTP requests, then we cross-fade in once the
// first frame is decoded (videoReady).
const HOVER_DELAY = 250;
let hoverPlaying = $state(false);
let videoReady = $state(false);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
// before mounting the <video> so a cursor skimming across the grid
// doesn't kick off N HTTP requests, then we cross-fade in once the
// first frame is decoded (videoReady).
const HOVER_DELAY = 250;
let hoverPlaying = $state(false);
let videoReady = $state(false);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onMouseEnter() {
if (!video || selected) return;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
hoverPlaying = true;
}, HOVER_DELAY);
}
function onMouseLeave() {
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
hoverPlaying = false;
videoReady = false;
}
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
// wider when the grid stretches to fill the column, but `tile_*` is
// discrete (100/224/500) so the rounding-up at the next variant
// boundary swallows the difference.
const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx));
function onMouseEnter() {
if (!video || selected) return;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
hoverPlaying = true;
}, HOVER_DELAY);
}
function onMouseLeave() {
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
hoverPlaying = false;
videoReady = false;
}
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
// wider when the grid stretches to fill the column, but `tile_*` is
// discrete (100/224/500) so the rounding-up at the next variant
// boundary swallows the difference.
const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx));
</script>
<!--
@@ -74,12 +74,12 @@
in <button> is invalid HTML.
-->
<div
class="group relative h-full w-full"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
role="presentation"
class="group relative h-full w-full"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
role="presentation"
>
<!--
<!--
Selection animation: scale to 90% + blue ring + blue tint overlay,
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.
The overshoot is intentionally modest — on a *shrink* a larger
@@ -91,85 +91,84 @@
The keyboard-focused tile gets the same treatment as a selected one,
so the arrow-key cursor reads as a "selection of one".
-->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={onClick}
ondblclick={onDblclick}
title="Click to select · Double-click to open"
class:scale-90={selected}
class:ring-2={selected}
class:ring-blue-500={selected}
class:ring-offset-2={selected}
class:ring-offset-background={selected}
class:transition-[transform,box-shadow]={selected}
class:duration-300={selected}
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={src1x}
srcset={srcset}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
class:transition={!selected}
class:group-hover:scale-105={!selected}
/>
{#if hoverPlaying}
<!--
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={onClick}
ondblclick={onDblclick}
class:scale-90={selected}
class:ring-2={selected}
class:ring-blue-500={selected}
class:ring-offset-2={selected}
class:ring-offset-background={selected}
class:transition-[transform,box-shadow]={selected}
class:duration-300={selected}
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={src1x}
{srcset}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? "Photo"}
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
class:transition={!selected}
class:group-hover:scale-105={!selected}
/>
{#if hoverPlaying}
<!--
Muted hover-preview video stacked over the thumbnail. We don't
unmount the <img> underneath — the video fades in once its
first frame is decoded, so the tile never goes blank during
the network round-trip. `pointer-events-none` keeps clicks
flowing through to the parent <button>.
-->
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={videoUrl(hash)}
autoplay
muted
loop
playsinline
preload="auto"
tabindex={-1}
oncanplay={() => (videoReady = true)}
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
class:opacity-0={!videoReady}
class:opacity-100={videoReady}
></video>
{/if}
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
<!--
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={videoUrl(hash)}
autoplay
muted
loop
playsinline
preload="auto"
tabindex={-1}
oncanplay={() => (videoReady = true)}
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
class:opacity-0={!videoReady}
class:opacity-100={videoReady}
></video>
{/if}
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
<!--
Hover-only "open preview" affordance. Single-click on this icon
opens the preview directly, giving users a one-click fallback for
the dblclick gesture (and a visual cue that previewing is a thing
at all). Hidden when the tile is selected — there'd be no preview
intent on a tile the user is in the middle of bulk-acting on.
-->
{#if !selected}
<button
type="button"
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
onclick={(e) => {
e.stopPropagation();
onOpenPreview();
}}
title="Open preview"
aria-label="Open preview"
>
<Maximize2 class="h-3 w-3" />
</button>
{/if}
{#if !selected}
<button
type="button"
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
onclick={(e) => {
e.stopPropagation();
onOpenPreview();
}}
title="Open preview"
aria-label="Open preview"
>
<Maximize2 class="h-3 w-3" />
</button>
{/if}
</div>

View File

@@ -1,36 +0,0 @@
/**
* Single-photo preview overlay state. The lightbox sits in +layout.svelte
* and listens to this store; any view (timeline, duplicates view, heaps)
* can `open(uid)` to pop it. The order array is mirrored from whatever
* list the user is currently looking at so prev/next stay in context.
*/
export const preview = $state<{
uid: string | null;
order: string[];
}>({
uid: null,
order: []
});
export function openPreview(uid: string, order?: string[]): void {
if (order) preview.order = order;
preview.uid = uid;
}
export function closePreview(): void {
preview.uid = null;
}
export function previewNext(): void {
if (!preview.uid) return;
const i = preview.order.indexOf(preview.uid);
if (i < 0 || i >= preview.order.length - 1) return;
preview.uid = preview.order[i + 1];
}
export function previewPrev(): void {
if (!preview.uid) return;
const i = preview.order.indexOf(preview.uid);
if (i <= 0) return;
preview.uid = preview.order[i - 1];
}

View File

@@ -24,6 +24,7 @@ interface Persisted {
thumbnailSize?: ThumbnailSize;
leftSidebarWidth?: number;
rightSidebarWidth?: number;
previewPaneHeight?: number;
/**
* Per-section expanded state for the right-sidebar metadata panel
* (GPS, Credits, File). Keyed by section id; missing entries use a
@@ -39,6 +40,14 @@ export const DEFAULT_LEFT_WIDTH = 224;
export const MIN_RIGHT_WIDTH = 220;
export const MAX_RIGHT_WIDTH = 480;
export const DEFAULT_RIGHT_WIDTH = 280;
export const MIN_PREVIEW_HEIGHT = 160;
export const DEFAULT_PREVIEW_HEIGHT = 360;
/**
* Cap the preview pane at 70 % of the viewport so the grid is always
* visible underneath. Resolved against `window.innerHeight` at set-time
* (the localStorage load happens before any viewport size is known).
*/
export const MAX_PREVIEW_HEIGHT_FRAC = 0.7;
function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
@@ -69,6 +78,7 @@ export const view = $state<{
thumbnailSize: ThumbnailSize;
leftSidebarWidth: number;
rightSidebarWidth: number;
previewPaneHeight: number;
metadataSections: Record<string, boolean>;
}>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
@@ -86,6 +96,12 @@ export const view = $state<{
MIN_RIGHT_WIDTH,
MAX_RIGHT_WIDTH
),
previewPaneHeight: Math.max(
MIN_PREVIEW_HEIGHT,
typeof initial.previewPaneHeight === 'number'
? initial.previewPaneHeight
: DEFAULT_PREVIEW_HEIGHT
),
metadataSections:
initial.metadataSections && typeof initial.metadataSections === 'object'
? { ...initial.metadataSections }
@@ -100,6 +116,7 @@ function persist(): void {
thumbnailSize: view.thumbnailSize,
leftSidebarWidth: view.leftSidebarWidth,
rightSidebarWidth: view.rightSidebarWidth,
previewPaneHeight: view.previewPaneHeight,
metadataSections: view.metadataSections
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -115,6 +132,14 @@ export function setRightSidebarWidth(px: number): void {
persist();
}
export function setPreviewPaneHeight(px: number): void {
const maxH = browser
? Math.max(MIN_PREVIEW_HEIGHT + 1, Math.floor(window.innerHeight * MAX_PREVIEW_HEIGHT_FRAC))
: 1024;
view.previewPaneHeight = clamp(Math.round(px), MIN_PREVIEW_HEIGHT, maxH);
persist();
}
export function setThumbnailSize(size: ThumbnailSize): void {
view.thumbnailSize = size;
persist();

View File

@@ -4,7 +4,6 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Component } from 'svelte';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
@@ -13,7 +12,6 @@
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import { preview } from '$lib/stores/preview.svelte';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
@@ -56,22 +54,6 @@
else stopIndexerWatch();
});
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
// pane, exif sidebar. Users who never click into a photo never need
// it, so we lazy-import the first time `preview.uid` flips non-null
// and keep the loaded module around for the rest of the session
// (re-opens skip the network round-trip). Closing the overlay leaves
// the component mounted but renders nothing — its internal
// `{#if preview.uid !== null}` guard collapses the DOM tree.
let PreviewOverlay = $state<Component | null>(null);
$effect(() => {
if (!browser) return;
if (preview.uid !== null && PreviewOverlay === null) {
void import('$lib/components/preview/PreviewOverlay.svelte').then((m) => {
PreviewOverlay = m.default as Component;
});
}
});
</script>
<svelte:head>
@@ -129,7 +111,4 @@
{:else}
{@render children?.()}
{/if}
{#if PreviewOverlay}
<PreviewOverlay />
{/if}
</QueryClientProvider>

View File

@@ -33,7 +33,6 @@
setFocused,
setOrder,
} from "$lib/stores/selection.svelte";
import { openPreview, preview } from "$lib/stores/preview.svelte";
import {
setRightSidebarWidth,
setThumbnailSize,
@@ -51,9 +50,11 @@
} from "$lib/actions/visibleRange";
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
import InlinePreview from "$lib/components/preview/InlinePreview.svelte";
import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import SplitGrid from "$lib/components/preview/SplitGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { type PpPhoto } from "$lib/types/photoprism";
@@ -408,25 +409,6 @@
if (el) scrollTileIntoView(el);
}
// When the preview overlay closes, scroll the just-shown photo back
// into the timeline window. PreviewOverlay already keeps
// selection.focused in lockstep with preview.uid, so we just need to
// make sure that tile is mounted (forcedExpand) and visible — the
// blue ring renders itself once the inner button is in the DOM.
let wasPreviewOpen = $state(false);
$effect(() => {
const open = preview.uid !== null;
const closing = wasPreviewOpen && !open;
wasPreviewOpen = open;
if (!closing) return;
const uid = selection.focused;
if (!uid) return;
untrack(() => {
const i = photos.findIndex((p) => p.UID === uid);
if (i >= 0) void scrollToIndex(i);
});
});
// ── Visual rows for keyboard navigation ──────────────────────────────────
// The CSS Grid lays each photo into a cell with column count derived from
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
@@ -675,24 +657,19 @@
}
function onTileDblclick(e: MouseEvent, uid: string) {
// Modifier-modified dblclicks shouldn't open the preview either —
// gridKeyNav already handled the underlying click.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(
uid,
photos.map((p) => p.UID),
);
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
// Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview.
function onTileOpenPreview(uid: string) {
openPreview(
uid,
photos.map((p) => p.UID),
);
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
@@ -840,6 +817,11 @@
sibling at row level and stays full height when the bar appears.
-->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main
bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -952,6 +934,8 @@
{/if}
</div>
</main>
{/snippet}
</SplitGrid>
<BulkActionBar />
</div>

View File

@@ -7,9 +7,10 @@
type MapSourceDataEvent
} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { goto } from '$app/navigation';
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
const geoQuery = createQuery<PpGeoCollection>(() => ({
@@ -154,7 +155,10 @@
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
openPreview(uid, allUids);
setOrder(allUids);
setFocused(uid);
setAnchor(uid);
void goto('/');
});
return el;
}

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { openPreview } from '$lib/stores/preview.svelte';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
// Deep-link entry: opening /photo/<uid> directly pops the overlay on
// the timeline. The route itself does not render anything; it hands
// off to the global PreviewOverlay and redirects to `/` so the URL
// stays clean and the timeline shows behind the modal.
$effect(() => {
const uid = page.params.uid as string | undefined;
if (!uid) return;
openPreview(uid);
void goto('/', { replaceState: true });
});
// Deep-link entry. The route renders a full-page InlinePreview keyed
// on the URL `uid` so the link stays shareable and reload-safe. No
// surrounding grid here — this surface is a single-photo viewer.
const uid = $derived((page.params.uid ?? null) as string | null);
const order = $derived(uid ? [uid] : []);
</script>
<div class="flex min-h-0 flex-1">
<InlinePreview {uid} {order} />
</div>

View File

@@ -51,6 +51,8 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab;
@@ -224,31 +226,38 @@
{:else}
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
{/snippet}
</SplitGrid>
<BulkActionBar />
</div>

View File

@@ -4,6 +4,7 @@
import { createQuery } from '@tanstack/svelte-query';
import {
aggregateKeywords,
countPhotos,
getAllMarks,
listLabels,
listPhotos,
@@ -11,13 +12,16 @@
type PhotoMarksMap,
type PpLabel
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap
@@ -88,6 +92,25 @@
enabled: isAuthenticated()
}));
// Photo-count for the Labels tab pill — counts pictures that carry any
// label, not distinct label categories. Mirrors (and shares cache with)
// the LeftSidebar's labels badge by reusing its scoping rules + queryKey
// so the two reads dedupe through svelte-query.
const isAdminUser = $derived(session.user?.Role === 'admin');
function scopedLabelsFilter(): string {
const bp = userBasePath();
const base = 'all:true label:*';
if (isAdminUser && bp === '') return base;
if (!isAdminUser && bp === '') return 'uid:none';
return `${base} path:"${bp}*"`;
}
const labelsPhotoCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
queryFn: () => countPhotos(scopedLabelsFilter()),
enabled: isAuthenticated(),
staleTime: 60_000
}));
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
@@ -186,12 +209,13 @@
}
// ── Per-tab badge counts ─────────────────────────────────────────────────
// Each tab pill shows what its grid covers: distinct labels/keywords for
// the bucket-style tabs, photo-count for the fixed-cardinality ones
// (ratings/colors), matching how the sidebar's Tags badge aggregates.
// Each pill shows the number of *photos* a tab covers (not categories),
// so all four tabs read on the same scale and match the sidebar's Tags
// badge. Keywords stays as a distinct-keyword count because the keywords
// pool is the only one without a cheap photo-rollup query.
// `undefined` means the underlying query hasn't resolved yet — the badge
// is skipped rather than showing a misleading 0.
const labelsCount = $derived<number | undefined>(labelsQuery.data?.length);
const labelsCount = $derived<number | undefined>(labelsPhotoCountQuery.data);
const keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
const ratedPhotosCount = $derived<number | undefined>(
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
@@ -367,24 +391,37 @@
{/snippet}
</Toolbar>
{#if drillKey}
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL
drives labels/keywords while ratings/colors resolve locally
from the marks pool already in cache. -->
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
<SkeletonGrid />
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
{/snippet}
</SplitGrid>
{:else}
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if drillKey}
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL drives
labels/keywords while ratings/colors resolve locally from the
marks pool already in cache. -->
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
<SkeletonGrid />
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
{:else if activeTab === 'labels'}
{#if activeTab === 'labels'}
{#if labelsQuery.isPending}
<SkeletonGrid />
{:else if labelsQuery.isError}
@@ -546,5 +583,6 @@
{/if}
{/if}
</main>
{/if}
<BulkActionBar />