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

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import '../app.css';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import PreviewOverlay from '$lib/components/preview/PreviewOverlay.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
let { children } = $props();
// Auth guard. Anything outside /login requires a session; otherwise
// punt to the login page (which itself redirects authenticated users
// back to /).
$effect(() => {
if (!browser) return;
const onLogin = page.url.pathname === '/login';
if (!isAuthenticated() && !onLogin) {
void goto('/login', { replaceState: true });
}
});
</script>
<svelte:head>
<title>Mulimage</title>
</svelte:head>
<ModeWatcher />
<Toaster richColors position="bottom-right" />
<QueryClientProvider client={queryClient}>
{#if isAuthenticated() && page.url.pathname !== '/login'}
<!-- App shell locks to viewport height; only the main thumbnail
region inside each page scrolls. The mule header, toolbar, and
sidebars stay fixed regardless of how far you scroll the grid. -->
<div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule />
<div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed}
<aside
class="relative hidden h-full shrink-0 border-r border-border bg-card/30 md:block"
style="width: {view.leftSidebarWidth}px;"
>
<div class="h-full overflow-y-auto p-3">
<LeftSidebar />
</div>
<div
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
use:resizable={{
edge: 'right',
getWidth: () => view.leftSidebarWidth,
setWidth: setLeftSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize left panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
<!-- Right column hosts the route's content. Pages render as
a flex column whose first child is the Toolbar (shrink-0)
and whose remaining content takes the rest, so each route
can decide which of its panes is the scrollable one. -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
{@render children?.()}
</div>
</div>
</div>
{:else}
{@render children?.()}
{/if}
<PreviewOverlay />
</QueryClientProvider>

View File

@@ -0,0 +1,4 @@
// SPA mode: disable SSR / prerendering across the app. The Svelte client
// talks directly to PhotoPrism's REST + WebSocket; no server runtime needed.
export const ssr = false;
export const prerender = false;

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

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getAllMarks,
listPhotos,
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// PhotoPrism's Color is auto-derived from image content — the user-set
// label lives in mule-sidecar's marks map alongside ratings. Pool the
// recent photo list so we can resolve thumbnail hashes for each labelled
// UID. Matches the four-swatch palette used by RightSidebar.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const photosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'colors-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated()
}));
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
];
interface ColorGroup {
key: string;
title: string;
bg: string;
photos: PpPhoto[];
}
const groups = $derived<ColorGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
function buildGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): ColorGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<string, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const c = mark.color;
if (!c) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(c) ?? [];
arr.push(photo);
buckets.set(c, arr);
}
const out: ColorGroup[] = [];
for (const swatch of COLOR_SWATCHES) {
const photos = buckets.get(swatch.key);
if (photos && photos.length > 0) {
out.push({ ...swatch, photos });
}
}
return out;
}
let selected = $state<string | null>(null);
const selectedGroup = $derived(
selected !== null ? groups.find((g) => g.key === selected) ?? null : null
);
function pickGroup(key: string) {
selected = key;
}
function clearSelection() {
selected = null;
}
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Colors
</span>
{#if selectedGroup}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
>
← Back
</button>
<span class="flex items-center gap-1.5 text-[11px] font-medium">
<span class="h-2.5 w-2.5 rounded-full {selectedGroup.bg}"></span>
{selectedGroup.title}
</span>
<span class="text-[11px] text-muted-foreground">
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
</span>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{groups.length} color{groups.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p>
{:else if marksQuery.isError || photosQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p>
{:else if groups.length === 0}
<p class="text-sm text-muted-foreground">
No color labels yet. Open a photo and use the four-swatch row in the right
sidebar to tag it.
</p>
{:else if selectedGroup}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
>
{#each selectedGroup.photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
<button
type="button"
onclick={() =>
openPreview(
photo.UID,
selectedGroup.photos.map((p) => p.UID)
)}
class="aspect-square 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.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
/>
</button>
{/each}
</div>
{:else}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
>
{#each groups as group (group.key)}
{@const rep = group.photos[0]}
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => pickGroup(group.key)}
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={group.title}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="flex items-center gap-1.5 truncate font-medium">
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
{group.title}
</span>
<span class="text-muted-foreground">{group.photos.length}</span>
</div>
</button>
{/each}
</div>
{/if}
</main>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import { isAuthenticated } from '$lib/stores/session.svelte';
import {
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS,
view
} from '$lib/stores/view.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
// toolbar bounces don't refetch the (potentially expensive) stack
// listing. Invalidation by mutations is explicit, not time-driven.
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated(),
staleTime: 30_000
}));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Duplicates · stacks
</span>
{#snippet trailing()}
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
store is global, so the picked size persists across routes —
when you come back to the timeline it stays where you left it. -->
<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="text-[11px] text-muted-foreground">
{dupesQuery.data?.length ?? 0} group{dupesQuery.data?.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto">
<DuplicatesView
groups={dupesQuery.data ?? []}
pending={dupesQuery.isPending}
error={dupesQuery.error}
/>
</main>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { login } from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
let username = $state('');
let password = $state('');
let submitting = $state(false);
$effect(() => {
if (isAuthenticated()) {
void goto('/', { replaceState: true });
}
});
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
try {
await login(username, password);
toast.success('Signed in');
await goto('/', { replaceState: true });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
toast.error(msg);
} finally {
submitting = false;
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-background p-6">
<form
onsubmit={onSubmit}
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
>
<header class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1>
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p>
</header>
<label class="block space-y-1.5">
<span class="text-sm font-medium text-foreground">Username</span>
<input
type="text"
autocomplete="username"
required
bind:value={username}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="block space-y-1.5">
<span class="text-sm font-medium text-foreground">Password</span>
<input
type="password"
autocomplete="current-password"
required
bind:value={password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<button
type="submit"
disabled={submitting}
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:opacity-90 disabled:opacity-50"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
<p class="text-xs text-muted-foreground">
OIDC SSO ships in M4 when the IdP is wired up.
</p>
</form>
</div>

View File

@@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import maplibregl, {
type GeoJSONSource,
type MapMouseEvent,
type MapSourceDataEvent
} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
const geoQuery = createQuery<PpGeoCollection>(() => ({
queryKey: ['geo'],
queryFn: () => listGeo(),
enabled: isAuthenticated()
}));
let mapEl: HTMLDivElement | undefined = $state();
let map: maplibregl.Map | undefined;
/** Reactive flag flipped on once the MapLibre `load` event has fired
* and the `photos` source has been installed. The data-push `$effect`
* depends on this — otherwise, if the geoQuery resolves before the
* basemap style finishes loading, the effect runs with no source
* available and never re-runs (since `map` itself is not `$state`),
* leaving the map permanently empty. */
let mapReady = $state(false);
/** Markers currently attached to the map, keyed by feature id (UIDs
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
* current `querySourceFeatures` set on every render to add markers
* that came into view and remove ones that scrolled out / got
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
const markers = new Map<string, maplibregl.Marker>();
const markersOnScreen = new Map<string, maplibregl.Marker>();
onMount(() => {
if (!mapEl) return;
map = new maplibregl.Map({
container: mapEl,
// PhotoPrism's default basemap style (CDN-hosted, no key required).
// The style JSON already references the correct glyphs URL, so
// no explicit override is needed here.
style: 'https://cdn.photoprism.app/maps/default.json',
center: [0, 20],
zoom: 1,
attributionControl: { compact: true }
});
map.addControl(
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
'top-right'
);
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
map.on('load', () => {
addPhotoLayers();
mapReady = true;
});
// PhotoPrism's update strategy: re-reconcile markers on every map
// movement, on resize (so cluster bubbles re-balance when the
// viewport changes), on idle (catches the post-`fitBounds` settle),
// and on `sourcedata` filtered to "source fully loaded" — that's
// the moment MapLibre has processed clustering and
// `querySourceFeatures` returns meaningful results.
const onSourceData = (e: MapSourceDataEvent) => {
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
};
map.on('sourcedata', onSourceData);
map.on('move', updateMarkers);
map.on('moveend', updateMarkers);
map.on('resize', updateMarkers);
map.on('idle', updateMarkers);
return () => {
map?.off('sourcedata', onSourceData);
map?.off('move', updateMarkers);
map?.off('moveend', updateMarkers);
map?.off('resize', updateMarkers);
map?.off('idle', updateMarkers);
markersOnScreen.forEach((m) => m.remove());
markersOnScreen.clear();
markers.clear();
map?.remove();
map = undefined;
mapReady = false;
};
});
function addPhotoLayers() {
if (!map) return;
map.addSource('photos', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
// PhotoPrism's clustering parameters — points within ~80px merge
// below zoom 17, individual photos render above that.
clusterMaxZoom: 17,
clusterRadius: 80
});
// Invisible layer for clusters — PhotoPrism does this so the source
// reports cluster features via `querySourceFeatures` (which only
// returns features actually rendered by some layer) while the
// visual presentation is owned by HTML markers below.
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'photos',
filter: ['has', 'point_count'],
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
});
// Click an (invisible) cluster anywhere on the map → zoom to its
// expansion level. The marker DOM also has a click handler, but
// pointer-through to the map needs this as a fallback.
map.on('click', 'clusters', (e: MapMouseEvent) => {
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0]?.properties?.cluster_id;
if (clusterId == null) return;
const source = map!.getSource('photos') as GeoJSONSource;
source.getClusterExpansionZoom(clusterId).then((zoom) => {
const geometry = features[0]?.geometry;
if (!geometry || geometry.type !== 'Point') return;
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
});
});
}
/** Cluster bubble diameter, scaled by the number of contained photos
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
function clusterSize(count: number): number {
if (count >= 10000) return 74;
if (count >= 1000) return 70;
if (count >= 750) return 68;
if (count >= 200) return 66;
if (count >= 100) return 64;
return 60;
}
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
function abbreviateCount(value: number): string {
if (value >= 1000) return `${Math.round(value / 1000)}k`;
return String(value);
}
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
const el = document.createElement('div');
el.className = 'marker';
if (title) el.title = title;
el.style.width = '50px';
el.style.height = '50px';
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
openPreview(uid, allUids);
});
return el;
}
function buildClusterMarker(clusterId: number, count: number) {
const size = clusterSize(count);
const el = document.createElement('div');
el.className = 'marker';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
const grid = document.createElement('div');
grid.className = 'cluster-marker';
el.appendChild(grid);
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = abbreviateCount(count);
el.appendChild(badge);
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
// source is captured once here; `getClusterLeaves` returns a
// Promise, so this populates asynchronously and the bubble shows a
// dark placeholder until the thumbs arrive.
if (map) {
const source = map.getSource('photos') as GeoJSONSource | undefined;
if (source && typeof source.getClusterLeaves === 'function') {
source
.getClusterLeaves(clusterId, 4, 0)
.then((leaves) => {
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
for (let i = 0; i < previewCount; i++) {
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
const props = (leaf?.properties ?? {}) as { Hash?: string };
if (!props.Hash) continue;
const tile = document.createElement('div');
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
grid.appendChild(tile);
}
})
.catch(() => {});
}
}
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (!map) return;
const source = map.getSource('photos') as GeoJSONSource;
source.getClusterExpansionZoom(clusterId).then((zoom) => {
// Use the marker's current LngLat — set just below in updateMarkers.
const m = markers.get(`cluster:${clusterId}`);
const ll = m?.getLngLat();
if (!ll) return;
map!.easeTo({ center: ll, zoom });
});
});
return el;
}
/** Reconcile HTML markers against what's currently in the rendered
* source. PhotoPrism's `updateMarkers`. */
function updateMarkers() {
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
const features = map.querySourceFeatures('photos');
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
const seen = new Set<string>();
for (const f of features) {
const props = (f.properties ?? {}) as Record<string, unknown> & {
cluster?: boolean;
cluster_id?: number;
point_count?: number;
UID?: string;
Hash?: string;
Title?: string;
};
const geom = f.geometry;
if (geom.type !== 'Point') continue;
const coords = geom.coordinates as [number, number];
let key: string;
let buildEl: () => HTMLElement;
if (props.cluster) {
if (props.cluster_id == null) continue;
key = `cluster:${props.cluster_id}`;
const cid = props.cluster_id;
const count = props.point_count ?? 0;
buildEl = () => buildClusterMarker(cid, count);
} else {
if (!props.UID || !props.Hash) continue;
key = props.UID;
const uid = props.UID;
const hash = props.Hash;
const title = props.Title;
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
}
seen.add(key);
let marker = markers.get(key);
if (!marker) {
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
markers.set(key, marker);
} else {
marker.setLngLat(coords);
}
if (!markersOnScreen.has(key)) {
marker.addTo(map);
markersOnScreen.set(key, marker);
}
}
for (const [key, marker] of markersOnScreen) {
if (!seen.has(key)) {
marker.remove();
markersOnScreen.delete(key);
}
}
}
// Push new geo data into the source whenever the query resolves AND
// the map is ready. Both orderings are handled: if data arrives first,
// the effect re-runs when `mapReady` flips; if the map is ready first,
// it re-runs when `data` arrives.
$effect(() => {
const data = geoQuery.data as
| (PpGeoCollection & { bbox?: number[] })
| undefined;
if (!map || !mapReady || !data) return;
const src = map.getSource('photos') as GeoJSONSource | undefined;
if (!src) return;
src.setData(data as GeoJSON.FeatureCollection);
// Drop stale markers; updateMarkers will rebuild for the current
// visible set on the next `sourcedata` (fired by setData) or `idle`.
markersOnScreen.forEach((m) => m.remove());
markersOnScreen.clear();
markers.clear();
// Fit to data extent on the first non-empty load — prefer the
// server-provided bbox (PhotoPrism returns one), else compute from
// the features.
if ((data.features?.length ?? 0) > 0) {
let bounds: maplibregl.LngLatBoundsLike | null = null;
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
bounds = [
[data.bbox[0], data.bbox[1]],
[data.bbox[2], data.bbox[3]]
];
} else {
const b = new maplibregl.LngLatBounds();
for (const f of data.features as PpGeoFeature[]) {
const c = f.geometry.coordinates as [number, number];
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
}
if (!b.isEmpty()) bounds = b;
}
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
}
});
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Map
</span>
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{geoQuery.data?.features?.length ?? 0} geotagged
</span>
{/snippet}
</Toolbar>
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
<style>
/* PhotoPrism's marker / cluster styling, ported from
frontend/src/css/places.css. `:global` because MapLibre appends
markers outside Svelte's scoped CSS reach. */
:global(.maplibregl-map .marker) {
display: block;
border-radius: 50%;
cursor: pointer;
border: 1px solid #ffffff99;
background-color: rgba(23, 23, 23, 0.23);
background-size: cover;
background-position: center;
overflow: hidden;
position: relative;
box-shadow:
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}
:global(.maplibregl-map .cluster-marker) {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1px;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 50%;
}
:global(.maplibregl-map .cluster-marker > div) {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
:global(.maplibregl-map .badge) {
position: absolute;
top: -5px;
right: -5px;
min-width: 24px;
height: 24px;
padding: 0 6px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #ffffff;
background: #53478a;
box-shadow:
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
}
</style>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { openPreview } from '$lib/stores/preview.svelte';
// Deep-link entry: opening /photo/<uid> directly pops the overlay on
// the timeline. The route itself does not render anything; it hands
// off to the global PreviewOverlay and redirects to `/` so the URL
// stays clean and the timeline shows behind the modal.
$effect(() => {
const uid = page.params.uid as string | undefined;
if (!uid) return;
openPreview(uid);
void goto('/', { replaceState: true });
});
</script>

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getAllMarks,
listPhotos,
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
// they live in mule-sidecar's marks map. We fan in two queries: marks
// (UID → {rating, color}) and a recent slice of photos (UID → photo)
// so we can resolve the thumbnail hash for each rated UID.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const photosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'ratings-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated()
}));
interface RatingGroup {
rating: number;
photos: PpPhoto[];
}
const groups = $derived<RatingGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
function buildGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): RatingGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<number, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const r = mark.rating ?? 0;
if (r <= 0) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(r) ?? [];
arr.push(photo);
buckets.set(r, arr);
}
const out: RatingGroup[] = [];
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r);
if (photos && photos.length > 0) out.push({ rating: r, photos });
}
return out;
}
let selected = $state<number | null>(null);
const selectedGroup = $derived(
selected !== null ? groups.find((g) => g.rating === selected) ?? null : null
);
function pickGroup(rating: number) {
selected = rating;
}
function clearSelection() {
selected = null;
}
function starLabel(rating: number): string {
return '★'.repeat(rating);
}
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Ratings
</span>
{#if selectedGroup}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
>
← Back
</button>
<span class="text-[11px] font-medium text-yellow-500">
{starLabel(selectedGroup.rating)}
</span>
<span class="text-[11px] text-muted-foreground">
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
</span>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{groups.length} rating{groups.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
{:else if marksQuery.isError || photosQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if groups.length === 0}
<p class="text-sm text-muted-foreground">
No rated photos yet. Open a photo and use the star row in the right sidebar
(or 15 in bulk mode) to rate it.
</p>
{:else if selectedGroup}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
>
{#each selectedGroup.photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
<button
type="button"
onclick={() =>
openPreview(
photo.UID,
selectedGroup.photos.map((p) => p.UID)
)}
class="aspect-square 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.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
/>
</button>
{/each}
</div>
{:else}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
>
{#each groups as group (group.rating)}
{@const rep = group.photos[0]}
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => pickGroup(group.rating)}
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={starLabel(group.rating)}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="truncate font-medium text-yellow-500">
{starLabel(group.rating)}
</span>
<span class="text-muted-foreground">{group.photos.length}</span>
</div>
</button>
{/each}
</div>
{/if}
</main>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { listLabels, type PpLabel } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated()
}));
async function openLabel(slug: string) {
// Tags drive search — clicking jumps to the timeline with the label
// term applied. Bookmarkable URL via the existing filter sync.
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
}
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Tags
</span>
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
{:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p>
{:else if (labelsQuery.data ?? []).length === 0}
<p class="text-sm text-muted-foreground">
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
indexer hasn't run on real photos yet, the list will be empty.
</p>
{:else}
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
{#each labelsQuery.data ?? [] as label (label.UID)}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
>
{#if label.Thumb}
<img
src={thumbUrl(label.Thumb, 'tile_500')}
alt={label.Name}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
{/if}
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="truncate font-medium">{label.Name}</span>
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
</div>
</button>
{/each}
</div>
{/if}
</main>