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

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