web: deep-link from RightSidebar to folder/map + anchor-mode timeline + fix root count

RightSidebar:
- Folder + Location rows gain a small ArrowUpRight icon button that
  deep-links into the timeline / map view focused on the photo. OSM
  external link removed; the in-app map nav covers the same job.

Folder navigation:
- New navigateToFolder(path, { focusUid, focusTakenAt }) helper in the
  filters store; LeftSidebar's pickFolder collapses to a one-liner that
  reuses it.
- One-shot pending-focus stash carries both UID and TakenAt across the
  goto. URL-watch effect on the timeline consumes the stash so even
  same-folder navigations (where the filter doesn't change) get
  picked up.

Anchor-mode timeline query:
- listPhotosAround(q, takenAt, after, before) issues two parallel
  PhotoPrism calls (`after:<day-1>` oldest-first + `before:<day+1>`
  newest-first), merges + dedupes newest-first. Uses PhotoPrism's
  existing date-only DSL clauses — no server changes.
- When a deep-link stashes a TakenAt, page 0 of the photosQuery uses
  the merged window so the target photo is loaded even for photos
  buried past the standard newest-first cursor. Pages 1+ are disabled
  in anchor mode (PhotoPrism's day-precision cursor would infinite-loop
  on dense days; users see 120 around the target, refresh to drop the
  anchor).
- After page 0 lands, the existing scrollToIndex(targetIdx) expands the
  windowed render set + scrolls the tile into view.

Map view:
- /map honors `?lat=&lng=&zoom=&focus=` URL params, jumping to the
  photo's coordinates at zoom 17 instead of fitBounds-ing the full
  library. Params are stripped after first apply so a manual zoom-out
  + reload doesn't snap back.

LeftSidebar root count badge:
- Now matches what Cmd+A selects in the timeline. Old code used
  /config.count.all (library aggregate, includes archived/hidden/
  review). Switched to countPhotos('', { merged: true }) which counts
  the actual photo entries the timeline lists.
- countPhotos gains a `merged` option; with merged=true it returns the
  response body length instead of the X-Count header — PhotoPrism's
  X-Count is always the file-row count regardless of merged, so a
  HEIC + JPG companion pair inflated the badge to 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:04:23 +02:00
parent c134afe023
commit 29f7ad7073
7 changed files with 363 additions and 55 deletions

View File

@@ -156,6 +156,95 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
return data;
}
/**
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
* older photos preceded by `after` newer ones, merged newest-first.
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
* neighbours can be loaded without paging through the whole filter.
*
* Used by the timeline's deep-link focus mode: an in-app navigation
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
* date for page 0, and the target photo lands ~`afterCount` tiles
* down with `~beforeCount` older neighbours below it.
*
* Subsequent infinite-scroll pages use plain `listPhotos` with the
* standard offset cursor — the anchor mode only matters for page 0.
*/
export interface AroundParams {
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
q?: string;
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
takenAt: string;
/** How many photos newer than the anchor to fetch. */
afterCount?: number;
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
beforeCount?: number;
merged?: boolean;
}
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
const afterCount = p.afterCount ?? 30;
const beforeCount = p.beforeCount ?? 90;
const baseQ = p.q?.trim() ?? '';
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
// the `before:` half (so the target tile is in the merged result).
const anchorDate = new Date(p.takenAt);
if (Number.isNaN(anchorDate.getTime())) {
// Date parse failed — fall back to a plain newest-first page.
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
}
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
// up/down by a day so the anchor's own day is included in the
// `before:` half — the bounds are inclusive day boundaries, so a
// timestamp-precision anchor lands inside the `[beforeBound,
// afterBound]` window.
function ymd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
const dayMs = 86_400_000;
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
const newerQ = `${baseQ} after:${afterBound}`.trim();
const olderQ = `${baseQ} before:${beforeBound}`.trim();
const [newerOldestFirst, older] = await Promise.all([
listPhotos({
q: newerQ,
count: afterCount,
order: 'oldest',
merged: p.merged ?? true
}),
listPhotos({
q: olderQ,
count: beforeCount,
order: 'newest',
merged: p.merged ?? true
})
]);
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
// to match the standard timeline order, then concat the older window.
// Dedupe by UID in case the anchor itself shows up in both halves.
const merged: PpPhoto[] = [];
const seen = new Set<string>();
for (const p of newerOldestFirst.slice().reverse()) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
for (const p of older) {
if (!seen.has(p.UID)) {
merged.push(p);
seen.add(p.UID);
}
}
return merged;
}
/**
* Count photos matching a DSL query, scoped to whatever the caller's
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
@@ -169,10 +258,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
* the signed-in user actually sees, not the global library aggregate
* exposed by `/config.count`.
*/
export async function countPhotos(q: string): Promise<number> {
const resp = await http.get('/photos', {
params: { count: 10000, offset: 0, merged: false, q }
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
const merged = opts.merged ?? false;
const resp = await http.get<PpPhoto[]>('/photos', {
params: { count: 10000, offset: 0, merged, q }
});
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
// `Files[]` entry), regardless of `merged`. With `merged: true` the
// response body is one entry per logical photo — so the body length
// is the canonical photo count when callers need to match what the
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
const header = resp.headers['x-count'];
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
return Number.isFinite(n) ? n : 0;