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:
112
web/src/lib/components/preview/PreviewPane.svelte
Normal file
112
web/src/lib/components/preview/PreviewPane.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<!--
|
||||
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 { 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;
|
||||
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 photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
queryFn: () => getPhoto(uid as string),
|
||||
enabled: Boolean(uid)
|
||||
}));
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
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}
|
||||
<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 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)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#if armedUid === uid}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user