Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
3 changed files with 964 additions and 916 deletions
Showing only changes of commit ccbc1050de - Show all commits

View File

@@ -90,21 +90,43 @@
enabled: open enabled: open
})); }));
let draft = $state<PpSettings | null>(null); /**
$effect(() => { * Some PhotoPrism deployments return `/settings` without the
if (settingsQuery.data && draft === null) { * `ui` / `search` / `maps` keys (older versions, custom edits to
draft = structuredClone(settingsQuery.data); * settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
* non-null-asserts those sub-objects — when they're missing the
* assertion lies and the bind getter throws on the next tick. Force
* the shape on every clone so every binding has a real object to
* write into, and so `draft.ui` is never null while `draft` is non-
* null (template gates only check `draft`).
*/
function normalize(s: PpSettings): PpSettings {
return {
...s,
ui: s.ui ?? {},
search: s.search ?? {},
maps: s.maps ?? {}
};
} }
});
let draft = $state<PpSettings | null>(null);
// Re-clone on each open so reopening the dialog shows the freshest
// server state. Eagerly nulling on close used to introduce a window
// where Dialog's exit animation kept the form mounted while draft
// was already null — and bind:value getters read null, triggering
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
// open instead avoids that race entirely.
$effect(() => { $effect(() => {
if (!open) draft = null; if (open && settingsQuery.data) {
draft = normalize(structuredClone(settingsQuery.data));
}
}); });
const saveMut = createMutation(() => ({ const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch), mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => { onSuccess: (next) => {
qc.setQueryData(['settings'], next); qc.setQueryData(['settings'], next);
draft = structuredClone(next); draft = normalize(structuredClone(next));
toast.success('Settings saved'); toast.success('Settings saved');
}, },
onError: (err) => onError: (err) =>
@@ -112,7 +134,7 @@
})); }));
function resetDraft() { function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data); if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
} }
const selectClass = const selectClass =

View File

