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:
89
web/src/lib/components/preview/InlinePreview.svelte
Normal file
89
web/src/lib/components/preview/InlinePreview.svelte
Normal 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>
|
||||
@@ -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}
|
||||
59
web/src/lib/components/preview/SplitGrid.svelte
Normal file
59
web/src/lib/components/preview/SplitGrid.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user