diff --git a/api/src/functions/media.ts b/api/src/functions/media.ts index b88b2cd..ef08a0f 100644 --- a/api/src/functions/media.ts +++ b/api/src/functions/media.ts @@ -31,11 +31,24 @@ export interface MediaPost { * meant to be read by every site visitor. Only reads Table Storage * metadata; blob bytes are served directly from Blob Storage via * `blobUrl`, never through this Function. + * + * Supports cursor pagination via `?before=` so callers + * (see MediaLightbox.tsx's `enablePagination`) can page through an + * arbitrarily large archive of older posts without ever fetching more + * than one page's worth of (small, metadata-only) JSON at a time. Omit + * `before` for the first/newest page. */ export async function media(request: HttpRequest, context: InvocationContext): Promise { const limitRaw = Number(request.query.get('limit')); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, SCAN_CAP) : SCAN_CAP; + // Cursor for "load older posts" pagination (see MediaLightbox.tsx) - the + // capturedAt of the last post the caller already has, so we only return + // posts strictly older than that. Absent on the first page. + const beforeRaw = request.query.get('before'); + const beforeMs = beforeRaw ? new Date(beforeRaw).getTime() : NaN; + const before = Number.isFinite(beforeMs) ? beforeMs : undefined; + try { const table = await getMediaTable(); // RowKeys are generated with an inverted-timestamp prefix (see @@ -72,9 +85,14 @@ export async function media(request: HttpRequest, context: InvocationContext): P // missing/unparseable, so this sort is always well-defined. posts.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime()); + // Apply the pagination cursor *after* sorting the full set, so paging + // deep into an archive of "hundreds" of photos still reads back in + // true capture-date order, not just upload order. + const windowed = before === undefined ? posts : posts.filter((p) => new Date(p.capturedAt).getTime() < before); + return { status: 200, - jsonBody: posts.slice(0, limit), + jsonBody: { posts: windowed.slice(0, limit), hasMore: windowed.length > limit }, headers: { 'Cache-Control': 'no-store' }, }; } catch (error) { diff --git a/src/components/MediaLightbox.tsx b/src/components/MediaLightbox.tsx index 80ac0f4..b4425ea 100644 --- a/src/components/MediaLightbox.tsx +++ b/src/components/MediaLightbox.tsx @@ -3,6 +3,11 @@ import { createPortal } from 'react-dom'; import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import type { MediaPost } from '../hooks/useMediaPosts'; +// How many additional posts to fetch per "load more" page once the user +// navigates past everything already loaded (see `enablePagination` below). +// Kept small - this is metadata-only JSON, but no reason to over-fetch. +const PAGE_SIZE = 24; + /** * Fullscreen overlay for browsing one or more uploaded photos/videos at * full size. This is the *single shared viewer* for every place photos can @@ -16,20 +21,40 @@ export default function MediaLightbox({ posts, initialIndex, onClose, + enablePagination = false, }: { posts: MediaPost[]; initialIndex: number; onClose: () => void; + // When true, navigating past the last post fetches the next page of + // older posts from the API (see media.ts's `before` cursor) instead of + // wrapping around - so browsing isn't capped at whatever `posts` this + // caller happened to have loaded already. Opt-in because callers that + // pass an intentionally-scoped subset (a map cluster, nearby pins - see + // MediaMarkers.tsx) should still just wrap within that subset. + enablePagination?: boolean; }) { const [index, setIndex] = useState(initialIndex); + // Posts fetched on-demand beyond the caller's initial `posts` list, only + // ever appended to - see goNext. Combined with `posts` below to form the + // full navigable list; kept separate (rather than merged into one + // state) so a poll-driven refresh of the caller's `posts` prop (e.g. + // PhotoStream re-fetching every 30s) can't wipe out pages we've already + // loaded while the user is mid-browsing. + const [extraPosts, setExtraPosts] = useState([]); + const [hasMore, setHasMore] = useState(enablePagination); + const [loadingMore, setLoadingMore] = useState(false); + + const allPosts = enablePagination ? [...posts, ...extraPosts] : posts; + // `index` is only set from `initialIndex` on mount; if a caller swaps // `posts` while this stays open (e.g. `PhotoStreamTile` re-fetching) and // the previous index is now out of range, clamp instead of indexing past // the end - otherwise `post` becomes undefined and the whole overlay // silently disappears (see the early return below). - const safeIndex = posts.length === 0 ? 0 : Math.min(index, posts.length - 1); - const post = posts[safeIndex]; - const canNavigate = posts.length > 1; + const safeIndex = allPosts.length === 0 ? 0 : Math.min(index, allPosts.length - 1); + const post = allPosts[safeIndex]; + const canNavigate = allPosts.length > 1 || (enablePagination && hasMore); // The full-resolution original (post.blobUrl) can be several MB - bad on // a metered connection just to *look* at a photo. If the post doesn't @@ -62,11 +87,40 @@ export default function MediaLightbox({ }, [post?.id, post?.mediaType, post?.displayUrl]); function goPrev() { - setIndex((i) => (Math.min(i, posts.length - 1) - 1 + posts.length) % posts.length); + setIndex((i) => (Math.min(i, allPosts.length - 1) - 1 + allPosts.length) % allPosts.length); } - function goNext() { - setIndex((i) => (Math.min(i, posts.length - 1) + 1) % posts.length); + async function goNext() { + if (index < allPosts.length - 1) { + setIndex(index + 1); + return; + } + + if (enablePagination && hasMore && !loadingMore) { + setLoadingMore(true); + try { + const last = allPosts[allPosts.length - 1]; + const params = new URLSearchParams({ before: last.capturedAt, limit: String(PAGE_SIZE) }); + const res = await fetch(`/api/media?${params}`, { cache: 'no-store' }); + if (res.ok) { + const data: { posts: MediaPost[]; hasMore: boolean } = await res.json(); + if (data.posts.length > 0) { + setExtraPosts((prev) => [...prev, ...data.posts]); + setHasMore(data.hasMore); + setIndex((i) => i + 1); + return; + } + setHasMore(false); + } + } catch { + // Network hiccup - fall through and wrap to the start instead of + // leaving navigation stuck. + } finally { + setLoadingMore(false); + } + } + + setIndex(0); } useEffect(() => { @@ -77,8 +131,11 @@ export default function MediaLightbox({ } window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canNavigate, posts.length, onClose]); + // Re-subscribing on every render (goPrev/goNext have a fresh identity + // each time) is intentional here - it's the simplest way to guarantee + // the listener never closes over a stale `index`/`hasMore`, and the + // cost of re-attaching one window listener per navigation is trivial. + }); if (!post) return null; @@ -161,8 +218,9 @@ export default function MediaLightbox({ e.stopPropagation(); goNext(); }} + disabled={loadingMore} aria-label="Next photo" - className="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors" + className="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors disabled:opacity-50" > @@ -170,7 +228,10 @@ export default function MediaLightbox({ {canNavigate && (
- {safeIndex + 1} / {posts.length} + {/* Total is only meaningful once we know there's nothing left to + page in - otherwise showing "x / n" would understate the + real count and look like a bug once more loads in. */} + {enablePagination && hasMore ? safeIndex + 1 : `${safeIndex + 1} / ${allPosts.length}`}
)} , diff --git a/src/components/PhotoStream.tsx b/src/components/PhotoStream.tsx index 9a6660b..86063d4 100644 --- a/src/components/PhotoStream.tsx +++ b/src/components/PhotoStream.tsx @@ -14,7 +14,13 @@ import MediaLightbox from './MediaLightbox'; const PREVIEW_COUNT = 12; export default function PhotoStream() { - const posts = useMediaPosts().slice(0, PREVIEW_COUNT); + // Full (unsliced) result passed to the lightbox so browsing isn't + // trapped inside the grid's small preview - only the tiles themselves + // are capped. Combined with `enablePagination` below, this scales to + // any number of photos: the lightbox fetches older pages on-demand as + // someone navigates past whatever's loaded here. + const allPosts = useMediaPosts(); + const posts = allPosts.slice(0, PREVIEW_COUNT); const [selectedIndex, setSelectedIndex] = useState(null); if (posts.length === 0) return null; @@ -66,7 +72,12 @@ export default function PhotoStream() { {selectedIndex !== null && ( - setSelectedIndex(null)} /> + setSelectedIndex(null)} + enablePagination + /> )} ); diff --git a/src/components/RouteMap/PhotoStreamTile.tsx b/src/components/RouteMap/PhotoStreamTile.tsx index a95207e..821028c 100644 --- a/src/components/RouteMap/PhotoStreamTile.tsx +++ b/src/components/RouteMap/PhotoStreamTile.tsx @@ -69,7 +69,12 @@ export default function PhotoStreamTile() { {selectedIndex !== null && ( - setSelectedIndex(null)} /> + setSelectedIndex(null)} + enablePagination + /> )} ); diff --git a/src/hooks/useMediaPosts.ts b/src/hooks/useMediaPosts.ts index e7e4a27..f4da7d6 100644 --- a/src/hooks/useMediaPosts.ts +++ b/src/hooks/useMediaPosts.ts @@ -54,12 +54,16 @@ export function useMediaPosts(): MediaPost[] { try { const res = await fetch('/api/media', { cache: 'no-store' }); if (!res.ok) return; - const data: MediaPost[] = await res.json(); + // media.ts now returns { posts, hasMore } (see MediaLightbox's + // enablePagination) instead of a bare array - this hook only ever + // polls the first/newest page as a seed list for the grid/map, so + // `hasMore` itself isn't needed here. + const data: { posts: MediaPost[] } = await res.json(); // The API (media.ts) already sorts by capturedAt server-side, but // re-sort here too - cheap for "hundreds" of posts, and keeps // every consumer of this hook guaranteed trip-timeline order even // if that ever changes API-side. - if (isMounted.current) setPosts(sortByCapturedAtDesc(data)); + if (isMounted.current) setPosts(sortByCapturedAtDesc(data.posts)); } catch { // Network hiccup or offline - keep showing the last known posts // and try again on the next tick.