Refactor infinite scroll

This commit is contained in:
Sam Becker 2024-04-27 12:16:23 -05:00
parent 1dbfc2d592
commit 08451cff13
7 changed files with 89 additions and 66 deletions

View File

@ -35,7 +35,7 @@ export default async function GridPage() {
photos.length > 0 photos.length > 0
? <SiteGrid ? <SiteGrid
contentMain={<div className="space-y-0.5 sm:space-y-1"> contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos, photoPriority: true }} /> <PhotoGrid {...{ photos }} />
{photos.length >= INFINITE_SCROLL_MULTIPLE_GRID && {photos.length >= INFINITE_SCROLL_MULTIPLE_GRID &&
<InfinitePhotoScroll <InfinitePhotoScroll
type='grid' type='grid'

View File

@ -1,12 +1,15 @@
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { RefObject } from 'react';
export default function SiteGrid({ export default function SiteGrid({
containerRef,
className, className,
contentMain, contentMain,
contentSide, contentSide,
sideFirstOnMobile, sideFirstOnMobile,
sideHiddenOnMobile, sideHiddenOnMobile,
}: { }: {
containerRef?: RefObject<HTMLDivElement>
className?: string className?: string
contentMain: JSX.Element contentMain: JSX.Element
contentSide?: JSX.Element contentSide?: JSX.Element
@ -14,14 +17,17 @@ export default function SiteGrid({
sideHiddenOnMobile?: boolean sideHiddenOnMobile?: boolean
}) { }) {
return ( return (
<div className={clsx( <div
className, ref={containerRef}
'grid', className={clsx(
'grid-cols-1 md:grid-cols-12', className,
'gap-x-4 lg:gap-x-6', 'grid',
'gap-y-4', 'grid-cols-1 md:grid-cols-12',
'max-w-7xl', 'gap-x-4 lg:gap-x-6',
)}> 'gap-y-4',
'max-w-7xl',
)}
>
<div className={clsx( <div className={clsx(
'col-span-1 md:col-span-9', 'col-span-1 md:col-span-9',
sideFirstOnMobile && 'order-2 md:order-none', sideFirstOnMobile && 'order-2 md:order-none',

View File

@ -1,11 +1,9 @@
'use client'; 'use client';
import { preload } from 'swr';
import useSwrInfinite from 'swr/infinite'; import useSwrInfinite from 'swr/infinite';
import PhotosLarge from '@/photo/PhotosLarge'; import PhotosLarge from '@/photo/PhotosLarge';
import { import {
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
} from 'react'; } from 'react';
@ -26,14 +24,10 @@ export default function InfinitePhotoScroll({
type = 'full-frame', type = 'full-frame',
initialOffset = 0, initialOffset = 0,
itemsPerPage = 12, itemsPerPage = 12,
prefetch = true,
triggerOnView = true,
}: { }: {
type?: 'full-frame' | 'grid' type?: 'full-frame' | 'grid'
initialOffset?: number initialOffset?: number
itemsPerPage?: number itemsPerPage?: number
prefetch?: boolean
triggerOnView?: boolean
debug?: boolean debug?: boolean
}) { }) {
const { swrTimestamp, isUserSignedIn } = useAppState(); const { swrTimestamp, isUserSignedIn } = useAppState();
@ -46,20 +40,21 @@ export default function InfinitePhotoScroll({
: [key, size] : [key, size]
, [key]); , [key]);
const fetcher = useCallback(([_key, size]: [string, number]) => const fetcher = useCallback(([_key, size]: [string, number]) => {
getPhotosAction( console.log('Fetching', size);
return getPhotosAction(
initialOffset + size * itemsPerPage, initialOffset + size * itemsPerPage,
itemsPerPage, itemsPerPage,
) );
, [initialOffset, itemsPerPage]); }, [initialOffset, itemsPerPage]);
const { data, isLoading, isValidating, error, mutate, size, setSize } = const { data, isLoading, isValidating, error, mutate, setSize } =
useSwrInfinite<Photo[]>( useSwrInfinite<Photo[]>(
keyGenerator, keyGenerator,
fetcher, fetcher,
{ {
revalidateFirstPage: Boolean(isUserSignedIn), initialSize: 2,
revalidateOnMount: Boolean(isUserSignedIn), revalidateFirstPage: false,
revalidateOnFocus: Boolean(isUserSignedIn), revalidateOnFocus: Boolean(isUserSignedIn),
revalidateOnReconnect: Boolean(isUserSignedIn), revalidateOnReconnect: Boolean(isUserSignedIn),
}, },
@ -73,46 +68,11 @@ export default function InfinitePhotoScroll({
data && data[data.length - 1]?.length < itemsPerPage data && data[data.length - 1]?.length < itemsPerPage
, [data, itemsPerPage]); , [data, itemsPerPage]);
useEffect(() => {
if (prefetch && !isFinished) {
preload([key, (size ?? 0) + 1], fetcher);
}
}, [prefetch, isFinished, key, size, fetcher]);
const advance = useCallback(() => { const advance = useCallback(() => {
if (!isFinished && !isLoadingOrValidating) { if (!isFinished && !isLoadingOrValidating) {
setSize(size => size + 1); setSize(size => size + 1);
} }
}, [isFinished, setSize, isLoadingOrValidating]); }, [isFinished, isLoadingOrValidating, setSize]);
useEffect(() => {
// Only add observer if button is rendered
if (buttonContainerRef.current) {
const observer = new IntersectionObserver(e => {
if (triggerOnView && e[0].isIntersecting) {
advance();
}
}, {
root: null,
threshold: 0,
});
observer.observe(buttonContainerRef.current);
return () => observer.disconnect();
}
}, [triggerOnView, advance]);
// Poll for button getting stuck
useEffect(() => {
if (triggerOnView && !isFinished && !isLoadingOrValidating) {
const interval = setInterval(() => {
const rect = buttonContainerRef.current?.getBoundingClientRect();
if (rect && rect.top <= window.innerHeight) {
advance();
}
}, 1000);
return () => clearInterval(interval);
}
}, [advance, isFinished, isLoadingOrValidating, triggerOnView]);
const photos = useMemo(() => (data ?? [])?.flat(), [data]); const photos = useMemo(() => (data ?? [])?.flat(), [data]);
@ -128,11 +88,7 @@ export default function InfinitePhotoScroll({
} as any), [data, mutate]); } as any), [data, mutate]);
const renderMoreButton = () => const renderMoreButton = () =>
<div <div ref={buttonContainerRef}>
ref={buttonContainerRef}
// Make button bounding visible earlier
className="-translate-y-32 pt-32 -mb-32"
>
<button <button
onClick={() => error ? mutate() : advance()} onClick={() => error ? mutate() : advance()}
disabled={isLoading || isValidating} disabled={isLoading || isValidating}
@ -152,8 +108,15 @@ export default function InfinitePhotoScroll({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{type === 'full-frame' {type === 'full-frame'
? <PhotosLarge {...{ photos, revalidatePhoto }} /> ? <PhotosLarge {...{
: <PhotoGrid {...{ photos }} />} photos,
revalidatePhoto,
onLastPhotoVisible: advance,
}} />
: <PhotoGrid {...{
photos,
onLastPhotoVisible: advance,
}} />}
{!isFinished && (type === 'full-frame' {!isFinished && (type === 'full-frame'
? <SiteGrid contentMain={renderMoreButton()} /> ? <SiteGrid contentMain={renderMoreButton()} />
: renderMoreButton())} : renderMoreButton())}

View File

@ -19,6 +19,7 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly = true, staggerOnFirstLoadOnly = true,
additionalTile, additionalTile,
small, small,
onLastPhotoVisible,
}: { }: {
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
@ -33,6 +34,7 @@ export default function PhotoGrid({
showMorePath?: string showMorePath?: string
additionalTile?: JSX.Element additionalTile?: JSX.Element
small?: boolean small?: boolean
onLastPhotoVisible?: () => void
}) { }) {
return ( return (
<AnimateItems <AnimateItems
@ -51,7 +53,7 @@ export default function PhotoGrid({
distanceOffset={40} distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly} animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly} staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
items={photos.map(photo => items={photos.map((photo, index) =>
<div <div
key={photo.id} key={photo.id}
className={GRID_ASPECT_RATIO !== 0 className={GRID_ASPECT_RATIO !== 0
@ -70,6 +72,9 @@ export default function PhotoGrid({
simulation, simulation,
selected: photo.id === selectedPhoto?.id, selected: photo.id === selectedPhoto?.id,
priority: photoPriority, priority: photoPriority,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}} /> }} />
</div>).concat(additionalTile ?? [])} </div>).concat(additionalTile ?? [])}
itemKeys={photos.map(photo => photo.id) itemKeys={photos.map(photo => photo.id)

View File

@ -22,6 +22,7 @@ import PhotoLink from './PhotoLink';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import { RevalidatePhoto } from './InfinitePhotoScroll'; import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useEffect, useRef } from 'react';
export default function PhotoLarge({ export default function PhotoLarge({
photo, photo,
@ -36,6 +37,7 @@ export default function PhotoLarge({
shouldShareCamera, shouldShareCamera,
shouldShareSimulation, shouldShareSimulation,
shouldScrollOnShare, shouldScrollOnShare,
onVisible,
}: { }: {
photo: Photo photo: Photo
primaryTag?: string primaryTag?: string
@ -49,7 +51,10 @@ export default function PhotoLarge({
shouldShareCamera?: boolean shouldShareCamera?: boolean
shouldShareSimulation?: boolean shouldShareSimulation?: boolean
shouldScrollOnShare?: boolean shouldScrollOnShare?: boolean
onVisible?: () => void
}) { }) {
const ref = useRef<HTMLDivElement>(null);
const tags = sortTags(photo.tags, primaryTag); const tags = sortTags(photo.tags, primaryTag);
const camera = cameraFromPhoto(photo); const camera = cameraFromPhoto(photo);
@ -58,8 +63,24 @@ export default function PhotoLarge({
const showTagsContent = tags.length > 0; const showTagsContent = tags.length > 0;
const showExifContent = shouldShowExifDataForPhoto(photo); const showExifContent = shouldShowExifDataForPhoto(photo);
useEffect(() => {
if (onVisible && ref.current) {
const observer = new IntersectionObserver(e => {
if (e[0].isIntersecting) {
onVisible();
}
}, {
root: null,
threshold: 0,
});
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [onVisible]);
return ( return (
<SiteGrid <SiteGrid
containerRef={ref}
contentMain={ contentMain={
<Link <Link
href={pathForPhoto(photo)} href={pathForPhoto(photo)}

View File

@ -1,3 +1,5 @@
'use client';
import { Photo, altTextForPhoto } from '.'; import { Photo, altTextForPhoto } from '.';
import ImageSmall from '@/components/ImageSmall'; import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link'; import Link from 'next/link';
@ -6,6 +8,7 @@ import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useEffect, useRef } from 'react';
export default function PhotoSmall({ export default function PhotoSmall({
photo, photo,
@ -15,6 +18,7 @@ export default function PhotoSmall({
selected, selected,
priority, priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS, prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: { }: {
photo: Photo photo: Photo
tag?: string tag?: string
@ -23,9 +27,28 @@ export default function PhotoSmall({
selected?: boolean selected?: boolean
priority?: boolean priority?: boolean
prefetch?: boolean prefetch?: boolean
onVisible?: () => void
}) { }) {
const ref = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (onVisible && ref.current) {
const observer = new IntersectionObserver(e => {
if (e[0].isIntersecting) {
onVisible();
}
}, {
root: null,
threshold: 0,
});
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [onVisible]);
return ( return (
<Link <Link
ref={ref}
href={pathForPhoto(photo, tag, camera, simulation)} href={pathForPhoto(photo, tag, camera, simulation)}
className={clsx( className={clsx(
'flex w-full h-full', 'flex w-full h-full',

View File

@ -8,11 +8,13 @@ export default function PhotosLarge({
animate = true, animate = true,
prefetchFirstPhotoLinks, prefetchFirstPhotoLinks,
revalidatePhoto, revalidatePhoto,
onLastPhotoVisible,
}: { }: {
photos: Photo[] photos: Photo[]
animate?: boolean animate?: boolean
prefetchFirstPhotoLinks?: boolean prefetchFirstPhotoLinks?: boolean
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
onLastPhotoVisible?: () => void
}) { }) {
return ( return (
<AnimateItems <AnimateItems
@ -29,6 +31,9 @@ export default function PhotosLarge({
priority={index <= 1} priority={index <= 1}
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0} prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
revalidatePhoto={revalidatePhoto} revalidatePhoto={revalidatePhoto}
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}
/>)} />)}
itemKeys={photos.map(photo => photo.id)} itemKeys={photos.map(photo => photo.id)}
/> />