@@ -126,11 +126,6 @@ function handleMessage(raw: string): void {
const eventName = inner.event as string | undefined; const eventName = inner.event as string | undefined;
const data = (inner.data ?? {}) as Record<string, unknown>; const data = (inner.data ?? {}) as Record<string, unknown>;
if (!eventName) return; if (!eventName) return;
// PhotoPrism's WS protocol isn't a stable contract; log the live shape
// at `debug` (hidden by default in DevTools — toggle "Verbose" to see)
// so future-us can spot new indexer event names without instrumenting
// the entire app.
console.debug('[indexer]', eventName, data);
switch (eventName) { switch (eventName) {
case 'index.indexing': { case 'index.indexing': {
// Per-file event during the scan pass. PhotoPrism emits one // Per-file event during the scan pass. PhotoPrism emits one

View File

@@ -1,54 +1,61 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from "$app/environment";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { page } from '$app/state'; import { page } from "$app/state";
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query'; import {
import { toast } from 'svelte-sonner'; createInfiniteQuery,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { import {
batchDelete, batchDelete,
getPhoto, getPhoto,
listHeaps, listHeaps,
listPhotos, listPhotos,
type PpAlbum type PpAlbum,
} from '$lib/services/photoprism'; } from "$lib/services/photoprism";
import { import {
filters, filters,
filtersToQ, filtersToQ,
filtersToUrlParams, filtersToUrlParams,
parseUrlParams, parseUrlParams,
setSearch, setSearch,
setSection setSection,
} from '$lib/stores/filters.svelte'; } from "$lib/stores/filters.svelte";
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from 'svelte'; import { untrack } from "svelte";
import { import {
isSelected, isSelected,
selectRange, selectRange,
selection, selection,
setAnchor, setAnchor,
setFocused, setFocused,
setOrder setOrder,
} from '$lib/stores/selection.svelte'; } from "$lib/stores/selection.svelte";
import { openPreview, preview } from '$lib/stores/preview.svelte'; import { openPreview, preview } from "$lib/stores/preview.svelte";
import { import {
setRightSidebarWidth, setRightSidebarWidth,
setThumbnailSize, setThumbnailSize,
THUMBNAIL_SIZE_LABELS, THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS, THUMBNAIL_SIZE_PRESETS,
view view,
} from '$lib/stores/view.svelte'; } from "$lib/stores/view.svelte";
import { tick } from 'svelte'; import { tick } from "svelte";
import { resizable } from '$lib/actions/resizable'; import { resizable } from "$lib/actions/resizable";
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav'; import { gridKeyNav, type ArrowKey } from "$lib/actions/gridKeyNav";
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from "$lib/actions/nearBottom";
import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange'; import {
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; visibleRange,
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; getVisibleRangeHandle,
import PhotoTile from '$lib/components/timeline/PhotoTile.svelte'; } from "$lib/actions/visibleRange";
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import { type PpPhoto } from '$lib/types/photoprism'; 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 { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store. // On nav (back/forward, deep link), reflect the URL into the store.
@@ -63,41 +70,43 @@
// When the store changes from in-app actions (left-sidebar nav, 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. // box, etc.), push the matching query string back so the URL is shareable.
let lastWritten = $state(''); let lastWritten = $state("");
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const qs = filtersToUrlParams().toString(); const qs = filtersToUrlParams().toString();
const here = page.url.searchParams.toString(); const here = page.url.searchParams.toString();
if (qs === here || qs === lastWritten) return; if (qs === here || qs === lastWritten) return;
lastWritten = qs; lastWritten = qs;
void goto(`/${qs ? '?' + qs : ''}`, { void goto(`/${qs ? "?" + qs : ""}`, {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true,
}); });
}); });
// ── Section title ──────────────────────────────────────────────────────── // ── Section title ────────────────────────────────────────────────────────
const heapsQuery = createQuery<PpAlbum[]>(() => ({ const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'], queryKey: ["heaps"],
queryFn: listHeaps, queryFn: listHeaps,
enabled: isAuthenticated() enabled: isAuthenticated(),
})); }));
const sectionLabel = $derived(buildSectionLabel()); const sectionLabel = $derived(buildSectionLabel());
function buildSectionLabel(): string { function buildSectionLabel(): string {
switch (filters.section) { switch (filters.section) {
case 'favorites': case "favorites":
return 'Favorites'; return "Favorites";
case 'review': case "review":
return 'Review'; return "Review";
case 'archive': case "archive":
return 'Archive'; return "Archive";
case 'hidden': case "hidden":
return 'Hidden'; return "Hidden";
case 'heap': { case "heap": {
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid); const heap = (heapsQuery.data ?? []).find(
return heap ? `Heap · ${heap.Title}` : 'Heap'; (h) => h.UID === filters.heapUid,
);
return heap ? `Heap · ${heap.Title}` : "Heap";
} }
default: default:
// 'all-photos' is the internal "no section filter" state — // 'all-photos' is the internal "no section filter" state —
@@ -106,9 +115,9 @@
// reflects what's actually on screen; only the rare // reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a // `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos". // heap) still reads as "All photos".
if (filters.folderPath === '/') return 'Folder · /'; if (filters.folderPath === "/") return "Folder · /";
if (filters.folderPath) return `Folder · ${filters.folderPath}`; if (filters.folderPath) return `Folder · ${filters.folderPath}`;
return 'All photos'; return "All photos";
} }
} }
@@ -120,14 +129,14 @@
// downstream into a single `photos` array consumers iterate. // downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120; const PHOTOS_PAGE_SIZE = 120;
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({ const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'q', filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }], queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
listPhotos({ listPhotos({
q: filtersToQ(filters), q: filtersToQ(filters),
count: PHOTOS_PAGE_SIZE, count: PHOTOS_PAGE_SIZE,
offset: pageParam as number, offset: pageParam as number,
order: 'newest', order: "newest",
merged: true merged: true,
}), }),
initialPageParam: 0, initialPageParam: 0,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each // PhotoPrism's `count` limits SQL rows; with `merged=true` each
@@ -137,7 +146,7 @@
// tail (cheap; the empty response is small). // tail (cheap; the empty response is small).
getNextPageParam: (last, pages) => getNextPageParam: (last, pages) =>
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE, last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
enabled: isAuthenticated() enabled: isAuthenticated(),
})); }));
/** Flattened view of every loaded page, deduplicated by UID. Adjacent /** Flattened view of every loaded page, deduplicated by UID. Adjacent
@@ -155,7 +164,9 @@
* to `Path === ''` here — PhotoPrism's `path:` operator can't * to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and * express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */ * we strip subfolder rows post-hoc. */
const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages)); const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages),
);
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters)); const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] { function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return []; if (!pages) return [];
@@ -230,15 +241,15 @@
* one-dimensional even though the template renders headers inline). * one-dimensional even though the template renders headers inline).
*/ */
type Row = type Row =
| { kind: 'header'; key: string; label: string; count: number } | { kind: "header"; key: string; label: string; count: number }
| { kind: 'tile'; photo: PpPhoto; tileIndex: number }; | { kind: "tile"; photo: PpPhoto; tileIndex: number };
const rows = $derived<Row[]>(buildRows(photos)); const rows = $derived<Row[]>(buildRows(photos));
function buildRows(list: PpPhoto[]): Row[] { function buildRows(list: PpPhoto[]): Row[] {
const out: Row[] = []; const out: Row[] = [];
const fmt = new Intl.DateTimeFormat(undefined, { const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long', month: "long",
year: 'numeric' year: "numeric",
}); });
// First pass: count per month — used by the header chip. Sparse // First pass: count per month — used by the header chip. Sparse
// Map: O(months) memory, O(photos) time, both trivial at 10k. // Map: O(months) memory, O(photos) time, both trivial at 10k.
@@ -250,25 +261,33 @@
if (!labels.has(key)) labels.set(key, label); if (!labels.has(key)) labels.set(key, label);
} }
// Second pass: emit rows in document order. // Second pass: emit rows in document order.
let prev = ''; let prev = "";
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const p = list[i]; const p = list[i];
const { key } = monthKey(p, fmt); const { key } = monthKey(p, fmt);
if (key !== prev) { if (key !== prev) {
out.push({ kind: 'header', key, label: labels.get(key) ?? '', count: counts.get(key) ?? 0 }); out.push({
kind: "header",
key,
label: labels.get(key) ?? "",
count: counts.get(key) ?? 0,
});
prev = key; prev = key;
} }
out.push({ kind: 'tile', photo: p, tileIndex: i }); out.push({ kind: "tile", photo: p, tileIndex: i });
} }
return out; return out;
} }
function monthKey(p: PpPhoto, fmt: Intl.DateTimeFormat): { key: string; label: string } { function monthKey(
const raw = p.TakenAtLocal ?? p.TakenAt ?? ''; p: PpPhoto,
if (!raw) return { key: 'no-date', label: 'No date' }; 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); const d = new Date(raw);
if (Number.isNaN(d.getTime())) return { key: 'no-date', label: 'No date' }; if (Number.isNaN(d.getTime())) return { key: "no-date", label: "No date" };
const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return { key: k, label: fmt.format(d) }; return { key: k, label: fmt.format(d) };
} }
@@ -287,10 +306,10 @@
let visLast = $state(INITIAL_VIS_LAST); let visLast = $state(INITIAL_VIS_LAST);
let forcedExpand = $state<number | null>(null); let forcedExpand = $state<number | null>(null);
const renderFirst = $derived( const renderFirst = $derived(
Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER) Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER),
); );
const renderLast = $derived( const renderLast = $derived(
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER,
); );
// Hoist the selection reads out of the per-tile each-block. The same // Hoist the selection reads out of the per-tile each-block. The same
@@ -348,7 +367,7 @@
}, },
destroy() { destroy() {
handle?.unregister(node); handle?.unregister(node);
} },
}; };
} }
@@ -362,7 +381,8 @@
* uses in its `scrollRowIntoView`. */ * uses in its `scrollRowIntoView`. */
function scrollTileIntoView(el: HTMLElement) { function scrollTileIntoView(el: HTMLElement) {
if (!scrollRoot) return; if (!scrollRoot) return;
const stickyH = scrollRoot.querySelector<HTMLElement>('h2.sticky')?.offsetHeight ?? 0; const stickyH =
scrollRoot.querySelector<HTMLElement>("h2.sticky")?.offsetHeight ?? 0;
const rootRect = scrollRoot.getBoundingClientRect(); const rootRect = scrollRoot.getBoundingClientRect();
const elRect = el.getBoundingClientRect(); const elRect = el.getBoundingClientRect();
const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop; const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
@@ -384,7 +404,9 @@
forcedExpand = i; forcedExpand = i;
await tick(); await tick();
if (!scrollRoot) return; if (!scrollRoot) return;
const el = scrollRoot.querySelector<HTMLElement>(`[data-uid-shell="${photos[i]?.UID ?? ''}"]`); const el = scrollRoot.querySelector<HTMLElement>(
`[data-uid-shell="${photos[i]?.UID ?? ""}"]`,
);
if (el) scrollTileIntoView(el); if (el) scrollTileIntoView(el);
} }
@@ -430,7 +452,9 @@
function trackGridCols(node: HTMLElement) { function trackGridCols(node: HTMLElement) {
gridEl = node; gridEl = node;
const measure = () => { const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(' ').filter(Boolean).length; const n = getComputedStyle(node)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
cols = Math.max(1, n); cols = Math.max(1, n);
}; };
measure(); measure();
@@ -444,7 +468,7 @@
destroy() { destroy() {
ro.disconnect(); ro.disconnect();
if (gridEl === node) gridEl = undefined; if (gridEl === node) gridEl = undefined;
} },
}; };
} }
@@ -455,7 +479,9 @@
void view.thumbnailSize; void view.thumbnailSize;
queueMicrotask(() => { queueMicrotask(() => {
if (!gridEl) return; if (!gridEl) return;
const n = getComputedStyle(gridEl).gridTemplateColumns.split(' ').filter(Boolean).length; const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
cols = Math.max(1, n); cols = Math.max(1, n);
}); });
}); });
@@ -474,15 +500,15 @@
function buildVisualGrid( function buildVisualGrid(
list: PpPhoto[], list: PpPhoto[],
colsPerRow: number colsPerRow: number,
): { rows: VisualRow[]; pos: Map<string, [number, number]> } { ): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
const rows: VisualRow[] = []; const rows: VisualRow[] = [];
const pos = new Map<string, [number, number]>(); const pos = new Map<string, [number, number]>();
const fmt = new Intl.DateTimeFormat(undefined, { const fmt = new Intl.DateTimeFormat(undefined, {
month: 'long', month: "long",
year: 'numeric' year: "numeric",
}); });
let curMonth = ''; let curMonth = "";
let curRow: VisualRow | null = null; let curRow: VisualRow | null = null;
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const p = list[i]; const p = list[i];
@@ -528,8 +554,8 @@
// vertical move preserves whatever column the user is already on. // vertical move preserves whatever column the user is already on.
if (intendedCol === null) intendedCol = c < 0 ? 0 : c; if (intendedCol === null) intendedCol = c < 0 ? 0 : c;
if (key === 'ArrowLeft' || key === 'ArrowRight') { if (key === "ArrowLeft" || key === "ArrowRight") {
c += key === 'ArrowRight' ? 1 : -1; c += key === "ArrowRight" ? 1 : -1;
// Wrap across row boundaries (LeftArrow at col 0 → previous // Wrap across row boundaries (LeftArrow at col 0 → previous
// row's last col, RightArrow at last col → next row's col 0). // row's last col, RightArrow at last col → next row's col 0).
while (c < 0 && r > 0) { while (c < 0 && r > 0) {
@@ -549,7 +575,7 @@
// subsequent vertical moves. // subsequent vertical moves.
intendedCol = c; intendedCol = c;
} else { } else {
r += key === 'ArrowDown' ? 1 : -1; r += key === "ArrowDown" ? 1 : -1;
if (r < 0) r = 0; if (r < 0) r = 0;
if (r >= rows.length) r = rows.length - 1; if (r >= rows.length) r = rows.length - 1;
// Vertical: prefer the intended column; clamp to destination // Vertical: prefer the intended column; clamp to destination
@@ -571,7 +597,9 @@
// Try the cheap path first: rendered tile, our scroll helper that // Try the cheap path first: rendered tile, our scroll helper that
// respects the sticky header. Fall back to scrollToIndex (windowing // respects the sticky header. Fall back to scrollToIndex (windowing
// expand + scroll) when the destination is currently unmounted. // expand + scroll) when the destination is currently unmounted.
const tile = scrollRoot?.querySelector<HTMLElement>(`[data-uid="${destUid}"]`); const tile = scrollRoot?.querySelector<HTMLElement>(
`[data-uid="${destUid}"]`,
);
if (tile) { if (tile) {
scrollTileIntoView(tile); scrollTileIntoView(tile);
} else { } else {
@@ -581,11 +609,11 @@
} }
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({ const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''], queryKey: ["photo", selection.focused ?? ""],
queryFn: () => queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null), selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused), enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0 staleTime: 0,
})); }));
const qc = useQueryClient(); const qc = useQueryClient();
@@ -602,7 +630,7 @@
if (emptyingArchive) return; if (emptyingArchive) return;
if ( if (
!confirm( !confirm(
'Permanently delete EVERY photo in the Archive? This cannot be undone.' "Permanently delete EVERY photo in the Archive? This cannot be undone.",
) )
) { ) {
return; return;
@@ -612,23 +640,23 @@
try { try {
while (true) { while (true) {
const batch = await listPhotos({ const batch = await listPhotos({
q: 'archived:true', q: "archived:true",
count: 1000, count: 1000,
offset: 0, offset: 0,
order: 'newest', order: "newest",
merged: false merged: false,
}); });
if (batch.length === 0) break; if (batch.length === 0) break;
const uids = Array.from(new Set(batch.map((p) => p.UID))); const uids = Array.from(new Set(batch.map((p) => p.UID)));
await batchDelete(uids); await batchDelete(uids);
total += uids.length; total += uids.length;
} }
toast.success(total === 0 ? 'Archive already empty' : `Deleted ${total}`); toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Empty archive failed'); toast.error(err instanceof Error ? err.message : "Empty archive failed");
} finally { } finally {
emptyingArchive = false; emptyingArchive = false;
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ["photos"] });
} }
} }
@@ -653,14 +681,20 @@
// gridKeyNav already handled the underlying click. // gridKeyNav already handled the underlying click.
if (e.shiftKey || e.metaKey || e.ctrlKey) return; if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault(); e.preventDefault();
openPreview(uid, photos.map((p) => p.UID)); openPreview(
uid,
photos.map((p) => p.UID),
);
} }
// Single-click fallback for the dblclick preview gesture. Wired to the // Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered // hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview. // dblclick can still get to the preview.
function onTileOpenPreview(uid: string) { function onTileOpenPreview(uid: string) {
openPreview(uid, photos.map((p) => p.UID)); openPreview(
uid,
photos.map((p) => p.UID),
);
} }
// Scroll root for the infinite-scroll IntersectionObserver. Bound by // Scroll root for the infinite-scroll IntersectionObserver. Bound by
@@ -681,10 +715,10 @@
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on // PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet. // focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [ const SEARCH_EXAMPLES = [
'label:dog', "label:dog",
'keyword:vacation', "keyword:vacation",
'taken:2024', "taken:2024",
'"exact phrase"' '"exact phrase"',
]; ];
let searchFocused = $state(false); let searchFocused = $state(false);
function onSearchFocus() { function onSearchFocus() {
@@ -702,22 +736,12 @@
</script> </script>
<Toolbar showRightToggle> <Toolbar showRightToggle>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span
class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{sectionLabel} {sectionLabel}
</span> </span>
<!-- {#if filters.section === "archive" && photos.length > 0}
Persistent gesture hint. The new click-semantics (single = select,
double = open) aren't intuitive for users arriving from Google
Photos / Apple Photos, so surface them in plain text where the eye
can see them without hover. Hidden below sm: so the search bar
still gets room on narrow viewports.
-->
{#if photos.length > 0}
<span class="hidden text-[10px] text-muted-foreground lg:inline">
click select · ⇧ range · ⌘ toggle · dblclick open
</span>
{/if}
{#if filters.section === 'archive' && photos.length > 0}
<button <button
type="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" class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
@@ -725,13 +749,13 @@
onclick={onEmptyArchive} onclick={onEmptyArchive}
title="Permanently delete every archived photo" title="Permanently delete every archived photo"
> >
{emptyingArchive ? 'Emptying…' : 'Empty Archive'} {emptyingArchive ? "Emptying…" : "Empty Archive"}
</button> </button>
{/if} {/if}
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}> <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input <input
type="search" type="search"
placeholder='Search · label:website / "vacation"' 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" 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} bind:value={searchDraft}
onfocus={onSearchFocus} onfocus={onSearchFocus}
@@ -748,8 +772,8 @@
type="button" type="button"
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent" class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
onclick={() => { onclick={() => {
searchDraft = ''; searchDraft = "";
setSearch(''); setSearch("");
}} }}
title="Clear search" title="Clear search"
> >
@@ -765,7 +789,9 @@
<div <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" 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"> <div
class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Examples Examples
</div> </div>
{#each SEARCH_EXAMPLES as ex (ex)} {#each SEARCH_EXAMPLES as ex (ex)}
@@ -806,7 +832,6 @@
</button> </button>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
</Toolbar> </Toolbar>
@@ -826,7 +851,7 @@
visFirst = f; visFirst = f;
visLast = l; visLast = l;
}, },
sampleEvery: TILE_SAMPLE sampleEvery: TILE_SAMPLE,
}} }}
> >
<div class="p-6 pb-24"> <div class="p-6 pb-24">
@@ -836,24 +861,24 @@
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error Failed to load photos: {photosQuery.error instanceof Error
? photosQuery.error.message ? photosQuery.error.message
: 'unknown error'} : "unknown error"}
</p> </p>
{:else if photos.length === 0} {:else if photos.length === 0}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{#if filters.section === 'archive'} {#if filters.section === "archive"}
Archive is empty. Archive is empty.
{:else if filters.section === 'favorites'} {:else if filters.section === "favorites"}
No favorites yet. Heart a photo to add it here. No favorites yet. Heart a photo to add it here.
{:else if filters.section === 'review'} {:else if filters.section === "review"}
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about Nothing left to review. Photos PhotoPrism's indexer wasn't sure
land here — use Keep to accept them into the timeline or Archive to about land here — use Keep to accept them into the timeline or
set them aside. Archive to set them aside.
{:else if filters.section === 'hidden'} {:else if filters.section === "hidden"}
No hidden photos. PhotoPrism auto-hides files it can't index (broken No hidden photos. PhotoPrism auto-hides files it can't index
files, very low quality); they only ever show up here. (broken files, very low quality); they only ever show up here.
{:else if filters.section === 'heap'} {:else if filters.section === "heap"}
This heap has no photos yet. Select some photos and use the bulk bar's This heap has no photos yet. Select some photos and use the bulk
" Add to heap" button. bar's " Add to heap" button.
{:else} {:else}
No photos. Index a folder via PhotoPrism's reindex command. No photos. Index a folder via PhotoPrism's reindex command.
{/if} {/if}
@@ -865,8 +890,8 @@
class="grid gap-2" class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));" 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}`)} {#each rows as row (row.kind === "header" ? `h:${row.key}` : `t:${row.photo.UID}`)}
{#if row.kind === 'header'} {#if row.kind === "header"}
<!-- col-span-full + position:sticky pins the month label to <!-- col-span-full + position:sticky pins the month label to
the top of the scrolling main as the user passes through. the top of the scrolling main as the user passes through.
-mx-6 stretches the bar past the wrapper padding so it -mx-6 stretches the bar past the wrapper padding so it
@@ -876,7 +901,9 @@
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
> >
{row.label} {row.label}
<span class="ml-2 text-[10px] font-normal text-muted-foreground"> <span
class="ml-2 text-[10px] font-normal text-muted-foreground"
>
{row.count} {row.count}
</span> </span>
</h2> </h2>
@@ -893,7 +920,8 @@
use:tileRegister={i} use:tileRegister={i}
> >
{#if inWindow} {#if inWindow}
{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID} {@const sel =
selectedIds.has(photo.UID) || focusedUid === photo.UID}
<PhotoTile <PhotoTile
{photo} {photo}
selected={sel} selected={sel}
@@ -915,12 +943,15 @@
class="h-4" class="h-4"
use:nearBottom={{ use:nearBottom={{
onHit: () => photosQuery.fetchNextPage(), onHit: () => photosQuery.fetchNextPage(),
enabled: photosQuery.hasNextPage && !photosQuery.isFetchingNextPage, enabled:
root: scrollRoot photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
root: scrollRoot,
}} }}
></div> ></div>
{#if photosQuery.isFetchingNextPage} {#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground">Loading more</p> <p class="py-3 text-center text-xs text-muted-foreground">
Loading more
</p>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -947,8 +978,8 @@
<div class="space-y-2 p-4 text-center"> <div class="space-y-2 p-4 text-center">
<div class="text-xl"></div> <div class="text-xl"></div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a thumbnail Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click
to view its metadata here. on a thumbnail to view its metadata here.
</p> </p>
</div> </div>
{/if} {/if}
@@ -958,9 +989,9 @@
<div <div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize" class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{ use:resizable={{
edge: 'left', edge: "left",
getWidth: () => view.rightSidebarWidth, getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth setWidth: setRightSidebarWidth,
}} }}
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"