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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user