Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion api/src/functions/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ISO capturedAt>` 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<HttpResponseInit> {
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
Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 71 additions & 10 deletions src/components/MediaLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MediaPost[]>([]);
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
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;

Expand Down Expand Up @@ -161,16 +218,20 @@ 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"
>
<ChevronRight size={24} strokeWidth={1.75} />
</button>
)}

{canNavigate && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-white/80 text-xs font-medium bg-black/60 rounded-full px-3 py-1">
{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}`}
</div>
)}
</div>,
Expand Down
15 changes: 13 additions & 2 deletions src/components/PhotoStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);

if (posts.length === 0) return null;
Expand Down Expand Up @@ -66,7 +72,12 @@ export default function PhotoStream() {
</div>

{selectedIndex !== null && (
<MediaLightbox posts={posts} initialIndex={selectedIndex} onClose={() => setSelectedIndex(null)} />
<MediaLightbox
posts={allPosts}
initialIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
enablePagination
/>
)}
</>
);
Expand Down
7 changes: 6 additions & 1 deletion src/components/RouteMap/PhotoStreamTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ export default function PhotoStreamTile() {
</div>

{selectedIndex !== null && (
<MediaLightbox posts={posts} initialIndex={selectedIndex} onClose={() => setSelectedIndex(null)} />
<MediaLightbox
posts={posts}
initialIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
enablePagination
/>
)}
</div>
);
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/useMediaPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down