Files
mule-image/web/src/routes/+page.svelte
dtoro 24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00

1005 lines
38 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { toast } from "svelte-sonner";
import {
batchDelete,
getPhoto,
listHeaps,
listPhotos,
type PpAlbum,
} from "$lib/services/photoprism";
import {
filters,
filtersToQ,
filtersToUrlParams,
parseUrlParams,
setSearch,
setSection,
} from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte";
import {
clearSelection,
isSelected,
selectRange,
selection,
setAnchor,
setFocused,
setOrder,
} from "$lib/stores/selection.svelte";
import {
openPreview,
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,
} from "$lib/actions/visibleRange";
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { EmptyState, InlineLoader } from "$lib/components/feedback";
import {
AlertCircle,
Archive,
EyeOff,
ImageOff,
Layers,
MousePointerClick,
Sparkles,
} from "lucide-svelte";
import { 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.folderPath !== undefined) filters.folderPath = next.folderPath;
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 "review":
return "Review";
case "archive":
return "Archive";
case "hidden":
return "Hidden";
case "heap": {
const heap = (heapsQuery.data ?? []).find(
(h) => h.UID === filters.heapUid,
);
return heap ? `Heap · ${heap.Title}` : "Heap";
}
default:
// 'all-photos' is the internal "no section filter" state —
// the visible context now comes from the folder filter
// (root by default). Show the folder path so the title
// reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos".
if (filters.folderPath === "/") return "Folder · /";
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
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.
*
* Split into two derived values so the heavy dedup pass only runs
* when the query's pages array changes (i.e. a fetch landed). The
* cheap folder-scope filter then runs whenever the user flips the
* filter store — search-as-you-type, section toggle, root vs.
* subfolder — without re-walking every page on each keystroke.
*
* When the user picks the root entry in the folder tree we filter
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */
const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages),
);
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
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;
}
// Root-folder scope is the only client-side filter we apply, and only
// when the timeline is actually showing a folder view — never when the
// user is in a heap, has a free-form search, or is on a non-default
// section (archive / review / hidden). Those views are
// scoped server-side via the q-DSL and must not be re-filtered here,
// or labels / search will silently drop subfolder photos when the
// store hasn't fully hydrated from the URL yet.
//
// Root (`/`) historically also restricted to `Path === ''` — i.e.
// photos sitting directly at the originals root with no subfolder.
// PhotoPrism's indexer however always nests photos under YYYY/MM, so
// that view was always empty in practice. Treat root as "the whole
// library" instead; subfolders still scope normally.
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
return list;
}
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.
*
* Always re-anchors to photos[0] when pageCount lands on 1 (which
* only happens on initial load or after a filter change resets the
* infinite-query) so switching views drops the user back at the top
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
* etc.) advance focus themselves via `focusAfter` and don't flip
* pageCount, so they don't get clobbered by this re-anchor.
*/
$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;
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,
);
// Hoist the selection reads out of the per-tile each-block. The same
// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
// just expressed at the loop level so the render reads as "given this
// (selected, focused) snapshot, here's each tile's state". No reactivity
// change — SvelteSet's `has` already subscribes per-key — but it removes
// the function-call indirection and keeps the hot loop body terse.
const selectedIds = $derived(selection.ids);
const focusedUid = $derived(selection.focused);
// 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;
});
});
/** `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.
*
* Resolve the handle lazily off the scroll root each call instead
* of stashing it in a `$state` populated by `$effect`. The effect
* runs *after* the DOM update flush, but on a remount with cached
* photo data the tiles render in the same pass as the scroll root,
* so a `$state`-backed handle is still `null` when tileRegister
* first fires — and the tile never enrols in the observer. Symptom
* was a blank grid on return-trip to the timeline (the bug this
* comment exists for). The visibleRange action sets
* `__visibleRange` on the scroll-root node during its setup phase,
* which runs before any child action, so a synchronous lookup
* always succeeds. */
function tileRegister(node: HTMLElement, index: number) {
let current = index;
const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
handle?.register(node, current);
return {
update(next: number) {
if (next === current) return;
handle?.unregister(node);
current = next;
handle?.register(node, current);
},
destroy() {
handle?.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;
// Plain arrow nav collapses any prior multi-selection to the cursor
// (mirrors gridKeyNav.moveFocus). Shift-extend keeps `ids` growing
// from the anchor below.
if (!extending) clearSelection();
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,
}));
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) {
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
// document-level click handler — let them bubble.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
// Plain click: select this tile only. Replaces the previous
// "click opens preview" semantics — preview now lives on dblclick.
// Reset both focus and anchor so a subsequent shift-click extends
// the range from this tile, and clear sticky-column intent so
// arrow nav re-anchors off this tile's actual column.
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
intendedCol = null;
}
function onTileDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
// 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());
}
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
"label:dog",
"keyword:vacation",
"taken:2024",
'"exact phrase"',
];
let searchFocused = $state(false);
function onSearchFocus() {
searchFocused = true;
}
function onSearchBlur() {
// Defer so a click on an example fires before the popover unmounts.
setTimeout(() => (searchFocused = false), 120);
}
function applySearchExample(ex: string) {
searchDraft = ex;
setSearch(ex);
searchFocused = false;
}
</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="relative 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}
onfocus={onSearchFocus}
onblur={onSearchBlur}
/>
<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}
<!--
Cheat-sheet popover: opens on input focus, lists working q-DSL
patterns. Clicking an example fills the input AND fires the
search, so it doubles as a one-click "try it" affordance.
-->
{#if searchFocused}
<div
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
<div
class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Examples
</div>
{#each SEARCH_EXAMPLES as ex (ex)}
<button
type="button"
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
onmousedown={(e) => {
e.preventDefault();
applySearchExample(ex);
}}
>
{ex}
</button>
{/each}
</div>
{/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>
{/snippet}
</Toolbar>
<div class="flex min-h-0 flex-1">
<!--
Main column wraps the scrollable timeline and the action bar so
the bar's width matches the timeline only — the right aside is a
sibling at row level and stays full height when the bar appears.
-->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<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="pr-2 pl-2 pb-2 overflow-x-hidden">
{#if photosQuery.isPending}
<SkeletonGrid />
{:else if photosQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message
: "unknown error"}
/>
{:else if photos.length === 0}
{#if filters.section === "archive"}
<EmptyState icon={Archive} title="Archive is empty" />
{:else if filters.section === "review"}
<EmptyState
icon={Sparkles}
title="Nothing left to review"
description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
/>
{:else if filters.section === "hidden"}
<EmptyState
icon={EyeOff}
title="No hidden photos"
description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
/>
{:else if filters.section === "heap"}
<EmptyState
icon={Layers}
title="This heap has no photos yet"
description={'Select some photos and use the bulk bars “+ Add to heap” button.'}
/>
{:else}
<EmptyState
icon={ImageOff}
title="No photos"
description="Index a folder from Settings → Index, or run a reindex from the server."
/>
{/if}
{: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 sel =
selectedIds.has(photo.UID) ||
focusedUid === photo.UID}
<PhotoTile
{photo}
selected={sel}
onClick={(e) => onTileClick(e, photo.UID)}
onDblclick={(e) => onTileDblclick(e, photo.UID)}
/>
{/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}
<InlineLoader
size="sm"
align="center"
polite={false}
label="Loading more photos…"
/>
{/if}
{/if}
</div>
</main>
<BulkActionBar />
</div>
{#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}
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
>+click on a thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/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>