Preview pane paints instantly: a blurred copy of the same thumbnail the grid loaded (cache hit) rides beneath the sharp fit_1280, which now carries fetchpriority=high and decoding=async. A $effect prefetches fit_1280 for the ±2 neighbours so arrow-skim hits the HTTP cache. Carousel thumbs drop to fetchpriority=low so they yield to the main image. Skeleton grid gains an mt-2 to breathe against the toolbar. BulkActionBar moves inside the main column in both PreviewModal and the /tags drill-in so it no longer stretches under the right sidebar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
5.9 KiB
Svelte
175 lines
5.9 KiB
Svelte
<!--
|
||
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, useQueryClient } from '@tanstack/svelte-query';
|
||
import { getPhoto } from '$lib/services/photoprism';
|
||
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||
import { view } from '$lib/stores/view.svelte';
|
||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
|
||
|
||
interface Props {
|
||
uid: string | null;
|
||
order: string[];
|
||
/** Hide the floating prev/next chevrons (callers that wrap their
|
||
* own nav can disable to avoid duplication). */
|
||
showChevrons?: boolean;
|
||
}
|
||
let { uid, order, showChevrons = true }: Props = $props();
|
||
|
||
const qc = useQueryClient();
|
||
|
||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||
queryKey: ['photo', uid ?? ''],
|
||
queryFn: () => getPhoto(uid as string),
|
||
enabled: Boolean(uid)
|
||
}));
|
||
|
||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||
|
||
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
|
||
* cache without firing a fetch, so we can resolve adjacent hashes for
|
||
* prefetching without making the prefetch itself trigger more work. */
|
||
function lookupCached(target: string): PpPhoto | null {
|
||
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
|
||
if (direct) return direct;
|
||
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 === target);
|
||
if (hit) return hit;
|
||
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 === target);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
|
||
// arrow-skim feels instant. We `new Image()` rather than `<link
|
||
// rel=preload>` because the URLs are runtime-derived and a throwaway
|
||
// Image() reuses the browser's HTTP cache the same way.
|
||
$effect(() => {
|
||
if (!uid || currentIndex < 0) return;
|
||
for (const offset of [-1, 1, -2, 2]) {
|
||
const idx = currentIndex + offset;
|
||
if (idx < 0 || idx >= order.length) continue;
|
||
const photo = lookupCached(order[idx]);
|
||
if (!photo) continue;
|
||
const hash = primaryFile(photo).Hash;
|
||
if (!hash) continue;
|
||
const img = new Image();
|
||
img.src = thumbUrl(hash, 'fit_1280');
|
||
}
|
||
});
|
||
|
||
const VIDEO_LOAD_DELAY_MS = 250;
|
||
let armedUid = $state<string | null>(null);
|
||
|
||
$effect(() => {
|
||
const target = uid;
|
||
if (!target) {
|
||
armedUid = null;
|
||
return;
|
||
}
|
||
const t = setTimeout(() => {
|
||
armedUid = target;
|
||
}, VIDEO_LOAD_DELAY_MS);
|
||
return () => clearTimeout(t);
|
||
});
|
||
|
||
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/40 p-4">
|
||
{#if uid === null}
|
||
<EmptyState icon={ImageIcon} title="Select a photo to preview" />
|
||
{:else if photoQuery.isPending}
|
||
<InlineLoader label="Loading photo…" align="center" />
|
||
{:else if photoQuery.isError}
|
||
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
|
||
{:else if photoQuery.data}
|
||
{@const pf = primaryFile(photoQuery.data)}
|
||
{#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)}
|
||
aria-label="Previous photo"
|
||
>
|
||
‹
|
||
</button>
|
||
{/if}
|
||
{#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)}
|
||
aria-label="Next photo"
|
||
>
|
||
›
|
||
</button>
|
||
{/if}
|
||
|
||
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||
{@const vf = videoFile(photoQuery.data)}
|
||
{#key vf.Hash}
|
||
<VideoPlayer
|
||
src={videoUrl(vf.Hash)}
|
||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||
/>
|
||
{/key}
|
||
{:else}
|
||
{@const altText =
|
||
photoQuery.data.OriginalName ??
|
||
pf.Name ??
|
||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||
{#if pf.Width && pf.Height}
|
||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||
the tile_*'s square center-crop against the sharp image's
|
||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||
lands in the exact same bounding box as the sharp <img>
|
||
beside it (object-contain semantics, but expressible on a
|
||
positioned element). Paints from the HTTP cache the moment
|
||
the modal opens. -->
|
||
<img
|
||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||
alt=""
|
||
aria-hidden="true"
|
||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||
/>
|
||
{/if}
|
||
<img
|
||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||
alt={altText}
|
||
fetchpriority="high"
|
||
decoding="async"
|
||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||
/>
|
||
{/if}
|
||
{/if}
|
||
</div>
|