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

@@ -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 />