preview: full-screen modal replaces inline split + tags route reorg
The old SplitGrid + InlinePreview pane is replaced by a full-screen PreviewModal mounted once at the layout root. Open via Space on the focused tile or double-click; close on Esc (or X / Space again). Inside, PreviewPane renders the focused photo, RightSidebar carries the metadata, BulkActionBar reuses the existing per-photo actions, and PreviewCarousel windows ±50 thumbs around the focused index. Selection contract matches the grid: plain click reduces, shift extends the range, ⌘/Ctrl toggles, plain arrow drops the multi- selection, shift-arrow extends. New clearBulkToFirst() helper makes Esc / Clear collapse a bulk back to single-focus on its first member before the next press fully dismisses (modal closes, grid clears focus). Tags route reorganised into /tags/[category]/[[value]] with its own +layout and TagsBrowserSidebar; the old monolithic /tags/+page is trimmed to a legacy redirect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
159
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
159
web/src/lib/components/preview/PreviewCarousel.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
Bottom filmstrip for the full-screen PreviewModal. Walks the same
|
||||
`selection.order` list the host view uses, so the user steps through
|
||||
their current context (timeline, drill-in, etc.) without surprises.
|
||||
|
||||
Windowed: only renders a slice of ±WINDOW around the focused index, so
|
||||
a 10k-photo timeline doesn't paint 10k thumbs. The slice shifts as
|
||||
focus moves; the strip re-scrolls the focused tile into view on every
|
||||
change.
|
||||
|
||||
Thumbnails are resolved from TanStack's cache so this surface stays
|
||||
side-effect-free (no extra fetches just to draw a strip).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
selectOnly,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
/** Tiles either side of focus to render. ±50 = ~100 thumbs in DOM at
|
||||
* any time, plenty to cover normal arrow-skim without bloating. */
|
||||
const WINDOW = 50;
|
||||
|
||||
function lookup(uid: string): string | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const order = $derived(selection.order);
|
||||
const focused = $derived(selection.focused);
|
||||
const focusedIdx = $derived(focused ? order.indexOf(focused) : -1);
|
||||
|
||||
interface Tile {
|
||||
uid: string;
|
||||
hash: string | null;
|
||||
idx: number;
|
||||
}
|
||||
const slice = $derived.by<Tile[]>(() => {
|
||||
if (focusedIdx < 0) return [];
|
||||
const lo = Math.max(0, focusedIdx - WINDOW);
|
||||
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
|
||||
const out: Tile[] = [];
|
||||
for (let i = lo; i < hi; i++) {
|
||||
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Scroll the focused tile into the centre of the strip whenever
|
||||
// `focused` changes. We key the scroll on `data-uid` so the lookup
|
||||
// survives slice re-renders.
|
||||
let stripEl: HTMLDivElement | null = $state(null);
|
||||
$effect(() => {
|
||||
const uid = focused;
|
||||
if (!uid || !stripEl) return;
|
||||
// Defer until after the slice re-renders — without this, the
|
||||
// querySelector on the freshly added tile element returns null.
|
||||
queueMicrotask(() => {
|
||||
const el = stripEl?.querySelector<HTMLElement>(`[data-strip-uid="${uid}"]`);
|
||||
el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-aware click: shift extends the range from the anchor, ⌘/Ctrl
|
||||
* toggles in/out of the multi-selection, plain click reduces to this
|
||||
* tile. Same semantics as the grid's PhotoTile + gridKeyNav click
|
||||
* handler, so the carousel reads as a continuation of the grid rather
|
||||
* than a separate surface.
|
||||
*/
|
||||
function onTileClick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
return;
|
||||
}
|
||||
selectOnly(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={stripEl}
|
||||
class="flex h-20 shrink-0 items-center gap-1 overflow-x-auto overflow-y-hidden border-t border-border bg-background/80 px-2 py-1.5 backdrop-blur"
|
||||
>
|
||||
{#each slice as tile (tile.uid)}
|
||||
{@const isFocused = tile.uid === focused}
|
||||
{@const isSelected = isFocused || selection.ids.has(tile.uid)}
|
||||
<!--
|
||||
Mirror PhotoTile's selected styling so the focused tile in the
|
||||
strip reads identically to a selected tile in the grid: the
|
||||
springy scale-90, the blue ring with background offset, and
|
||||
the blue tint overlay. Keeps the visual language consistent
|
||||
as the user moves between grid and modal.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
data-strip-uid={tile.uid}
|
||||
onclick={(e) => onTileClick(e, tile.uid)}
|
||||
aria-label={`Photo ${tile.idx + 1} of ${order.length}`}
|
||||
aria-current={isFocused ? 'true' : undefined}
|
||||
class:scale-90={isSelected}
|
||||
class:ring-2={isSelected}
|
||||
class:ring-blue-500={isSelected}
|
||||
class:ring-offset-2={isSelected}
|
||||
class:ring-offset-background={isSelected}
|
||||
class:transition-[transform,box-shadow]={isSelected}
|
||||
class:duration-300={isSelected}
|
||||
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={isSelected}
|
||||
class="relative h-full aspect-square shrink-0 overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
{#if tile.hash}
|
||||
<img
|
||||
src={thumbUrl(tile.hash, 'tile_100')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{#if isSelected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
168
web/src/lib/components/preview/PreviewModal.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
Full-screen preview modal. Mounted once at the layout root so every
|
||||
route (timeline, /tags, future grids) can open it via Space or a
|
||||
double-click on a tile. Reads from the global selection store:
|
||||
|
||||
- selection.focused → which photo to display
|
||||
- selection.order → walked left/right + drives PreviewCarousel
|
||||
|
||||
Layout: preview pane (left, fills) + RightSidebar (right) + action
|
||||
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
|
||||
Esc) is owned here; gridKeyNav bails out of those keys while the modal
|
||||
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
|
||||
to flow through gridKeyNav since they target selection state, which
|
||||
the modal shares.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { closePreview, view } from '$lib/stores/view.svelte';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import PreviewPane from './PreviewPane.svelte';
|
||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
// Fetch the focused photo so the RightSidebar always has full
|
||||
// metadata even when the user jumped here from a list that only
|
||||
// hydrated thumbnails.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', focusedUid ?? ''],
|
||||
queryFn: () => getPhoto(focusedUid as string),
|
||||
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
|
||||
}));
|
||||
|
||||
function step(delta: number, extending: boolean) {
|
||||
const order = selection.order;
|
||||
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
|
||||
if (cur < 0) return;
|
||||
const next = order[cur + delta];
|
||||
if (!next) return;
|
||||
// Plain arrow drops any prior multi-selection down to the cursor —
|
||||
// otherwise the previously selected tiles keep their blue ring and
|
||||
// the carousel reads as two simultaneously selected images (the old
|
||||
// ones still in ids, plus the freshly focused one). Mirrors
|
||||
// gridKeyNav.moveFocus's contract.
|
||||
if (!extending) clearSelection();
|
||||
setFocused(next);
|
||||
if (extending) selectRange(next);
|
||||
else setAnchor(next);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Don't steal keys from inputs inside the sidebar (editable
|
||||
// metadata fields, keyword chips, etc.).
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
// stopImmediatePropagation prevents gridKeyNav's window-level
|
||||
// handler from firing afterwards — without it, closing on Space
|
||||
// would set previewOpen=false and then gridKeyNav's Space branch
|
||||
// would re-open the modal because selection.focused is still set.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
// First Esc collapses a bulk back to single-focus on its first
|
||||
// member, keeping the modal open so the user can keep working.
|
||||
// Second Esc (no bulk left) closes the modal.
|
||||
if (clearBulkToFirst()) return;
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(-1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(1, e.shiftKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind a window-level handler only while the modal is open. The
|
||||
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
|
||||
// keys we own (arrows, Esc, Space) without having to coordinate
|
||||
// listener order.
|
||||
$effect(() => {
|
||||
if (!view.previewOpen) return;
|
||||
const handler = (e: KeyboardEvent) => onKeydown(e);
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={view.previewOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closePreview();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
>
|
||||
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
|
||||
onclick={closePreview}
|
||||
aria-label="Close preview"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
</div>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -1,34 +1,29 @@
|
||||
<!--
|
||||
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.
|
||||
Inner preview surface used by the full-screen PreviewModal and the
|
||||
shareable /photo/[uid] deep-link route. Renders the focused photo or
|
||||
video, with optional prev/next chevrons that walk `order` via
|
||||
setFocused.
|
||||
|
||||
Video mounting is debounced 250 ms so arrow-skim across a stretch of
|
||||
video tiles doesn't open (and immediately cancel) range requests we'd
|
||||
throw away. Until the timer fires, the poster image stands in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { selection, setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||
import SelectionDeck from '$lib/components/preview/SelectionDeck.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[];
|
||||
/** Hide the floating prev/next chevrons (callers that wrap their
|
||||
* own nav can disable to avoid duplication). */
|
||||
showChevrons?: boolean;
|
||||
}
|
||||
let { uid, order }: Props = $props();
|
||||
|
||||
// Multi-select swaps the single-photo pane for a fanned deck of the
|
||||
// selected thumbnails. Reads `selection.ids` directly (already in
|
||||
// scope via the import) so the host doesn't need to thread it
|
||||
// through as a prop. Spreading SvelteSet preserves insertion order,
|
||||
// so the most recently picked card sits on top of the fan.
|
||||
const selectedUids = $derived([...selection.ids]);
|
||||
const multiSelect = $derived(selectedUids.length >= 2);
|
||||
let { uid, order, showChevrons = true }: Props = $props();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
@@ -38,20 +33,10 @@
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
// Debounce window before a focused video actually mounts <VideoPlayer>
|
||||
// and opens an HTTP range request. Short enough that a deliberate
|
||||
// click feels instant; long enough that arrow-skim across video tiles
|
||||
// never opens (and immediately cancels) a stream we'd have thrown
|
||||
// away anyway. The grid's thumbnail traffic is the thing this
|
||||
// protects.
|
||||
const VIDEO_LOAD_DELAY_MS = 250;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
// Re-arm on every uid change. Until the timer fires, the template
|
||||
// renders the poster image instead of <VideoPlayer>, so no video
|
||||
// fetch is issued. If uid changes again before the 250 ms is up,
|
||||
// the cleanup clears the pending timer and the new one takes over.
|
||||
const target = uid;
|
||||
if (!target) {
|
||||
armedUid = null;
|
||||
@@ -71,10 +56,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
|
||||
{#if multiSelect}
|
||||
<SelectionDeck uids={selectedUids} />
|
||||
{:else if uid === null}
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 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>
|
||||
@@ -82,7 +65,7 @@
|
||||
<p class="text-sm text-destructive">Failed to load photo.</p>
|
||||
{:else if photoQuery.data}
|
||||
{@const pf = primaryFile(photoQuery.data)}
|
||||
{#if currentIndex > 0}
|
||||
{#if showChevrons && 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)}
|
||||
@@ -91,7 +74,7 @@
|
||||
‹
|
||||
</button>
|
||||
{/if}
|
||||
{#if currentIndex >= 0 && currentIndex < order.length - 1}
|
||||
{#if showChevrons && 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)}
|
||||
@@ -104,10 +87,6 @@
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#if armedUid === uid}
|
||||
<!-- 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)}
|
||||
@@ -116,9 +95,6 @@
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Debounce window: render the poster only. Matches the
|
||||
still-photo branch's styling so the pane reads identically
|
||||
until <VideoPlayer> arms in. -->
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
||||
@@ -1,112 +0,0 @@
|
||||
<!--
|
||||
Multi-select deck. Renders the selected thumbnails as a fanned spread
|
||||
of cards in the inline preview pane. Each new selection flies onto
|
||||
the deck; each removal shrinks out. Layout reflow on add/remove is
|
||||
driven by CSS transforms transitioning between the recomputed fan
|
||||
positions.
|
||||
|
||||
Hashes come from the per-photo TanStack cache (populated by the
|
||||
timeline + the right-sidebar's getPhoto query); we walk the photos-
|
||||
infinite-query cache as a fallback for photos the user selected
|
||||
before the right sidebar had a chance to fetch them. Anything not
|
||||
found is skipped silently — the deck just renders the resolvable
|
||||
subset, which keeps this surface side-effect-free (no fetches just
|
||||
to draw a thumbnail spread).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
uids: string[];
|
||||
}
|
||||
let { uids }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
/** Resolve a uid to its primary-file hash by walking TanStack's caches.
|
||||
* Order: per-photo cache first (cheapest), then any infinite-query
|
||||
* entry under `['photos', …]` — both shapes (flat list and Infinite-
|
||||
* Data envelope) are handled because the timeline uses the envelope
|
||||
* while pools/sidebars use the flat list. Mirrors the cachedPhoto
|
||||
* helper in gridKeyNav.ts. */
|
||||
function lookup(uid: string): string | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Card {
|
||||
uid: string;
|
||||
hash: string;
|
||||
}
|
||||
const cards = $derived<Card[]>(
|
||||
uids
|
||||
.map((uid) => {
|
||||
const hash = lookup(uid);
|
||||
return hash ? { uid, hash } : null;
|
||||
})
|
||||
.filter((c): c is Card => c !== null)
|
||||
);
|
||||
|
||||
/** Fan geometry. The spread tightens as the deck grows so 20 cards
|
||||
* still fit visually; small selections (2–4) get wide-stance spreads
|
||||
* so each card reads as its own surface. The `mid`-relative offset
|
||||
* keeps the deck centred regardless of count. */
|
||||
function transform(i: number, n: number): string {
|
||||
if (n <= 1) return 'translateX(0) rotate(0deg)';
|
||||
const mid = (n - 1) / 2;
|
||||
const off = i - mid;
|
||||
// Tighter angle + offset as N grows; cap so very large selections
|
||||
// don't degenerate to a single overlapping pile or, conversely,
|
||||
// spread past the pane edges.
|
||||
const angleStep = Math.min(9, 30 / n);
|
||||
const transStep = Math.min(60, 220 / n);
|
||||
const angle = off * angleStep;
|
||||
const tx = off * transStep;
|
||||
return `translateX(${tx}px) rotate(${angle}deg)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center overflow-hidden">
|
||||
{#if cards.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Selection has no resolvable thumbnails yet.</p>
|
||||
{/if}
|
||||
{#each cards as card, i (card.uid)}
|
||||
<img
|
||||
src={thumbUrl(card.hash, 'fit_720')}
|
||||
alt=""
|
||||
class="absolute max-h-[80%] max-w-[60%] rounded-lg object-contain shadow-2xl ring-1 ring-black/40 transition-transform duration-300 ease-out"
|
||||
style="transform: {transform(i, cards.length)}; z-index: {i};"
|
||||
in:fly={{ y: -180, duration: 320, easing: cubicOut }}
|
||||
out:scale={{ start: 0.5, duration: 220, easing: cubicOut }}
|
||||
/>
|
||||
{/each}
|
||||
<!-- Count chip so the user can see at a glance how many photos are
|
||||
in the bulk selection without counting cards. -->
|
||||
{#if cards.length > 0}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-3 right-3 rounded-full bg-background/85 px-2.5 py-1 text-xs font-medium shadow"
|
||||
>
|
||||
{cards.length} selected
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,59 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -106,10 +106,10 @@
|
||||
:global(.vds-player) {
|
||||
/* 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. */
|
||||
was stretching widescreen video into the square 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 its host pane. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--media-brand: #3b82f6;
|
||||
|
||||
Reference in New Issue
Block a user