Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts, log header, login screen) with neutral terms like "the indexer", "the library", "the server" — accurate regardless of backend. The login header becomes "Mulimage" and drops the explicit PhotoPrism mention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1005 lines
38 KiB
Svelte
1005 lines
38 KiB
Svelte
<script lang="ts">
|
||
import { browser } from "$app/environment";
|
||
import { goto } from "$app/navigation";
|
||
import { page } from "$app/state";
|
||
import {
|
||
createInfiniteQuery,
|
||
createQuery,
|
||
useQueryClient,
|
||
} from "@tanstack/svelte-query";
|
||
import { toast } from "svelte-sonner";
|
||
import {
|
||
batchDelete,
|
||
getPhoto,
|
||
listHeaps,
|
||
listPhotos,
|
||
type PpAlbum,
|
||
} from "$lib/services/photoprism";
|
||
import {
|
||
filters,
|
||
filtersToQ,
|
||
filtersToUrlParams,
|
||
parseUrlParams,
|
||
setSearch,
|
||
setSection,
|
||
} from "$lib/stores/filters.svelte";
|
||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||
import { untrack } from "svelte";
|
||
import {
|
||
clearSelection,
|
||
isSelected,
|
||
selectRange,
|
||
selection,
|
||
setAnchor,
|
||
setFocused,
|
||
setOrder,
|
||
} from "$lib/stores/selection.svelte";
|
||
import {
|
||
openPreview,
|
||
setRightSidebarWidth,
|
||
setThumbnailSize,
|
||
THUMBNAIL_SIZE_LABELS,
|
||
THUMBNAIL_SIZE_PRESETS,
|
||
view,
|
||
} from "$lib/stores/view.svelte";
|
||
import { tick } from "svelte";
|
||
import { resizable } from "$lib/actions/resizable";
|
||
import { gridKeyNav, type ArrowKey } from "$lib/actions/gridKeyNav";
|
||
import { nearBottom } from "$lib/actions/nearBottom";
|
||
import {
|
||
visibleRange,
|
||
getVisibleRangeHandle,
|
||
} from "$lib/actions/visibleRange";
|
||
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
|
||
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
|
||
import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
|
||
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
|
||
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
|
||
import Toolbar from "$lib/components/layout/Toolbar.svelte";
|
||
import { EmptyState, InlineLoader } from "$lib/components/feedback";
|
||
import {
|
||
AlertCircle,
|
||
Archive,
|
||
EyeOff,
|
||
ImageOff,
|
||
Layers,
|
||
MousePointerClick,
|
||
Sparkles,
|
||
} from "lucide-svelte";
|
||
import { type PpPhoto } from "$lib/types/photoprism";
|
||
|
||
// ── URL ↔ filter store sync ──────────────────────────────────────────────
|
||
// On nav (back/forward, deep link), reflect the URL into the store.
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
const next = parseUrlParams(page.url.searchParams);
|
||
if (next.section !== undefined) filters.section = next.section;
|
||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||
if (next.search !== undefined) filters.search = next.search;
|
||
});
|
||
|
||
// When the store changes from in-app actions (left-sidebar nav, search
|
||
// box, etc.), push the matching query string back so the URL is shareable.
|
||
let lastWritten = $state("");
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
const qs = filtersToUrlParams().toString();
|
||
const here = page.url.searchParams.toString();
|
||
if (qs === here || qs === lastWritten) return;
|
||
lastWritten = qs;
|
||
void goto(`/${qs ? "?" + qs : ""}`, {
|
||
replaceState: true,
|
||
keepFocus: true,
|
||
noScroll: true,
|
||
});
|
||
});
|
||
|
||
// ── Section title ────────────────────────────────────────────────────────
|
||
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||
queryKey: ["heaps"],
|
||
queryFn: listHeaps,
|
||
enabled: isAuthenticated(),
|
||
}));
|
||
|
||
const sectionLabel = $derived(buildSectionLabel());
|
||
function buildSectionLabel(): string {
|
||
switch (filters.section) {
|
||
case "review":
|
||
return "Review";
|
||
case "archive":
|
||
return "Archive";
|
||
case "hidden":
|
||
return "Hidden";
|
||
case "heap": {
|
||
const heap = (heapsQuery.data ?? []).find(
|
||
(h) => h.UID === filters.heapUid,
|
||
);
|
||
return heap ? `Heap · ${heap.Title}` : "Heap";
|
||
}
|
||
default:
|
||
// 'all-photos' is the internal "no section filter" state —
|
||
// the visible context now comes from the folder filter
|
||
// (root by default). Show the folder path so the title
|
||
// reflects what's actually on screen; only the rare
|
||
// `folderPath === null` case (e.g. right after deleting a
|
||
// heap) still reads as "All photos".
|
||
if (filters.folderPath === "/") return "Folder · /";
|
||
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
|
||
return "All photos";
|
||
}
|
||
}
|
||
|
||
// ── Photo list ───────────────────────────────────────────────────────────
|
||
// Infinite scroll. PhotoPrism's `/photos?merged=true` returns a multi-row-
|
||
// per-photo shape (HEIC + companion JPG + MOV each count toward `count`),
|
||
// so the page size is generous: 120 photo entries per page lands ~250-360
|
||
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
||
// downstream into a single `photos` array consumers iterate.
|
||
const PHOTOS_PAGE_SIZE = 120;
|
||
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
||
queryFn: ({ pageParam }) =>
|
||
listPhotos({
|
||
q: filtersToQ(filters),
|
||
count: PHOTOS_PAGE_SIZE,
|
||
offset: pageParam as number,
|
||
order: "newest",
|
||
merged: true,
|
||
}),
|
||
initialPageParam: 0,
|
||
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
|
||
// photo expands into its file rows, so a "full" page of count=120
|
||
// typically returns ~60 photo entries. The only reliable end-of-
|
||
// pagination signal is an empty page. Costs one extra fetch at the
|
||
// tail (cheap; the empty response is small).
|
||
getNextPageParam: (last, pages) =>
|
||
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
|
||
enabled: isAuthenticated(),
|
||
}));
|
||
|
||
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
|
||
* pages can repeat a photo when its file-row span straddles the offset
|
||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||
* preserves order.
|
||
*
|
||
* Split into two derived values so the heavy dedup pass only runs
|
||
* when the query's pages array changes (i.e. a fetch landed). The
|
||
* cheap folder-scope filter then runs whenever the user flips the
|
||
* filter store — search-as-you-type, section toggle, root vs.
|
||
* subfolder — without re-walking every page on each keystroke.
|
||
*
|
||
* When the user picks the root entry in the folder tree we filter
|
||
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||
* express that match, so the query fetches the whole library and
|
||
* we strip subfolder rows post-hoc. */
|
||
const dedupedAll = $derived<PpPhoto[]>(
|
||
dedupedPhotos(photosQuery.data?.pages),
|
||
);
|
||
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
|
||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||
if (!pages) return [];
|
||
const seen = new Set<string>();
|
||
const out: PpPhoto[] = [];
|
||
for (const page of pages) {
|
||
for (const p of page) {
|
||
if (seen.has(p.UID)) continue;
|
||
seen.add(p.UID);
|
||
out.push(p);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
// Root-folder scope is the only client-side filter we apply, and only
|
||
// when the timeline is actually showing a folder view — never when the
|
||
// user is in a heap, has a free-form search, or is on a non-default
|
||
// section (archive / review / hidden). Those views are
|
||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||
// or labels / search will silently drop subfolder photos when the
|
||
// store hasn't fully hydrated from the URL yet.
|
||
//
|
||
// Root (`/`) historically also restricted to `Path === ''` — i.e.
|
||
// photos sitting directly at the originals root with no subfolder.
|
||
// PhotoPrism's indexer however always nests photos under YYYY/MM, so
|
||
// that view was always empty in practice. Treat root as "the whole
|
||
// library" instead; subfolders still scope normally.
|
||
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
|
||
return list;
|
||
}
|
||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||
|
||
$effect(() => {
|
||
setOrder(photos.map((p) => p.UID));
|
||
});
|
||
|
||
/**
|
||
* Auto-focus the first photo on the FIRST page only. Subsequent pages
|
||
* append silently — we never want the focus to jump back to the top of
|
||
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
|
||
* from immediately re-triggering this effect.
|
||
*
|
||
* Always re-anchors to photos[0] when pageCount lands on 1 (which
|
||
* only happens on initial load or after a filter change resets the
|
||
* infinite-query) so switching views drops the user back at the top
|
||
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
|
||
* etc.) advance focus themselves via `focusAfter` and don't flip
|
||
* pageCount, so they don't get clobbered by this re-anchor.
|
||
*/
|
||
$effect(() => {
|
||
// Re-read pageCount so the effect bottoms out cleanly on filter
|
||
// changes (which reset pageCount back to 0/1).
|
||
const pages = pageCount;
|
||
untrack(() => {
|
||
if (photos.length === 0) {
|
||
setFocused(null);
|
||
return;
|
||
}
|
||
// Only re-anchor focus on the very first page; later pages
|
||
// must not pull focus back to photo[0].
|
||
if (pages !== 1) return;
|
||
setFocused(photos[0].UID);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Flatten photos + month headers into a single row list. PhotoPrism
|
||
* returns photos pre-sorted by TakenAt; we emit a header row whenever
|
||
* the month-key changes, and tile rows for every photo in order. The
|
||
* tile's `tileIndex` matches its position in `photos`, which is what
|
||
* the windowing observer keys off of (so the visible-range math stays
|
||
* one-dimensional even though the template renders headers inline).
|
||
*/
|
||
type Row =
|
||
| { kind: "header"; key: string; label: string; count: number }
|
||
| { kind: "tile"; photo: PpPhoto; tileIndex: number };
|
||
const rows = $derived<Row[]>(buildRows(photos));
|
||
|
||
function buildRows(list: PpPhoto[]): Row[] {
|
||
const out: Row[] = [];
|
||
const fmt = new Intl.DateTimeFormat(undefined, {
|
||
month: "long",
|
||
year: "numeric",
|
||
});
|
||
// First pass: count per month — used by the header chip. Sparse
|
||
// Map: O(months) memory, O(photos) time, both trivial at 10k.
|
||
const counts = new Map<string, number>();
|
||
const labels = new Map<string, string>();
|
||
for (const p of list) {
|
||
const { key, label } = monthKey(p, fmt);
|
||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||
if (!labels.has(key)) labels.set(key, label);
|
||
}
|
||
// Second pass: emit rows in document order.
|
||
let prev = "";
|
||
for (let i = 0; i < list.length; i++) {
|
||
const p = list[i];
|
||
const { key } = monthKey(p, fmt);
|
||
if (key !== prev) {
|
||
out.push({
|
||
kind: "header",
|
||
key,
|
||
label: labels.get(key) ?? "",
|
||
count: counts.get(key) ?? 0,
|
||
});
|
||
prev = key;
|
||
}
|
||
out.push({ kind: "tile", photo: p, tileIndex: i });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function monthKey(
|
||
p: PpPhoto,
|
||
fmt: Intl.DateTimeFormat,
|
||
): { key: string; label: string } {
|
||
const raw = p.TakenAtLocal ?? p.TakenAt ?? "";
|
||
if (!raw) return { key: "no-date", label: "No date" };
|
||
const d = new Date(raw);
|
||
if (Number.isNaN(d.getTime())) return { key: "no-date", label: "No date" };
|
||
const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||
return { key: k, label: fmt.format(d) };
|
||
}
|
||
|
||
// Visible-range window. The observer reports `first`/`last` based on
|
||
// sampled tiles (every 5th, PhotoPrism's value). We render tiles in
|
||
// [first - BUFFER, last + BUFFER] plus any `forcedExpand` index that
|
||
// keyboard scrollToIndex has demanded (so a focused tile that's
|
||
// currently windowed-out is mounted before scrollIntoView runs).
|
||
const TILE_BUFFER = 4;
|
||
const TILE_SAMPLE = 5;
|
||
// Seed `visLast` generously so the first paint already shows a screen-
|
||
// ful of tiles instead of a single buffer of 4. The IntersectionObserver
|
||
// narrows it on the next layout pass.
|
||
const INITIAL_VIS_LAST = 60;
|
||
let visFirst = $state(0);
|
||
let visLast = $state(INITIAL_VIS_LAST);
|
||
let forcedExpand = $state<number | null>(null);
|
||
const renderFirst = $derived(
|
||
Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER),
|
||
);
|
||
const renderLast = $derived(
|
||
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER,
|
||
);
|
||
|
||
// Hoist the selection reads out of the per-tile each-block. The same
|
||
// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
|
||
// just expressed at the loop level so the render reads as "given this
|
||
// (selected, focused) snapshot, here's each tile's state". No reactivity
|
||
// change — SvelteSet's `has` already subscribes per-key — but it removes
|
||
// the function-call indirection and keeps the hot loop body terse.
|
||
const selectedIds = $derived(selection.ids);
|
||
const focusedUid = $derived(selection.focused);
|
||
|
||
// Reset the window when the filter key changes — old indices from the
|
||
// previous photo list otherwise pin renderFirst/renderLast outside the
|
||
// new shorter list and the grid renders empty. Track `filtersToQ` as
|
||
// the trigger; appending pages (which keeps the same filter key) does
|
||
// NOT reset, so scroll position stays stable mid-pagination.
|
||
$effect(() => {
|
||
filtersToQ(filters);
|
||
untrack(() => {
|
||
visFirst = 0;
|
||
visLast = INITIAL_VIS_LAST;
|
||
forcedExpand = null;
|
||
});
|
||
});
|
||
|
||
/** `use:tileRegister={i}` — stable-identity Svelte action that hooks
|
||
* the tile shell into the visibility observer when it mounts and
|
||
* un-hooks it when it unmounts (or when `i` changes because the
|
||
* photos array shifted). Using a `use:` action (not `{@attach}`)
|
||
* keeps the registration stable across re-renders; `{@attach}` would
|
||
* rebuild on every render because the inline arrow has fresh
|
||
* identity each time.
|
||
*
|
||
* Resolve the handle lazily off the scroll root each call instead
|
||
* of stashing it in a `$state` populated by `$effect`. The effect
|
||
* runs *after* the DOM update flush, but on a remount with cached
|
||
* photo data the tiles render in the same pass as the scroll root,
|
||
* so a `$state`-backed handle is still `null` when tileRegister
|
||
* first fires — and the tile never enrols in the observer. Symptom
|
||
* was a blank grid on return-trip to the timeline (the bug this
|
||
* comment exists for). The visibleRange action sets
|
||
* `__visibleRange` on the scroll-root node during its setup phase,
|
||
* which runs before any child action, so a synchronous lookup
|
||
* always succeeds. */
|
||
function tileRegister(node: HTMLElement, index: number) {
|
||
let current = index;
|
||
const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
|
||
handle?.register(node, current);
|
||
return {
|
||
update(next: number) {
|
||
if (next === current) return;
|
||
handle?.unregister(node);
|
||
current = next;
|
||
handle?.register(node, current);
|
||
},
|
||
destroy() {
|
||
handle?.unregister(node);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Scroll the given tile fully into view. The default `scrollIntoView`
|
||
* ignores the sticky month header overlaying the top of the grid, so
|
||
* arrow-up into the row immediately under a header would leave the
|
||
* tile occluded. We compute the visible region manually, subtract
|
||
* the sticky header height from the top, and add a 25% "peek" so
|
||
* the focused tile lands with breathing room rather than flush
|
||
* against the viewport edge — same heuristic mule-image's Timeline
|
||
* uses in its `scrollRowIntoView`. */
|
||
function scrollTileIntoView(el: HTMLElement) {
|
||
if (!scrollRoot) return;
|
||
const stickyH =
|
||
scrollRoot.querySelector<HTMLElement>("h2.sticky")?.offsetHeight ?? 0;
|
||
const rootRect = scrollRoot.getBoundingClientRect();
|
||
const elRect = el.getBoundingClientRect();
|
||
const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
|
||
const elBot = elTop + elRect.height;
|
||
const peek = Math.round(elRect.height * 0.25);
|
||
const viewTop = scrollRoot.scrollTop + stickyH;
|
||
const viewBot = scrollRoot.scrollTop + scrollRoot.clientHeight;
|
||
if (elTop - peek < viewTop) {
|
||
scrollRoot.scrollTo({ top: Math.max(0, elTop - peek - stickyH) });
|
||
} else if (elBot + peek > viewBot) {
|
||
scrollRoot.scrollTo({ top: elBot + peek - scrollRoot.clientHeight });
|
||
}
|
||
}
|
||
|
||
/** Called when arrow-keying lands on a tile that isn't currently
|
||
* rendered. Forces the window to include the target, waits for the
|
||
* tile shell to mount, then scrolls it fully into view. */
|
||
async function scrollToIndex(i: number) {
|
||
forcedExpand = i;
|
||
await tick();
|
||
if (!scrollRoot) return;
|
||
const el = scrollRoot.querySelector<HTMLElement>(
|
||
`[data-uid-shell="${photos[i]?.UID ?? ""}"]`,
|
||
);
|
||
if (el) scrollTileIntoView(el);
|
||
}
|
||
|
||
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||
// The CSS Grid lays each photo into a cell with column count derived from
|
||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||
// full row, so a new month forces a row break even if the previous month's
|
||
// last row had empty slots. Linear "+/-cols" arrow math doesn't account
|
||
// for that and crosses headers wrong; mule-image's `useGridKeyNav` solves
|
||
// it by operating on an explicit `string[][]` visual-row map. We do the
|
||
// same: build the row map from `photos` + `tilesPerRow` + month
|
||
// boundaries, then translate arrow keys into (row, col) moves.
|
||
|
||
/** Cached column count of the photo grid. Measured from
|
||
* `grid-template-columns` on the grid element itself (the only place
|
||
* with a reliable value) via a Svelte action that runs *after* the
|
||
* grid mounts. An earlier $effect-based version observed
|
||
* `scrollRoot` and ran before the {#if photos.length > 0} branch
|
||
* evaluated, so the grid element didn't exist and `cols` stayed at
|
||
* 1 — making the arrow-keyboard nav feel like a 1-column list. */
|
||
let cols = $state(1);
|
||
let gridEl: HTMLElement | undefined = $state();
|
||
|
||
function trackGridCols(node: HTMLElement) {
|
||
gridEl = node;
|
||
const measure = () => {
|
||
const n = getComputedStyle(node)
|
||
.gridTemplateColumns.split(" ")
|
||
.filter(Boolean).length;
|
||
cols = Math.max(1, n);
|
||
};
|
||
measure();
|
||
// ResizeObserver fires on width changes (sidebar toggle, window
|
||
// resize, container reflow). Thumbnail-size changes don't change
|
||
// the grid's width but DO change its column count — handled by a
|
||
// separate effect below that re-runs `measure` on the next tick.
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(node);
|
||
return {
|
||
destroy() {
|
||
ro.disconnect();
|
||
if (gridEl === node) gridEl = undefined;
|
||
},
|
||
};
|
||
}
|
||
|
||
// Thumbnail-size changes alter column count without resizing the grid,
|
||
// so the ResizeObserver above misses them. Re-measure on the next
|
||
// microtask so the new computed style is in place.
|
||
$effect(() => {
|
||
void view.thumbnailSize;
|
||
queueMicrotask(() => {
|
||
if (!gridEl) return;
|
||
const n = getComputedStyle(gridEl)
|
||
.gridTemplateColumns.split(" ")
|
||
.filter(Boolean).length;
|
||
cols = Math.max(1, n);
|
||
});
|
||
});
|
||
|
||
interface VisualRow {
|
||
uids: string[];
|
||
/** Index of the first tile in this row, in the flat `photos` array.
|
||
* Used to call `scrollToIndex` after a move. */
|
||
firstTileIndex: number;
|
||
}
|
||
/** Pre-broken visual rows + a `uid → (row, col)` index. Whenever a new
|
||
* month begins, the prior row is flushed regardless of how full it was
|
||
* — this matches the CSS Grid where a full-span header forces the
|
||
* next tile onto a fresh row. */
|
||
const visualGrid = $derived(buildVisualGrid(photos, cols));
|
||
|
||
function buildVisualGrid(
|
||
list: PpPhoto[],
|
||
colsPerRow: number,
|
||
): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
|
||
const rows: VisualRow[] = [];
|
||
const pos = new Map<string, [number, number]>();
|
||
const fmt = new Intl.DateTimeFormat(undefined, {
|
||
month: "long",
|
||
year: "numeric",
|
||
});
|
||
let curMonth = "";
|
||
let curRow: VisualRow | null = null;
|
||
for (let i = 0; i < list.length; i++) {
|
||
const p = list[i];
|
||
const { key } = monthKey(p, fmt);
|
||
const monthChanged = key !== curMonth;
|
||
const rowFull = curRow !== null && curRow.uids.length >= colsPerRow;
|
||
if (!curRow || monthChanged || rowFull) {
|
||
curRow = { uids: [], firstTileIndex: i };
|
||
rows.push(curRow);
|
||
curMonth = key;
|
||
}
|
||
pos.set(p.UID, [rows.length - 1, curRow.uids.length]);
|
||
curRow.uids.push(p.UID);
|
||
}
|
||
return { rows, pos };
|
||
}
|
||
|
||
// "Intended" column for sticky-column behaviour. Updated by horizontal
|
||
// arrow presses; vertical presses look up the destination cell with
|
||
// this column clamped to the destination row's width — so traversing
|
||
// a partial row doesn't permanently drift your column. Reset whenever
|
||
// focus is established by a non-arrow path (click, Escape).
|
||
let intendedCol: number | null = $state(null);
|
||
$effect(() => {
|
||
// Auto-clear when focus is dropped (Escape, photos refetch, etc.).
|
||
if (selection.focused === null) intendedCol = null;
|
||
});
|
||
|
||
function onArrow(key: ArrowKey, extending: boolean) {
|
||
const { rows, pos } = visualGrid;
|
||
if (rows.length === 0) return;
|
||
|
||
// Resolve starting (row, col). Without a focused tile, default to
|
||
// the top-left so the first ArrowDown/Right lands on the first
|
||
// real photo instead of doing nothing. The explicit tuple type
|
||
// keeps TS from widening `[number, number]` to `string | number`.
|
||
const cur = selection.focused ? pos.get(selection.focused) : undefined;
|
||
const start: [number, number] = cur ?? [0, -1];
|
||
let r = start[0];
|
||
let c = start[1];
|
||
|
||
// Bootstrap intendedCol from the current column so the first
|
||
// vertical move preserves whatever column the user is already on.
|
||
if (intendedCol === null) intendedCol = c < 0 ? 0 : c;
|
||
|
||
if (key === "ArrowLeft" || key === "ArrowRight") {
|
||
c += key === "ArrowRight" ? 1 : -1;
|
||
// Wrap across row boundaries (LeftArrow at col 0 → previous
|
||
// row's last col, RightArrow at last col → next row's col 0).
|
||
while (c < 0 && r > 0) {
|
||
r -= 1;
|
||
c = rows[r].uids.length - 1;
|
||
}
|
||
while (r < rows.length && c >= rows[r].uids.length) {
|
||
if (r === rows.length - 1) {
|
||
c = rows[r].uids.length - 1;
|
||
break;
|
||
}
|
||
r += 1;
|
||
c = 0;
|
||
}
|
||
if (c < 0) c = 0;
|
||
// User explicitly chose this column → it's the new "home" for
|
||
// subsequent vertical moves.
|
||
intendedCol = c;
|
||
} else {
|
||
r += key === "ArrowDown" ? 1 : -1;
|
||
if (r < 0) r = 0;
|
||
if (r >= rows.length) r = rows.length - 1;
|
||
// Vertical: prefer the intended column; clamp to destination
|
||
// row width so a narrower row doesn't fall off the edge.
|
||
// intendedCol is preserved so going down through narrow rows
|
||
// then back up returns to the original column.
|
||
const w = rows[r].uids.length;
|
||
c = Math.min(intendedCol, w - 1);
|
||
if (c < 0) c = 0;
|
||
}
|
||
|
||
const destUid = rows[r]?.uids[c];
|
||
if (!destUid) return;
|
||
|
||
// Plain arrow nav collapses any prior multi-selection to the cursor
|
||
// (mirrors gridKeyNav.moveFocus). Shift-extend keeps `ids` growing
|
||
// from the anchor below.
|
||
if (!extending) clearSelection();
|
||
setFocused(destUid);
|
||
if (!extending) setAnchor(destUid);
|
||
if (extending && selection.focused) selectRange(destUid);
|
||
|
||
// Try the cheap path first: rendered tile, our scroll helper that
|
||
// respects the sticky header. Fall back to scrollToIndex (windowing
|
||
// expand + scroll) when the destination is currently unmounted.
|
||
const tile = scrollRoot?.querySelector<HTMLElement>(
|
||
`[data-uid="${destUid}"]`,
|
||
);
|
||
if (tile) {
|
||
scrollTileIntoView(tile);
|
||
} else {
|
||
const flatIdx = rows[r].firstTileIndex + c;
|
||
scrollToIndex(flatIdx);
|
||
}
|
||
}
|
||
|
||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||
queryKey: ["photo", selection.focused ?? ""],
|
||
queryFn: () =>
|
||
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||
enabled: isAuthenticated() && Boolean(selection.focused),
|
||
staleTime: 0,
|
||
}));
|
||
|
||
const qc = useQueryClient();
|
||
let emptyingArchive = $state(false);
|
||
|
||
/**
|
||
* Walk every archived photo and delete it in pages. The timeline's
|
||
* infinite query only holds what the user has scrolled; we re-query
|
||
* `archived:true` directly so even unloaded pages get cleared.
|
||
* PhotoPrism caps `count` at 1000 — pull the max each round so we
|
||
* spend one HTTP call per chunk.
|
||
*/
|
||
async function onEmptyArchive() {
|
||
if (emptyingArchive) return;
|
||
if (
|
||
!confirm(
|
||
"Permanently delete EVERY photo in the Archive? This cannot be undone.",
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
emptyingArchive = true;
|
||
let total = 0;
|
||
try {
|
||
while (true) {
|
||
const batch = await listPhotos({
|
||
q: "archived:true",
|
||
count: 1000,
|
||
offset: 0,
|
||
order: "newest",
|
||
merged: false,
|
||
});
|
||
if (batch.length === 0) break;
|
||
const uids = Array.from(new Set(batch.map((p) => p.UID)));
|
||
await batchDelete(uids);
|
||
total += uids.length;
|
||
}
|
||
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "Empty archive failed");
|
||
} finally {
|
||
emptyingArchive = false;
|
||
void qc.invalidateQueries({ queryKey: ["photos"] });
|
||
}
|
||
}
|
||
|
||
function onTileClick(e: MouseEvent, uid: string) {
|
||
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
|
||
// document-level click handler — let them bubble.
|
||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||
// Plain click: select this tile only. Replaces the previous
|
||
// "click opens preview" semantics — preview now lives on dblclick.
|
||
// Reset both focus and anchor so a subsequent shift-click extends
|
||
// the range from this tile, and clear sticky-column intent so
|
||
// arrow nav re-anchors off this tile's actual column.
|
||
selection.ids.clear();
|
||
selection.ids.add(uid);
|
||
setFocused(uid);
|
||
setAnchor(uid);
|
||
intendedCol = null;
|
||
}
|
||
|
||
function onTileDblclick(e: MouseEvent, uid: string) {
|
||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||
e.preventDefault();
|
||
selection.ids.clear();
|
||
selection.ids.add(uid);
|
||
setFocused(uid);
|
||
setAnchor(uid);
|
||
openPreview();
|
||
}
|
||
|
||
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
||
// the <main> element below; the sentinel's `root` references this so
|
||
// the observer measures intersections relative to the timeline pane
|
||
// (the page itself doesn't scroll).
|
||
let scrollRoot: HTMLElement | undefined = $state();
|
||
|
||
let searchDraft = $state(filters.search);
|
||
$effect(() => {
|
||
searchDraft = filters.search;
|
||
});
|
||
function onSearchSubmit(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
setSearch(searchDraft.trim());
|
||
}
|
||
|
||
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||
const SEARCH_EXAMPLES = [
|
||
"label:dog",
|
||
"keyword:vacation",
|
||
"taken:2024",
|
||
'"exact phrase"',
|
||
];
|
||
let searchFocused = $state(false);
|
||
function onSearchFocus() {
|
||
searchFocused = true;
|
||
}
|
||
function onSearchBlur() {
|
||
// Defer so a click on an example fires before the popover unmounts.
|
||
setTimeout(() => (searchFocused = false), 120);
|
||
}
|
||
function applySearchExample(ex: string) {
|
||
searchDraft = ex;
|
||
setSearch(ex);
|
||
searchFocused = false;
|
||
}
|
||
</script>
|
||
|
||
<Toolbar showRightToggle>
|
||
<span
|
||
class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||
>
|
||
{sectionLabel}
|
||
</span>
|
||
{#if filters.section === "archive" && photos.length > 0}
|
||
<button
|
||
type="button"
|
||
class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||
disabled={emptyingArchive}
|
||
onclick={onEmptyArchive}
|
||
title="Permanently delete every archived photo"
|
||
>
|
||
{emptyingArchive ? "Emptying…" : "Empty Archive"}
|
||
</button>
|
||
{/if}
|
||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||
<input
|
||
type="search"
|
||
placeholder={'Search · label:website / "vacation"'}
|
||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||
bind:value={searchDraft}
|
||
onfocus={onSearchFocus}
|
||
onblur={onSearchBlur}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||
>
|
||
Go
|
||
</button>
|
||
{#if filters.search}
|
||
<button
|
||
type="button"
|
||
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
|
||
onclick={() => {
|
||
searchDraft = "";
|
||
setSearch("");
|
||
}}
|
||
title="Clear search"
|
||
>
|
||
✕
|
||
</button>
|
||
{/if}
|
||
<!--
|
||
Cheat-sheet popover: opens on input focus, lists working q-DSL
|
||
patterns. Clicking an example fills the input AND fires the
|
||
search, so it doubles as a one-click "try it" affordance.
|
||
-->
|
||
{#if searchFocused}
|
||
<div
|
||
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
|
||
>
|
||
<div
|
||
class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||
>
|
||
Examples
|
||
</div>
|
||
{#each SEARCH_EXAMPLES as ex (ex)}
|
||
<button
|
||
type="button"
|
||
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
|
||
onmousedown={(e) => {
|
||
e.preventDefault();
|
||
applySearchExample(ex);
|
||
}}
|
||
>
|
||
{ex}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</form>
|
||
|
||
{#snippet trailing()}
|
||
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||
Persisted to localStorage via view.svelte.ts. -->
|
||
<div
|
||
class="flex items-center overflow-hidden rounded border border-border"
|
||
role="group"
|
||
aria-label="Thumbnail size"
|
||
>
|
||
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||
<button
|
||
type="button"
|
||
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||
class:bg-accent={view.thumbnailSize === size}
|
||
class:text-foreground={view.thumbnailSize === size}
|
||
class:text-muted-foreground={view.thumbnailSize !== size}
|
||
onclick={() => setThumbnailSize(size)}
|
||
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||
>
|
||
{THUMBNAIL_SIZE_LABELS[i]}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/snippet}
|
||
</Toolbar>
|
||
|
||
<div class="flex min-h-0 flex-1">
|
||
<!--
|
||
Main column wraps the scrollable timeline and the action bar so
|
||
the bar's width matches the timeline only — the right aside is a
|
||
sibling at row level and stays full height when the bar appears.
|
||
-->
|
||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||
<main
|
||
bind:this={scrollRoot}
|
||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||
use:gridKeyNav={{ scrollToIndex, onArrow }}
|
||
use:visibleRange={{
|
||
onChange: (f, l) => {
|
||
visFirst = f;
|
||
visLast = l;
|
||
},
|
||
sampleEvery: TILE_SAMPLE,
|
||
}}
|
||
>
|
||
<div class="pr-2 pl-2 pb-2 overflow-x-hidden">
|
||
{#if photosQuery.isPending}
|
||
<SkeletonGrid />
|
||
{:else if photosQuery.isError}
|
||
<EmptyState
|
||
tone="destructive"
|
||
icon={AlertCircle}
|
||
title="Failed to load photos"
|
||
description={photosQuery.error instanceof Error
|
||
? photosQuery.error.message
|
||
: "unknown error"}
|
||
/>
|
||
{:else if photos.length === 0}
|
||
{#if filters.section === "archive"}
|
||
<EmptyState icon={Archive} title="Archive is empty" />
|
||
{:else if filters.section === "review"}
|
||
<EmptyState
|
||
icon={Sparkles}
|
||
title="Nothing left to review"
|
||
description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
|
||
/>
|
||
{:else if filters.section === "hidden"}
|
||
<EmptyState
|
||
icon={EyeOff}
|
||
title="No hidden photos"
|
||
description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
|
||
/>
|
||
{:else if filters.section === "heap"}
|
||
<EmptyState
|
||
icon={Layers}
|
||
title="This heap has no photos yet"
|
||
description={'Select some photos and use the bulk bar’s “+ Add to heap” button.'}
|
||
/>
|
||
{:else}
|
||
<EmptyState
|
||
icon={ImageOff}
|
||
title="No photos"
|
||
description="Index a folder from Settings → Index, or run a reindex from the server."
|
||
/>
|
||
{/if}
|
||
{:else}
|
||
<div
|
||
data-photo-grid
|
||
use:trackGridCols
|
||
class="grid gap-2"
|
||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||
>
|
||
{#each rows as row (row.kind === "header" ? `h:${row.key}` : `t:${row.photo.UID}`)}
|
||
{#if row.kind === "header"}
|
||
<!-- col-span-full + position:sticky pins the month label to
|
||
the top of the scrolling main as the user passes through.
|
||
-mx-6 stretches the bar past the wrapper padding so it
|
||
reads edge-to-edge in the viewport. -->
|
||
<h2
|
||
class="sticky top-0 z-10 -mx-6 border-b border-border bg-background/95 px-6 py-2 text-xs font-semibold uppercase tracking-wide text-foreground/80 backdrop-blur"
|
||
style="grid-column: 1 / -1;"
|
||
>
|
||
{row.label}
|
||
<span
|
||
class="ml-2 text-[10px] font-normal text-muted-foreground"
|
||
>
|
||
{row.count}
|
||
</span>
|
||
</h2>
|
||
{:else}
|
||
{@const photo = row.photo}
|
||
{@const i = row.tileIndex}
|
||
{@const inWindow = i >= renderFirst && i <= renderLast}
|
||
<!-- Shell: always rendered. Holds grid-cell space + the
|
||
stable `data-uid-shell` anchor that gridKeyNav.scrollToIndex
|
||
can query even when the inner button is windowed out. -->
|
||
<div
|
||
data-uid-shell={photo.UID}
|
||
class="aspect-square"
|
||
use:tileRegister={i}
|
||
>
|
||
{#if inWindow}
|
||
{@const sel =
|
||
selectedIds.has(photo.UID) ||
|
||
focusedUid === photo.UID}
|
||
<PhotoTile
|
||
{photo}
|
||
selected={sel}
|
||
onClick={(e) => onTileClick(e, photo.UID)}
|
||
onDblclick={(e) => onTileDblclick(e, photo.UID)}
|
||
/>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Sentinel: when this div nears the viewport we kick the next
|
||
page. The 4-viewport rootMargin (handled by the action) is
|
||
PhotoPrism's preload distance from page/photos.vue. -->
|
||
<div
|
||
aria-hidden="true"
|
||
class="h-4"
|
||
use:nearBottom={{
|
||
onHit: () => photosQuery.fetchNextPage(),
|
||
enabled:
|
||
photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
|
||
root: scrollRoot,
|
||
}}
|
||
></div>
|
||
{#if photosQuery.isFetchingNextPage}
|
||
<InlineLoader
|
||
size="sm"
|
||
align="center"
|
||
polite={false}
|
||
label="Loading more photos…"
|
||
/>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</main>
|
||
<BulkActionBar />
|
||
</div>
|
||
|
||
{#if !view.rightSidebarCollapsed}
|
||
<aside
|
||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||
style="width: {view.rightSidebarWidth}px;"
|
||
>
|
||
<div class="h-full overflow-y-auto">
|
||
{#if selection.ids.size >= 2}
|
||
<!-- Multi-select swaps to a bulk-edit panel: edits fan out across
|
||
the whole selection (note / date / keyword). The single-photo
|
||
metadata view returns when the user drops back to one. -->
|
||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||
{:else if focusedPhotoQuery.data}
|
||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||
{:else if focusedPhotoQuery.isFetching}
|
||
<InlineLoader size="sm" label="Loading metadata…" />
|
||
{:else}
|
||
<EmptyState icon={MousePointerClick} title="No photo selected">
|
||
{#snippet descriptionSnippet()}
|
||
<p>
|
||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd
|
||
>+click on a thumbnail to view its metadata here.
|
||
</p>
|
||
{/snippet}
|
||
</EmptyState>
|
||
{/if}
|
||
</div>
|
||
<!-- Resize handle on the left edge; mirrors the layout's left aside
|
||
hot-zone for symmetry. -->
|
||
<div
|
||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||
use:resizable={{
|
||
edge: "left",
|
||
getWidth: () => view.rightSidebarWidth,
|
||
setWidth: setRightSidebarWidth,
|
||
}}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="Resize info panel"
|
||
>
|
||
<div
|
||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||
></div>
|
||
</div>
|
||
</aside>
|
||
{/if}
|
||
</div>
|