feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate

Replace the legacy mule-image backend with PhotoPrism plus a thin
SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't
expose (file rename), and add a two-phase migrator (metadata via PUT,
heaps → albums) for the existing Postgres library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

871
web/src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,871 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toggleMode, mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import {
batchDelete,
getPhoto,
listHeaps,
listPhotos,
logout,
type PpAlbum
} from '$lib/services/photoprism';
import {
filters,
filtersToQ,
filtersToUrlParams,
parseUrlParams,
setSearch,
setSection
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, thumbUrl } from '$lib/stores/session.svelte';
import { untrack } from 'svelte';
import {
isSelected,
selectRange,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import {
setRightSidebarWidth,
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS,
view
} from '$lib/stores/view.svelte';
import { tick } from 'svelte';
import { resizable } from '$lib/actions/resizable';
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
import { nearBottom } from '$lib/actions/nearBottom';
import {
visibleRange,
getVisibleRangeHandle,
type VisibleRangeHandle
} from '$lib/actions/visibleRange';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
// ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store.
$effect(() => {
if (!browser) return;
const next = parseUrlParams(page.url.searchParams);
if (next.section !== undefined) filters.section = next.section;
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.search !== undefined) filters.search = next.search;
});
// When the store changes from in-app actions (left-sidebar nav, search
// box, etc.), push the matching query string back so the URL is shareable.
let lastWritten = $state('');
$effect(() => {
if (!browser) return;
const qs = filtersToUrlParams().toString();
const here = page.url.searchParams.toString();
if (qs === here || qs === lastWritten) return;
lastWritten = qs;
void goto(`/${qs ? '?' + qs : ''}`, {
replaceState: true,
keepFocus: true,
noScroll: true
});
});
// ── Section title ────────────────────────────────────────────────────────
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated()
}));
const sectionLabel = $derived(buildSectionLabel());
function buildSectionLabel(): string {
switch (filters.section) {
case 'favorites':
return 'Favorites';
case 'archive':
return 'Archive';
case 'heap': {
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
return heap ? `Heap · ${heap.Title}` : 'Heap';
}
default:
return 'All photos';
}
}
// ── Photo list ───────────────────────────────────────────────────────────
// Infinite scroll. PhotoPrism's `/photos?merged=true` returns a multi-row-
// per-photo shape (HEIC + companion JPG + MOV each count toward `count`),
// so the page size is generous: 120 photo entries per page lands ~250-360
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
// downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120;
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'q', filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
queryFn: ({ pageParam }) =>
listPhotos({
q: filtersToQ(filters),
count: PHOTOS_PAGE_SIZE,
offset: pageParam as number,
order: 'newest',
merged: true
}),
initialPageParam: 0,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
// photo expands into its file rows, so a "full" page of count=120
// typically returns ~60 photo entries. The only reliable end-of-
// pagination signal is an empty page. Costs one extra fetch at the
// tail (cheap; the empty response is small).
getNextPageParam: (last, pages) =>
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
enabled: isAuthenticated()
}));
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
* pages can repeat a photo when its file-row span straddles the offset
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
* preserves order. Downstream (`setOrder`, `rows`, preview, click
* handlers) treat this as the single source of truth. */
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return [];
const seen = new Set<string>();
const out: PpPhoto[] = [];
for (const page of pages) {
for (const p of page) {
if (seen.has(p.UID)) continue;
seen.add(p.UID);
out.push(p);
}
}
return out;
}
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
$effect(() => {
setOrder(photos.map((p) => p.UID));
});
/**
* Auto-focus the first photo on the FIRST page only. Subsequent pages
* append silently — we never want the focus to jump back to the top of
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
* from immediately re-triggering this effect.
*/
$effect(() => {
// Re-read pageCount so the effect bottoms out cleanly on filter
// changes (which reset pageCount back to 0/1).
const pages = pageCount;
untrack(() => {
if (photos.length === 0) {
setFocused(null);
return;
}
// Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0].
if (pages !== 1) return;
const cur = selection.focused;
if (!cur || !photos.some((p) => p.UID === cur)) {
setFocused(photos[0].UID);
}
});
});
/**
* Flatten photos + month headers into a single row list. PhotoPrism
* returns photos pre-sorted by TakenAt; we emit a header row whenever
* the month-key changes, and tile rows for every photo in order. The
* tile's `tileIndex` matches its position in `photos`, which is what
* the windowing observer keys off of (so the visible-range math stays
* one-dimensional even though the template renders headers inline).
*/
type Row =
| { kind: 'header'; key: string; label: string; count: number }
| { kind: 'tile'; photo: PpPhoto; tileIndex: number };
const rows = $derived<Row[]>(buildRows(photos));
function buildRows(list: PpPhoto[]): Row[] {
const out: Row[] = [];
const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric'
});
// First pass: count per month — used by the header chip. Sparse
// Map: O(months) memory, O(photos) time, both trivial at 10k.
const counts = new Map<string, number>();
const labels = new Map<string, string>();
for (const p of list) {
const { key, label } = monthKey(p, fmt);
counts.set(key, (counts.get(key) ?? 0) + 1);
if (!labels.has(key)) labels.set(key, label);
}
// Second pass: emit rows in document order.
let prev = '';
for (let i = 0; i < list.length; i++) {
const p = list[i];
const { key } = monthKey(p, fmt);
if (key !== prev) {
out.push({ kind: 'header', key, label: labels.get(key) ?? '', count: counts.get(key) ?? 0 });
prev = key;
}
out.push({ kind: 'tile', photo: p, tileIndex: i });
}
return out;
}
function monthKey(p: PpPhoto, fmt: Intl.DateTimeFormat): { key: string; label: string } {
const raw = p.TakenAtLocal ?? p.TakenAt ?? '';
if (!raw) return { key: 'no-date', label: 'No date' };
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return { key: 'no-date', label: 'No date' };
const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
return { key: k, label: fmt.format(d) };
}
// Visible-range window. The observer reports `first`/`last` based on
// sampled tiles (every 5th, PhotoPrism's value). We render tiles in
// [first - BUFFER, last + BUFFER] plus any `forcedExpand` index that
// keyboard scrollToIndex has demanded (so a focused tile that's
// currently windowed-out is mounted before scrollIntoView runs).
const TILE_BUFFER = 4;
const TILE_SAMPLE = 5;
// Seed `visLast` generously so the first paint already shows a screen-
// ful of tiles instead of a single buffer of 4. The IntersectionObserver
// narrows it on the next layout pass.
const INITIAL_VIS_LAST = 60;
let visFirst = $state(0);
let visLast = $state(INITIAL_VIS_LAST);
let forcedExpand = $state<number | null>(null);
const renderFirst = $derived(
Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER)
);
const renderLast = $derived(
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
);
// Reset the window when the filter key changes — old indices from the
// previous photo list otherwise pin renderFirst/renderLast outside the
// new shorter list and the grid renders empty. Track `filtersToQ` as
// the trigger; appending pages (which keeps the same filter key) does
// NOT reset, so scroll position stays stable mid-pagination.
$effect(() => {
filtersToQ(filters);
untrack(() => {
visFirst = 0;
visLast = INITIAL_VIS_LAST;
forcedExpand = null;
});
});
// Per-tile register handle exposed by the visibleRange action. The
// host pulls it off the scroll-root node once after mount.
let visHandle: VisibleRangeHandle | null = $state(null);
$effect(() => {
if (scrollRoot) visHandle = getVisibleRangeHandle(scrollRoot);
});
/** `use:tileRegister={i}` — stable-identity Svelte action that hooks
* the tile shell into the visibility observer when it mounts and
* un-hooks it when it unmounts (or when `i` changes because the
* photos array shifted). Using a `use:` action (not `{@attach}`)
* keeps the registration stable across re-renders; `{@attach}` would
* rebuild on every render because the inline arrow has fresh
* identity each time. */
function tileRegister(node: HTMLElement, index: number) {
let current = index;
visHandle?.register(node, current);
return {
update(next: number) {
if (next === current) return;
visHandle?.unregister(node);
current = next;
visHandle?.register(node, current);
},
destroy() {
visHandle?.unregister(node);
}
};
}
/** Scroll the given tile fully into view. The default `scrollIntoView`
* ignores the sticky month header overlaying the top of the grid, so
* arrow-up into the row immediately under a header would leave the
* tile occluded. We compute the visible region manually, subtract
* the sticky header height from the top, and add a 25% "peek" so
* the focused tile lands with breathing room rather than flush
* against the viewport edge — same heuristic mule-image's Timeline
* uses in its `scrollRowIntoView`. */
function scrollTileIntoView(el: HTMLElement) {
if (!scrollRoot) return;
const stickyH = scrollRoot.querySelector<HTMLElement>('h2.sticky')?.offsetHeight ?? 0;
const rootRect = scrollRoot.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
const elBot = elTop + elRect.height;
const peek = Math.round(elRect.height * 0.25);
const viewTop = scrollRoot.scrollTop + stickyH;
const viewBot = scrollRoot.scrollTop + scrollRoot.clientHeight;
if (elTop - peek < viewTop) {
scrollRoot.scrollTo({ top: Math.max(0, elTop - peek - stickyH) });
} else if (elBot + peek > viewBot) {
scrollRoot.scrollTo({ top: elBot + peek - scrollRoot.clientHeight });
}
}
/** Called when arrow-keying lands on a tile that isn't currently
* rendered. Forces the window to include the target, waits for the
* tile shell to mount, then scrolls it fully into view. */
async function scrollToIndex(i: number) {
forcedExpand = i;
await tick();
if (!scrollRoot) return;
const el = scrollRoot.querySelector<HTMLElement>(`[data-uid-shell="${photos[i]?.UID ?? ''}"]`);
if (el) scrollTileIntoView(el);
}
// ── Visual rows for keyboard navigation ──────────────────────────────────
// The CSS Grid lays each photo into a cell with column count derived from
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
// full row, so a new month forces a row break even if the previous month's
// last row had empty slots. Linear "+/-cols" arrow math doesn't account
// for that and crosses headers wrong; mule-image's `useGridKeyNav` solves
// it by operating on an explicit `string[][]` visual-row map. We do the
// same: build the row map from `photos` + `tilesPerRow` + month
// boundaries, then translate arrow keys into (row, col) moves.
/** Cached column count of the photo grid. Measured from
* `grid-template-columns` on the grid element itself (the only place
* with a reliable value) via a Svelte action that runs *after* the
* grid mounts. An earlier $effect-based version observed
* `scrollRoot` and ran before the {#if photos.length > 0} branch
* evaluated, so the grid element didn't exist and `cols` stayed at
* 1 — making the arrow-keyboard nav feel like a 1-column list. */
let cols = $state(1);
let gridEl: HTMLElement | undefined = $state();
function trackGridCols(node: HTMLElement) {
gridEl = node;
const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
// ResizeObserver fires on width changes (sidebar toggle, window
// resize, container reflow). Thumbnail-size changes don't change
// the grid's width but DO change its column count — handled by a
// separate effect below that re-runs `measure` on the next tick.
const ro = new ResizeObserver(measure);
ro.observe(node);
return {
destroy() {
ro.disconnect();
if (gridEl === node) gridEl = undefined;
}
};
}
// Thumbnail-size changes alter column count without resizing the grid,
// so the ResizeObserver above misses them. Re-measure on the next
// microtask so the new computed style is in place.
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl).gridTemplateColumns.split(' ').filter(Boolean).length;
cols = Math.max(1, n);
});
});
interface VisualRow {
uids: string[];
/** Index of the first tile in this row, in the flat `photos` array.
* Used to call `scrollToIndex` after a move. */
firstTileIndex: number;
}
/** Pre-broken visual rows + a `uid → (row, col)` index. Whenever a new
* month begins, the prior row is flushed regardless of how full it was
* — this matches the CSS Grid where a full-span header forces the
* next tile onto a fresh row. */
const visualGrid = $derived(buildVisualGrid(photos, cols));
function buildVisualGrid(
list: PpPhoto[],
colsPerRow: number
): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
const rows: VisualRow[] = [];
const pos = new Map<string, [number, number]>();
const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric'
});
let curMonth = '';
let curRow: VisualRow | null = null;
for (let i = 0; i < list.length; i++) {
const p = list[i];
const { key } = monthKey(p, fmt);
const monthChanged = key !== curMonth;
const rowFull = curRow !== null && curRow.uids.length >= colsPerRow;
if (!curRow || monthChanged || rowFull) {
curRow = { uids: [], firstTileIndex: i };
rows.push(curRow);
curMonth = key;
}
pos.set(p.UID, [rows.length - 1, curRow.uids.length]);
curRow.uids.push(p.UID);
}
return { rows, pos };
}
// "Intended" column for sticky-column behaviour. Updated by horizontal
// arrow presses; vertical presses look up the destination cell with
// this column clamped to the destination row's width — so traversing
// a partial row doesn't permanently drift your column. Reset whenever
// focus is established by a non-arrow path (click, Escape).
let intendedCol: number | null = $state(null);
$effect(() => {
// Auto-clear when focus is dropped (Escape, photos refetch, etc.).
if (selection.focused === null) intendedCol = null;
});
function onArrow(key: ArrowKey, extending: boolean) {
const { rows, pos } = visualGrid;
if (rows.length === 0) return;
// Resolve starting (row, col). Without a focused tile, default to
// the top-left so the first ArrowDown/Right lands on the first
// real photo instead of doing nothing. The explicit tuple type
// keeps TS from widening `[number, number]` to `string | number`.
const cur = selection.focused ? pos.get(selection.focused) : undefined;
const start: [number, number] = cur ?? [0, -1];
let r = start[0];
let c = start[1];
// Bootstrap intendedCol from the current column so the first
// vertical move preserves whatever column the user is already on.
if (intendedCol === null) intendedCol = c < 0 ? 0 : c;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
c += key === 'ArrowRight' ? 1 : -1;
// Wrap across row boundaries (LeftArrow at col 0 → previous
// row's last col, RightArrow at last col → next row's col 0).
while (c < 0 && r > 0) {
r -= 1;
c = rows[r].uids.length - 1;
}
while (r < rows.length && c >= rows[r].uids.length) {
if (r === rows.length - 1) {
c = rows[r].uids.length - 1;
break;
}
r += 1;
c = 0;
}
if (c < 0) c = 0;
// User explicitly chose this column → it's the new "home" for
// subsequent vertical moves.
intendedCol = c;
} else {
r += key === 'ArrowDown' ? 1 : -1;
if (r < 0) r = 0;
if (r >= rows.length) r = rows.length - 1;
// Vertical: prefer the intended column; clamp to destination
// row width so a narrower row doesn't fall off the edge.
// intendedCol is preserved so going down through narrow rows
// then back up returns to the original column.
const w = rows[r].uids.length;
c = Math.min(intendedCol, w - 1);
if (c < 0) c = 0;
}
const destUid = rows[r]?.uids[c];
if (!destUid) return;
setFocused(destUid);
if (!extending) setAnchor(destUid);
if (extending && selection.focused) selectRange(destUid);
// Try the cheap path first: rendered tile, our scroll helper that
// respects the sticky header. Fall back to scrollToIndex (windowing
// expand + scroll) when the destination is currently unmounted.
const tile = scrollRoot?.querySelector<HTMLElement>(`[data-uid="${destUid}"]`);
if (tile) {
scrollTileIntoView(tile);
} else {
const flatIdx = rows[r].firstTileIndex + c;
scrollToIndex(flatIdx);
}
}
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
}
const qc = useQueryClient();
let emptyingArchive = $state(false);
/**
* Walk every archived photo and delete it in pages. The timeline's
* infinite query only holds what the user has scrolled; we re-query
* `archived:true` directly so even unloaded pages get cleared.
* PhotoPrism caps `count` at 1000 — pull the max each round so we
* spend one HTTP call per chunk.
*/
async function onEmptyArchive() {
if (emptyingArchive) return;
if (
!confirm(
'Permanently delete EVERY photo in the Archive? This cannot be undone.'
)
) {
return;
}
emptyingArchive = true;
let total = 0;
try {
while (true) {
const batch = await listPhotos({
q: 'archived:true',
count: 1000,
offset: 0,
order: 'newest',
merged: false
});
if (batch.length === 0) break;
const uids = Array.from(new Set(batch.map((p) => p.UID)));
await batchDelete(uids);
total += uids.length;
}
toast.success(total === 0 ? 'Archive already empty' : `Deleted ${total}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Empty archive failed');
} finally {
emptyingArchive = false;
void qc.invalidateQueries({ queryKey: ['photos'] });
}
}
function onTileClick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
if (selection.ids.size > 0) return;
// Establish the "starting photo" so a subsequent shift-click extends
// the range from this tile. Reset both focus and anchor — anchor on
// its own would stick to an older toggle/selectOnly tile and the
// shift-range would silently use the wrong starting point. Also
// clear the sticky-column intent so the next arrow press anchors
// off the clicked tile's actual column.
setFocused(uid);
setAnchor(uid);
intendedCol = null;
openPreview(uid, photos.map((p) => p.UID));
}
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
// the <main> element below; the sentinel's `root` references this so
// the observer measures intersections relative to the timeline pane
// (the page itself doesn't scroll).
let scrollRoot: HTMLElement | undefined = $state();
let searchDraft = $state(filters.search);
$effect(() => {
searchDraft = filters.search;
});
function onSearchSubmit(e: SubmitEvent) {
e.preventDefault();
setSearch(searchDraft.trim());
}
</script>
<Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
{sectionLabel}
</span>
{#if filters.section === 'archive' && photos.length > 0}
<button
type="button"
class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={emptyingArchive}
onclick={onEmptyArchive}
title="Permanently delete every archived photo"
>
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
</button>
{/if}
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
<input
type="search"
placeholder='Search · label:website / "vacation"'
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft}
/>
<button
type="submit"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
>
Go
</button>
{#if filters.search}
<button
type="button"
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
onclick={() => {
searchDraft = '';
setSearch('');
}}
title="Clear search"
>
</button>
{/if}
</form>
{#snippet trailing()}
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
Persisted to localStorage via view.svelte.ts. -->
<div
class="flex items-center overflow-hidden rounded border border-border"
role="group"
aria-label="Thumbnail size"
>
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
<button
type="button"
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
class:bg-accent={view.thumbnailSize === size}
class:text-foreground={view.thumbnailSize === size}
class:text-muted-foreground={view.thumbnailSize !== size}
onclick={() => setThumbnailSize(size)}
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
>
{THUMBNAIL_SIZE_LABELS[i]}
</button>
{/each}
</div>
<span class="hidden text-[11px] text-muted-foreground sm:inline">
{session.user?.DisplayName ?? session.user?.Name}
</span>
<button
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={toggleMode}
title="Toggle theme"
>
{mode.current === 'dark' ? '☀' : '☾'}
</button>
<button
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={onSignOut}
>
Sign out
</button>
{/snippet}
</Toolbar>
<div class="flex min-h-0 flex-1">
<main
bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none"
use:gridKeyNav={{ scrollToIndex, onArrow }}
use:visibleRange={{
onChange: (f, l) => {
visFirst = f;
visLast = l;
},
sampleEvery: TILE_SAMPLE
}}
>
<div class="p-6 pb-24">
{#if photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p>
{:else if photosQuery.isError}
<p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error
? photosQuery.error.message
: 'unknown error'}
</p>
{:else if photos.length === 0}
<p class="text-sm text-muted-foreground">
{#if filters.section === 'archive'}
Archive is empty.
{:else if filters.section === 'favorites'}
No favorites yet. Heart a photo to add it here.
{:else if filters.section === 'heap'}
This heap has no photos yet. Select some photos and use the bulk bar's
" Add to heap" button.
{:else}
No photos. Index a folder via PhotoPrism's reindex command.
{/if}
</p>
{:else}
<div
data-photo-grid
use:trackGridCols
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)}
{#if row.kind === 'header'}
<!-- col-span-full + position:sticky pins the month label to
the top of the scrolling main as the user passes through.
-mx-6 stretches the bar past the wrapper padding so it
reads edge-to-edge in the viewport. -->
<h2
class="sticky top-0 z-10 -mx-6 border-b border-border bg-background/95 px-6 py-2 text-xs font-semibold uppercase tracking-wide text-foreground/80 backdrop-blur"
style="grid-column: 1 / -1;"
>
{row.label}
<span class="ml-2 text-[10px] font-normal text-muted-foreground">
{row.count}
</span>
</h2>
{:else}
{@const photo = row.photo}
{@const i = row.tileIndex}
{@const inWindow = i >= renderFirst && i <= renderLast}
<!-- Shell: always rendered. Holds grid-cell space + the
stable `data-uid-shell` anchor that gridKeyNav.scrollToIndex
can query even when the inner button is windowed out. -->
<div
data-uid-shell={photo.UID}
class="aspect-square"
use:tileRegister={i}
>
{#if inWindow}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<!-- Selection animation ported from mule-image's PhotoThumbnail:
scale to 90% + blue ring with offset + blue tint overlay, all
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over
300ms. Crucially, the transition class is ONLY applied when
selected — dropping it on deselect snaps the photo back to
full size instantly instead of crawling back.
The keyboard-focused photo gets the same treatment, so the
arrow-key cursor reads as a "selection of one" (matches
mule-image, where focused == singular selection). -->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onTileClick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/>
<!-- Blue tint overlay (mule-image's primary selection
signal): pointer-events-none so clicks still hit the
button beneath. Rendered after the image so it composites
on top; before the badges so a star/heart still reads. -->
{#if sel}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
</button>
{/if}
</div>
{/if}
{/each}
</div>
<!-- Sentinel: when this div nears the viewport we kick the next
page. The 4-viewport rootMargin (handled by the action) is
PhotoPrism's preload distance from page/photos.vue. -->
<div
aria-hidden="true"
class="h-4"
use:nearBottom={{
onHit: () => photosQuery.fetchNextPage(),
enabled: photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
root: scrollRoot
}}
></div>
{#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground">Loading more</p>
{/if}
{/if}
</div>
</main>
{#if !view.rightSidebarCollapsed}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if selection.ids.size >= 2}
<!-- Multi-select swaps to a bulk-edit panel: edits fan out across
the whole selection (note / date / keyword). The single-photo
metadata view returns when the user drops back to one. -->
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p>
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a thumbnail
to view its metadata here.
</p>
</div>
{/if}
</div>
<!-- Resize handle on the left edge; mirrors the layout's left aside
hot-zone for symmetry. -->
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>
<BulkActionBar />