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>
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
/**
|
|
* Fires `onHit` whenever the attached element scrolls near the bottom of
|
|
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
|
|
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
|
|
* div with a rootMargin equal to ~4 viewport heights, so the next page is
|
|
* fetched well before the user actually reaches the end.
|
|
*
|
|
* Usage: attach to a sentinel <div /> placed at the bottom of the scroll
|
|
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
|
|
*
|
|
* <div use:nearBottom={{ onHit: fetchNextPage, enabled: canFetch }} />
|
|
*/
|
|
export interface NearBottomParams {
|
|
onHit: () => void;
|
|
/** When false the observer ignores intersections (use for the
|
|
* hasNextPage + !isFetchingNextPage gate). */
|
|
enabled?: boolean;
|
|
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
|
|
* we default to the same. Caller can pass a number for tests. */
|
|
preloadPx?: number;
|
|
/** Optional scroll root (defaults to the viewport). Pass the
|
|
* scrolling ancestor when the page itself doesn't scroll, which is
|
|
* our case — the timeline scrolls inside `<main>`. */
|
|
root?: Element | null;
|
|
}
|
|
|
|
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
|
let current: NearBottomParams = params;
|
|
let io: IntersectionObserver | null = null;
|
|
|
|
function buildObserver(p: NearBottomParams) {
|
|
io?.disconnect();
|
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
|
io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (!current.enabled) return;
|
|
for (const e of entries) {
|
|
if (e.isIntersecting) {
|
|
current.onHit();
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: p.root ?? null,
|
|
// Inflate the root's bottom edge so we trip well before
|
|
// the sentinel actually enters the viewport.
|
|
rootMargin: `0px 0px ${preload}px 0px`
|
|
}
|
|
);
|
|
io.observe(node);
|
|
}
|
|
|
|
buildObserver(current);
|
|
|
|
return {
|
|
update(next: NearBottomParams) {
|
|
const rootChanged = next.root !== current.root;
|
|
const preloadChanged = next.preloadPx !== current.preloadPx;
|
|
current = next;
|
|
// `enabled` and `onHit` are read live inside the callback,
|
|
// so they don't require rebuilding the observer. Root and
|
|
// preloadPx are baked in at construction.
|
|
if (rootChanged || preloadChanged) buildObserver(current);
|
|
},
|
|
destroy() {
|
|
io?.disconnect();
|
|
}
|
|
};
|
|
}
|