preview: LQIP + ±2 prefetch + bound action bar to its column
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>
This commit is contained in:
@@ -155,6 +155,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -133,7 +133,11 @@
|
|||||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|
||||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
<!-- Top row: preview pane (fills) + sidebar (fixed width).
|
||||||
|
BulkActionBar lives inside the main column — same shape as the
|
||||||
|
timeline (+page.svelte) so the bar stays bounded by the
|
||||||
|
column's width and doesn't stretch under the metadata
|
||||||
|
sidebar. -->
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||||
<button
|
<button
|
||||||
@@ -148,6 +152,8 @@
|
|||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||||
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
{#if focusedPhotoQuery.data}
|
{#if focusedPhotoQuery.data}
|
||||||
<aside
|
<aside
|
||||||
@@ -158,9 +164,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|
||||||
<!-- Bottom filmstrip across selection.order. -->
|
<!-- Bottom filmstrip across selection.order. -->
|
||||||
<PreviewCarousel />
|
<PreviewCarousel />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
throw away. Until the timer fires, the poster image stands in.
|
throw away. Until the timer fires, the poster image stands in.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||||
import { setAnchor, setFocused } from '$lib/stores/selection.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 VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
}
|
}
|
||||||
let { uid, order, showChevrons = true }: Props = $props();
|
let { uid, order, showChevrons = true }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||||
queryKey: ['photo', uid ?? ''],
|
queryKey: ['photo', uid ?? ''],
|
||||||
queryFn: () => getPhoto(uid as string),
|
queryFn: () => getPhoto(uid as string),
|
||||||
@@ -35,6 +38,48 @@
|
|||||||
|
|
||||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
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;
|
const VIDEO_LOAD_DELAY_MS = 250;
|
||||||
let armedUid = $state<string | null>(null);
|
let armedUid = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -86,9 +131,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isVideo(photoQuery.data)}
|
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||||||
{@const vf = videoFile(photoQuery.data)}
|
{@const vf = videoFile(photoQuery.data)}
|
||||||
{#if armedUid === uid}
|
|
||||||
{#key vf.Hash}
|
{#key vf.Hash}
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src={videoUrl(vf.Hash)}
|
src={videoUrl(vf.Hash)}
|
||||||
@@ -97,17 +141,33 @@
|
|||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
{: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
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
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}
|
{/if}
|
||||||
{:else}
|
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
alt={altText}
|
||||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="grid gap-2"
|
class="mt-2 grid gap-2"
|
||||||
style="grid-template-columns: {tracks};"
|
style="grid-template-columns: {tracks};"
|
||||||
>
|
>
|
||||||
{#each Array.from({ length: count }) as _, i (i)}
|
{#each Array.from({ length: count }) as _, i (i)}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@
|
|||||||
<PhotoGrid photos={drillPhotos} />
|
<PhotoGrid photos={drillPhotos} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !view.rightSidebarCollapsed}
|
{#if !view.rightSidebarCollapsed}
|
||||||
@@ -270,5 +271,3 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|||||||
Reference in New Issue
Block a user