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'; } from '$lib/services/photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
import { import {
clearSelection, clearSelection,
focusAfter, focusAfter,
@@ -55,16 +54,14 @@ export interface GridKeyNavParams {
* - Window-level shortcuts mirroring mule-image's keyboard layer: * - Window-level shortcuts mirroring mule-image's keyboard layer:
* x archive-toggle, u restore, s + (19) add to * x archive-toggle, u restore, s + (19) add to
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles * heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, space/enter opens preview, * left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* esc clears, ⌘Z undoes, ⌘A selects all visible. * ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no * Rating + color labels are mouse-driven via the metadata sidebar — no
* keyboard shortcuts. * keyboard shortcuts.
* *
* Archive / restore target a synthesized "cull target list" — in priority: * Archive / restore target a synthesized "cull target list" — in priority:
* 1. preview overlay uid (when open) — applies to the visible preview * 1. multi-selection set
* photo even if the grid still shows a stale selection * 2. focused tile
* 2. multi-selection set
* 3. focused tile
*/ */
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
let scrollToIndex = params.scrollToIndex; 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[] { function cullTargets(): string[] {
if (preview.uid) return [preview.uid];
if (selection.ids.size > 0) return Array.from(selection.ids); if (selection.ids.size > 0) return Array.from(selection.ids);
if (selection.focused) return [selection.focused]; if (selection.focused) return [selection.focused];
return []; return [];
@@ -376,17 +372,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await addCullTargetsToHeap(heap); 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) { async function onKey(e: KeyboardEvent) {
// Don't hijack typing inside form fields. // Don't hijack typing inside form fields.
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); 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 meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey; const shift = e.shiftKey;
const inPreview = preview.uid !== null;
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ────────────── // ── Grid nav keys ────────────────────────────────────────────────────
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) ──────────────────
switch (e.key) { switch (e.key) {
case ' ': case 'ArrowLeft':
case 'Enter': case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault(); 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; return;
case 'Tab': case 'Tab':
// Tab in the grid context = mule-image's left-sidebar toggle. // Tab in the grid context = mule-image's left-sidebar toggle.
@@ -459,7 +432,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return; return;
case 'i': case 'i':
case 'I': case 'I':
if (!meta && !shift && !inPreview) { if (!meta && !shift) {
e.preventDefault(); e.preventDefault();
toggleRightSidebar(); toggleRightSidebar();
} }
@@ -482,7 +455,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return; return;
case 'a': case 'a':
case 'A': case 'A':
if (meta && !inPreview) { if (meta) {
e.preventDefault(); e.preventDefault();
for (const id of selection.order) selection.ids.add(id); 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); node.addEventListener('click', onClick);
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work // Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
// immediately on page load regardless of which element holds focus. // immediately on page load regardless of which element holds focus.
// The filters inside `onKey` keep form-field typing and preview mode // The filter inside `onKey` keeps form-field typing safe.
// safe (preview owns its own Arrow/Esc).
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return { 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 // (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra // used directly — same chrome as before that fix, no extra
// round-trips. // round-trips.
@@ -134,10 +134,20 @@
const reviewCountQuery = scopedCountQuery('review', 'review:true'); const reviewCountQuery = scopedCountQuery('review', 'review:true');
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true'); const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
const archivedCountQuery = scopedCountQuery('archived', 'archived: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( function bucketCount(
key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels', key: 'favorites' | 'review' | 'hidden' | 'archived',
query: { data: number | undefined; isPending: boolean } query: { data: number | undefined; isPending: boolean }
): number | undefined { ): number | undefined {
if (wantScoped) { if (wantScoped) {
@@ -148,7 +158,6 @@
// (no extra round-trip). // (no extra round-trip).
const c = configQuery.data?.count; const c = configQuery.data?.count;
if (!c) return undefined; if (!c) return undefined;
if (key === 'labels') return c.labels;
return c[key]; return c[key];
} }
@@ -286,7 +295,9 @@
const reviewBadge = $derived(bucketCount('review', reviewCountQuery)); const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery)); const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery)); 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(() => ({ const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title), mutationFn: (title: string) => createHeap(title),

View File

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

View File

@@ -11,8 +11,9 @@
import { import {
Aperture, Aperture,
Calendar, Calendar,
Camera,
ExternalLink, ExternalLink,
File,
Folder,
ImageIcon, ImageIcon,
Loader2, Loader2,
MapPin, MapPin,
@@ -34,7 +35,6 @@
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.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 { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import RelatedStrip from './RelatedStrip.svelte'; import RelatedStrip from './RelatedStrip.svelte';
@@ -289,27 +289,18 @@
</script> </script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs"> <aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Header strip — thumb + path (read-only) over editable basename. The <!-- Compact info rows. Filename / folder lead the stack as icon-led
sidecar's rename endpoint only accepts a bare basename and preserves rows so they read in the same rhythm as the date / place / camera
the directory on disk, so the split UI mirrors that contract. --> rows below. The sidecar's rename endpoint only accepts a bare
<div class="flex items-start gap-2"> basename and preserves the directory on disk, so the basename row
<img is editable while the folder row stays read-only. -->
src={thumbUrl(pf.Hash, 'tile_100')} <dl class="space-y-1">
alt="" <!-- Filename (editable) -->
class="h-10 w-10 shrink-0 rounded object-cover" <div class="flex items-center gap-2">
/> <File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<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}
<input <input
type="text" 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} bind:value={basename}
disabled={renaming} disabled={renaming}
onblur={commitFilename} onblur={commitFilename}
@@ -321,16 +312,11 @@
}} }}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'} 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> </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 --> <!-- Taken at -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -346,10 +332,31 @@
/> />
</div> </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 --> <!-- Location -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <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'} {placeLabel || 'No location'}
</span> </span>
{#if mapsHref} {#if mapsHref}
@@ -364,33 +371,6 @@
</a> </a>
{/if} {/if}
</div> </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> </dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match <!-- Note (PhotoPrism's Caption field — labelled "Note" to match
@@ -603,8 +583,31 @@
</span> </span>
</summary> </summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]"> <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> {#if cameraStr}
<dd class="text-foreground/80">{dims} · {sizeStr}</dd> <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> <dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd> <dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
<dt class="text-muted-foreground">Hash</dt> <dt class="text-muted-foreground">Hash</dt>

View File

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

View File

@@ -15,56 +15,56 @@
not-yet-discovered dblclick gesture) not-yet-discovered dblclick gesture)
--> -->
<script lang="ts"> <script lang="ts">
import { Maximize2 } from 'lucide-svelte'; import { Maximize2 } from "lucide-svelte";
import { thumbSrc, thumbSrcSet, videoUrl } from '$lib/stores/session.svelte'; import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
import { view } from '$lib/stores/view.svelte'; import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
interface Props { interface Props {
photo: PpPhoto; photo: PpPhoto;
selected: boolean; selected: boolean;
onClick: (e: MouseEvent) => void; onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void; onDblclick: (e: MouseEvent) => void;
onOpenPreview: () => void; onOpenPreview: () => void;
} }
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props(); let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash); const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
const video = $derived(isVideo(photo)); const video = $derived(isVideo(photo));
// Hover preview: PhotoPrism plays a muted, looping preview of the actual // 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 // 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 // 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 // doesn't kick off N HTTP requests, then we cross-fade in once the
// first frame is decoded (videoReady). // first frame is decoded (videoReady).
const HOVER_DELAY = 250; const HOVER_DELAY = 250;
let hoverPlaying = $state(false); let hoverPlaying = $state(false);
let videoReady = $state(false); let videoReady = $state(false);
let hoverTimer: ReturnType<typeof setTimeout> | null = null; let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function onMouseEnter() { function onMouseEnter() {
if (!video || selected) return; if (!video || selected) return;
if (hoverTimer) clearTimeout(hoverTimer); if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => { hoverTimer = setTimeout(() => {
hoverPlaying = true; hoverPlaying = true;
}, HOVER_DELAY); }, HOVER_DELAY);
} }
function onMouseLeave() { function onMouseLeave() {
if (hoverTimer) { if (hoverTimer) {
clearTimeout(hoverTimer); clearTimeout(hoverTimer);
hoverTimer = null; hoverTimer = null;
} }
hoverPlaying = false; hoverPlaying = false;
videoReady = false; videoReady = false;
} }
// Render-size hint for the browser's srcset picker. `view.thumbnailSize` // 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 // 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 // wider when the grid stretches to fill the column, but `tile_*` is
// discrete (100/224/500) so the rounding-up at the next variant // discrete (100/224/500) so the rounding-up at the next variant
// boundary swallows the difference. // boundary swallows the difference.
const tilePx = $derived(view.thumbnailSize); const tilePx = $derived(view.thumbnailSize);
const src1x = $derived(thumbSrc(hash, tilePx)); const src1x = $derived(thumbSrc(hash, tilePx));
const srcset = $derived(thumbSrcSet(hash, tilePx)); const srcset = $derived(thumbSrcSet(hash, tilePx));
</script> </script>
<!-- <!--
@@ -74,12 +74,12 @@
in <button> is invalid HTML. in <button> is invalid HTML.
--> -->
<div <div
class="group relative h-full w-full" class="group relative h-full w-full"
onmouseenter={onMouseEnter} onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave} onmouseleave={onMouseLeave}
role="presentation" role="presentation"
> >
<!-- <!--
Selection animation: scale to 90% + blue ring + blue tint overlay, 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. 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 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, The keyboard-focused tile gets the same treatment as a selected one,
so the arrow-key cursor reads as a "selection of one". so the arrow-key cursor reads as a "selection of one".
--> -->
<button <button
type="button" type="button"
data-tile data-tile
data-uid={photo.UID} data-uid={photo.UID}
onclick={onClick} onclick={onClick}
ondblclick={onDblclick} ondblclick={onDblclick}
title="Click to select · Double-click to open" class:scale-90={selected}
class:scale-90={selected} class:ring-2={selected}
class:ring-2={selected} class:ring-blue-500={selected}
class:ring-blue-500={selected} class:ring-offset-2={selected}
class:ring-offset-2={selected} class:ring-offset-background={selected}
class:ring-offset-background={selected} class:transition-[transform,box-shadow]={selected}
class:transition-[transform,box-shadow]={selected} class:duration-300={selected}
class:duration-300={selected} class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={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"
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none" >
> <img
<img src={src1x}
src={src1x} {srcset}
srcset={srcset} alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? "Photo"}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'} loading="lazy"
loading="lazy" decoding="async"
decoding="async" class="h-full w-full object-cover"
class="h-full w-full object-cover" class:transition={!selected}
class:transition={!selected} class:group-hover:scale-105={!selected}
class:group-hover:scale-105={!selected} />
/> {#if hoverPlaying}
{#if hoverPlaying} <!--
<!--
Muted hover-preview video stacked over the thumbnail. We don't Muted hover-preview video stacked over the thumbnail. We don't
unmount the <img> underneath — the video fades in once its unmount the <img> underneath — the video fades in once its
first frame is decoded, so the tile never goes blank during first frame is decoded, so the tile never goes blank during
the network round-trip. `pointer-events-none` keeps clicks the network round-trip. `pointer-events-none` keeps clicks
flowing through to the parent <button>. flowing through to the parent <button>.
--> -->
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video <video
src={videoUrl(hash)} src={videoUrl(hash)}
autoplay autoplay
muted muted
loop loop
playsinline playsinline
preload="auto" preload="auto"
tabindex={-1} tabindex={-1}
oncanplay={() => (videoReady = true)} oncanplay={() => (videoReady = true)}
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200" class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
class:opacity-0={!videoReady} class:opacity-0={!videoReady}
class:opacity-100={videoReady} class:opacity-100={videoReady}
></video> ></video>
{/if} {/if}
{#if selected} {#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div> <div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if} {/if}
{#if isVideo(photo)} {#if isVideo(photo)}
<span <span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground" class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span >VIDEO</span
> >
{/if} {/if}
</button> </button>
<!-- <!--
Hover-only "open preview" affordance. Single-click on this icon Hover-only "open preview" affordance. Single-click on this icon
opens the preview directly, giving users a one-click fallback for opens the preview directly, giving users a one-click fallback for
the dblclick gesture (and a visual cue that previewing is a thing 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 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. intent on a tile the user is in the middle of bulk-acting on.
--> -->
{#if !selected} {#if !selected}
<button <button
type="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" 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) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenPreview(); onOpenPreview();
}} }}
title="Open preview" title="Open preview"
aria-label="Open preview" aria-label="Open preview"
> >
<Maximize2 class="h-3 w-3" /> <Maximize2 class="h-3 w-3" />
</button> </button>
{/if} {/if}
</div> </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; thumbnailSize?: ThumbnailSize;
leftSidebarWidth?: number; leftSidebarWidth?: number;
rightSidebarWidth?: number; rightSidebarWidth?: number;
previewPaneHeight?: number;
/** /**
* Per-section expanded state for the right-sidebar metadata panel * Per-section expanded state for the right-sidebar metadata panel
* (GPS, Credits, File). Keyed by section id; missing entries use a * (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 MIN_RIGHT_WIDTH = 220;
export const MAX_RIGHT_WIDTH = 480; export const MAX_RIGHT_WIDTH = 480;
export const DEFAULT_RIGHT_WIDTH = 280; 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 { function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n)); return Math.min(hi, Math.max(lo, n));
@@ -69,6 +78,7 @@ export const view = $state<{
thumbnailSize: ThumbnailSize; thumbnailSize: ThumbnailSize;
leftSidebarWidth: number; leftSidebarWidth: number;
rightSidebarWidth: number; rightSidebarWidth: number;
previewPaneHeight: number;
metadataSections: Record<string, boolean>; metadataSections: Record<string, boolean>;
}>({ }>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
@@ -86,6 +96,12 @@ export const view = $state<{
MIN_RIGHT_WIDTH, MIN_RIGHT_WIDTH,
MAX_RIGHT_WIDTH MAX_RIGHT_WIDTH
), ),
previewPaneHeight: Math.max(
MIN_PREVIEW_HEIGHT,
typeof initial.previewPaneHeight === 'number'
? initial.previewPaneHeight
: DEFAULT_PREVIEW_HEIGHT
),
metadataSections: metadataSections:
initial.metadataSections && typeof initial.metadataSections === 'object' initial.metadataSections && typeof initial.metadataSections === 'object'
? { ...initial.metadataSections } ? { ...initial.metadataSections }
@@ -100,6 +116,7 @@ function persist(): void {
thumbnailSize: view.thumbnailSize, thumbnailSize: view.thumbnailSize,
leftSidebarWidth: view.leftSidebarWidth, leftSidebarWidth: view.leftSidebarWidth,
rightSidebarWidth: view.rightSidebarWidth, rightSidebarWidth: view.rightSidebarWidth,
previewPaneHeight: view.previewPaneHeight,
metadataSections: view.metadataSections metadataSections: view.metadataSections
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -115,6 +132,14 @@ export function setRightSidebarWidth(px: number): void {
persist(); 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 { export function setThumbnailSize(size: ThumbnailSize): void {
view.thumbnailSize = size; view.thumbnailSize = size;
persist(); persist();

View File

@@ -4,7 +4,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import type { Component } from 'svelte';
import { QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner'; import { Toaster } from 'svelte-sonner';
@@ -13,7 +12,6 @@
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte'; import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
import { resizable } from '$lib/actions/resizable'; import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { preview } from '$lib/stores/preview.svelte';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte'; import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte'; import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte'; import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
@@ -56,22 +54,6 @@
else stopIndexerWatch(); 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> </script>
<svelte:head> <svelte:head>
@@ -129,7 +111,4 @@
{:else} {:else}
{@render children?.()} {@render children?.()}
{/if} {/if}
{#if PreviewOverlay}
<PreviewOverlay />
{/if}
</QueryClientProvider> </QueryClientProvider>

View File

@@ -33,7 +33,6 @@
setFocused, setFocused,
setOrder, setOrder,
} from "$lib/stores/selection.svelte"; } from "$lib/stores/selection.svelte";
import { openPreview, preview } from "$lib/stores/preview.svelte";
import { import {
setRightSidebarWidth, setRightSidebarWidth,
setThumbnailSize, setThumbnailSize,
@@ -51,9 +50,11 @@
} from "$lib/actions/visibleRange"; } from "$lib/actions/visibleRange";
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte"; import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.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 PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.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 Toolbar from "$lib/components/layout/Toolbar.svelte";
import { type PpPhoto } from "$lib/types/photoprism"; import { type PpPhoto } from "$lib/types/photoprism";
@@ -408,25 +409,6 @@
if (el) scrollTileIntoView(el); 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 ────────────────────────────────── // ── Visual rows for keyboard navigation ──────────────────────────────────
// The CSS Grid lays each photo into a cell with column count derived from // The CSS Grid lays each photo into a cell with column count derived from
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the // `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
@@ -675,24 +657,19 @@
} }
function onTileDblclick(e: MouseEvent, uid: string) { 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; if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault(); e.preventDefault();
openPreview( selection.ids.clear();
uid, selection.ids.add(uid);
photos.map((p) => p.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) { function onTileOpenPreview(uid: string) {
openPreview( selection.ids.clear();
uid, selection.ids.add(uid);
photos.map((p) => p.UID), setFocused(uid);
); setAnchor(uid);
} }
// Scroll root for the infinite-scroll IntersectionObserver. Bound by // 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. sibling at row level and stays full height when the bar appears.
--> -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden"> <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 <main
bind:this={scrollRoot} bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none" class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -952,6 +934,8 @@
{/if} {/if}
</div> </div>
</main> </main>
{/snippet}
</SplitGrid>
<BulkActionBar /> <BulkActionBar />
</div> </div>

View File

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

View File

@@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state'; 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 // Deep-link entry. The route renders a full-page InlinePreview keyed
// the timeline. The route itself does not render anything; it hands // on the URL `uid` so the link stays shareable and reload-safe. No
// off to the global PreviewOverlay and redirects to `/` so the URL // surrounding grid here — this surface is a single-photo viewer.
// stays clean and the timeline shows behind the modal. const uid = $derived((page.params.uid ?? null) as string | null);
$effect(() => { const order = $derived(uid ? [uid] : []);
const uid = page.params.uid as string | undefined;
if (!uid) return;
openPreview(uid);
void goto('/', { replaceState: true });
});
</script> </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 BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.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 DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab; type Tab = CauseKey | DupTab;
@@ -224,31 +226,38 @@
{:else} {:else}
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col"> <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={{}}> <SplitGrid>
{#if reviewQuery.isPending} {#snippet preview()}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <InlinePreview uid={selection.focused} order={selection.order} />
{:else if reviewQuery.error} {/snippet}
<p class="text-sm text-destructive"> {#snippet grid()}
Could not load review queue: {reviewQuery.error instanceof Error <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
? reviewQuery.error.message {#if reviewQuery.isPending}
: 'unknown error'} <p class="text-sm text-muted-foreground">Loading review queue…</p>
</p> {:else if reviewQuery.error}
{:else if groups.length === 0} <p class="text-sm text-destructive">
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> Could not load review queue: {reviewQuery.error instanceof Error
<p>The review queue is empty.</p> ? reviewQuery.error.message
<p class="text-xs"> : 'unknown error'}
PhotoPrism's indexer flags photos with a low quality score for human </p>
review. New arrivals with missing EXIF, low resolution, or unknown {:else if groups.length === 0}
cameras will land here. The Stacks and Cross-folder tabs above stay <div class="max-w-prose space-y-2 text-sm text-muted-foreground">
available for duplicate cleanup. <p>The review queue is empty.</p>
</p> <p class="text-xs">
</div> PhotoPrism's indexer flags photos with a low quality score for human
{:else if activeGroup} review. New arrivals with missing EXIF, low resolution, or unknown
{#key activeGroup.cause} cameras will land here. The Stacks and Cross-folder tabs above stay
<CauseGroupCard group={activeGroup} /> available for duplicate cleanup.
{/key} </p>
{/if} </div>
</main> {:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
{/snippet}
</SplitGrid>
<BulkActionBar /> <BulkActionBar />
</div> </div>

View File

@@ -4,6 +4,7 @@
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { import {
aggregateKeywords, aggregateKeywords,
countPhotos,
getAllMarks, getAllMarks,
listLabels, listLabels,
listPhotos, listPhotos,
@@ -11,13 +12,16 @@
type PhotoMarksMap, type PhotoMarksMap,
type PpLabel type PpLabel
} from '$lib/services/photoprism'; } 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 { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav'; import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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 PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.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 Toolbar from '$lib/components/layout/Toolbar.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap // Tag-flavoured surfaces, all under one route so the user can swap
@@ -88,6 +92,25 @@
enabled: isAuthenticated() 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[]>(() => ({ const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'], queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords, queryFn: aggregateKeywords,
@@ -186,12 +209,13 @@
} }
// ── Per-tab badge counts ───────────────────────────────────────────────── // ── Per-tab badge counts ─────────────────────────────────────────────────
// Each tab pill shows what its grid covers: distinct labels/keywords for // Each pill shows the number of *photos* a tab covers (not categories),
// the bucket-style tabs, photo-count for the fixed-cardinality ones // so all four tabs read on the same scale and match the sidebar's Tags
// (ratings/colors), matching how the sidebar's Tags badge aggregates. // 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 // `undefined` means the underlying query hasn't resolved yet — the badge
// is skipped rather than showing a misleading 0. // 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 keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
const ratedPhotosCount = $derived<number | undefined>( const ratedPhotosCount = $derived<number | undefined>(
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
@@ -367,24 +391,37 @@
{/snippet} {/snippet}
</Toolbar> </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 <main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none" class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}} use:gridKeyNav={{}}
> >
{#if drillKey} {#if activeTab === 'labels'}
<!-- 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 labelsQuery.isPending} {#if labelsQuery.isPending}
<SkeletonGrid /> <SkeletonGrid />
{:else if labelsQuery.isError} {:else if labelsQuery.isError}
@@ -546,5 +583,6 @@
{/if} {/if}
{/if} {/if}
</main> </main>
{/if}
<BulkActionBar /> <BulkActionBar />