feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
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>
This commit is contained in:
70
web/src/lib/actions/nearBottom.ts
Normal file
70
web/src/lib/actions/nearBottom.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user