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:
@@ -13,15 +13,18 @@
|
||||
getPhoto,
|
||||
listHeaps,
|
||||
listPhotos,
|
||||
listPhotosAround,
|
||||
type PpAlbum,
|
||||
} from "$lib/services/photoprism";
|
||||
import {
|
||||
consumePendingFocus,
|
||||
filters,
|
||||
filtersToQ,
|
||||
filtersToUrlParams,
|
||||
parseUrlParams,
|
||||
setSearch,
|
||||
setSection,
|
||||
type PendingFocus,
|
||||
} from "$lib/stores/filters.svelte";
|
||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||
import { untrack } from "svelte";
|
||||
@@ -137,24 +140,90 @@
|
||||
// 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;
|
||||
|
||||
// Anchor mode: when an in-app deep link stashes a pending focus with a
|
||||
// TakenAt, the first page is fetched as a window around that date via
|
||||
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
|
||||
// loaded page even when it would otherwise be hundreds of entries past
|
||||
// the newest-first cursor. Subsequent pages continue chronologically
|
||||
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
|
||||
// standard offset, so the listing stays in newest-first order without
|
||||
// jumping around the library. Cleared when the filter changes — a new
|
||||
// filter is a fresh listing, possibly with its own anchor.
|
||||
let anchor = $state<PendingFocus | null>(null);
|
||||
let lastFilterQ: string | null = null;
|
||||
// Watch every URL change so we catch pending-focus stashes even when the
|
||||
// filter didn't change (e.g. user clicks the open-folder icon for a photo
|
||||
// in the folder they're already on — the goto sets the same URL but the
|
||||
// user still expects to land on THAT photo). Pure filter changes with
|
||||
// no pending stash clear any stale anchor so a subsequent refetch
|
||||
// doesn't keep the old window.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
void page.url.search;
|
||||
untrack(() => {
|
||||
const pending = consumePendingFocus();
|
||||
if (pending) {
|
||||
anchor = pending;
|
||||
lastFilterQ = filtersToQ(filters);
|
||||
return;
|
||||
}
|
||||
const q = filtersToQ(filters);
|
||||
if (q !== lastFilterQ) {
|
||||
anchor = null;
|
||||
lastFilterQ = q;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listPhotos({
|
||||
q: filtersToQ(filters),
|
||||
queryKey: [
|
||||
"photos",
|
||||
"q",
|
||||
filtersToQ(filters),
|
||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const offset = pageParam as number;
|
||||
const baseQ = filtersToQ(filters);
|
||||
// Page 0 + anchor → load a window around the anchor's date.
|
||||
// Subsequent pages aren't reachable in anchor mode (see
|
||||
// getNextPageParam).
|
||||
if (offset === 0 && anchor?.takenAt) {
|
||||
return listPhotosAround({
|
||||
q: baseQ,
|
||||
takenAt: anchor.takenAt,
|
||||
afterCount: 30,
|
||||
beforeCount: 90,
|
||||
merged: true,
|
||||
});
|
||||
}
|
||||
return listPhotos({
|
||||
q: baseQ,
|
||||
count: PHOTOS_PAGE_SIZE,
|
||||
offset: pageParam as number,
|
||||
offset,
|
||||
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,
|
||||
getNextPageParam: (last, pages) => {
|
||||
if (last.length === 0) return undefined;
|
||||
// Anchor mode terminates after page 0 — the user sees the 120-
|
||||
// photo window around the deep-linked photo. PhotoPrism's
|
||||
// `before:` cursor is day-precision, so paginating further
|
||||
// chronologically risks dense-day infinite loops (same-day
|
||||
// photos exceeding the page size keep the cursor at the same
|
||||
// value). To "see more," the user clears the anchor by
|
||||
// navigating fresh.
|
||||
if (anchor?.takenAt) return undefined;
|
||||
return pages.length * PHOTOS_PAGE_SIZE;
|
||||
},
|
||||
enabled: isAuthenticated(),
|
||||
}));
|
||||
|
||||
@@ -237,7 +306,22 @@
|
||||
// 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);
|
||||
// Anchor (from an in-app deep link) takes precedence — its UID is
|
||||
// guaranteed in `photos` because page 0 was fetched as a window
|
||||
// around its TakenAt. Plain navigations leave anchor null and we
|
||||
// snap to photos[0] as before. `scrollToIndex` expands the
|
||||
// windowed render set + scrolls the tile into view (with sticky-
|
||||
// header peek) — same helper gridKeyNav uses for arrow nav.
|
||||
const targetIdx =
|
||||
anchor?.uid != null
|
||||
? photos.findIndex((p) => p.UID === anchor!.uid)
|
||||
: -1;
|
||||
if (targetIdx >= 0) {
|
||||
setFocused(photos[targetIdx].UID);
|
||||
void scrollToIndex(targetIdx);
|
||||
} else {
|
||||
setFocused(photos[0].UID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -299,6 +299,37 @@
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
|
||||
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||
// rather than fitting to the full library extent. Strip the params
|
||||
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||
// through to the default fitBounds when the params aren't present.
|
||||
const sp = new URL(window.location.href).searchParams;
|
||||
const latParam = Number(sp.get('lat'));
|
||||
const lngParam = Number(sp.get('lng'));
|
||||
if (
|
||||
(data.features?.length ?? 0) > 0 &&
|
||||
Number.isFinite(latParam) &&
|
||||
Number.isFinite(lngParam) &&
|
||||
sp.has('lat') &&
|
||||
sp.has('lng')
|
||||
) {
|
||||
const zoom = Number(sp.get('zoom')) || 17;
|
||||
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||
const stripped = new URL(window.location.href);
|
||||
stripped.searchParams.delete('lat');
|
||||
stripped.searchParams.delete('lng');
|
||||
stripped.searchParams.delete('zoom');
|
||||
stripped.searchParams.delete('focus');
|
||||
const qs = stripped.searchParams.toString();
|
||||
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fit to data extent on the first non-empty load — prefer the
|
||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||
// the features.
|
||||
|
||||
Reference in New Issue
Block a user