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:
84
web/src/routes/+layout.svelte
Normal file
84
web/src/routes/+layout.svelte
Normal 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>
|
||||
4
web/src/routes/+layout.ts
Normal file
4
web/src/routes/+layout.ts
Normal 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
871
web/src/routes/+page.svelte
Normal 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 />
|
||||
180
web/src/routes/colors/+page.svelte
Normal file
180
web/src/routes/colors/+page.svelte
Normal 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>
|
||||
67
web/src/routes/duplicates/+page.svelte
Normal file
67
web/src/routes/duplicates/+page.svelte
Normal 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>
|
||||
78
web/src/routes/login/+page.svelte
Normal file
78
web/src/routes/login/+page.svelte
Normal 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>
|
||||
388
web/src/routes/map/+page.svelte
Normal file
388
web/src/routes/map/+page.svelte
Normal 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>
|
||||
16
web/src/routes/photo/[uid]/+page.svelte
Normal file
16
web/src/routes/photo/[uid]/+page.svelte
Normal 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>
|
||||
171
web/src/routes/ratings/+page.svelte
Normal file
171
web/src/routes/ratings/+page.svelte
Normal 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 1–5 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>
|
||||
68
web/src/routes/tags/+page.svelte
Normal file
68
web/src/routes/tags/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user