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:
2026-05-20 00:13:20 +02:00
parent 680fa90cbe
commit 0d5f380948
24 changed files with 1593 additions and 1068 deletions

View 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>