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

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