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>
160 lines
5.1 KiB
Svelte
160 lines
5.1 KiB
Svelte
<!--
|
|
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>
|