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
? <SiteGrid
contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos, photoPriority: true }} />
<PhotoGrid {...{ photos }} />
{photos.length >= INFINITE_SCROLL_MULTIPLE_GRID &&
<InfinitePhotoScroll
type='grid'

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly = true,
additionalTile,
small,
onLastPhotoVisible,
}: {
photos: Photo[]
selectedPhoto?: Photo
@ -33,6 +34,7 @@ export default function PhotoGrid({
showMorePath?: string
additionalTile?: JSX.Element
small?: boolean
onLastPhotoVisible?: () => void
}) {
return (
<AnimateItems
@ -51,7 +53,7 @@ export default function PhotoGrid({
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
items={photos.map(photo =>
items={photos.map((photo, index) =>
<div
key={photo.id}
className={GRID_ASPECT_RATIO !== 0
@ -70,6 +72,9 @@ export default function PhotoGrid({
simulation,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}} />
</div>).concat(additionalTile ?? [])}
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 AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useEffect, useRef } from 'react';
export default function PhotoLarge({
photo,
@ -36,6 +37,7 @@ export default function PhotoLarge({
shouldShareCamera,
shouldShareSimulation,
shouldScrollOnShare,
onVisible,
}: {
photo: Photo
primaryTag?: string
@ -49,7 +51,10 @@ export default function PhotoLarge({
shouldShareCamera?: boolean
shouldShareSimulation?: boolean
shouldScrollOnShare?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLDivElement>(null);
const tags = sortTags(photo.tags, primaryTag);
const camera = cameraFromPhoto(photo);
@ -58,8 +63,24 @@ export default function PhotoLarge({
const showTagsContent = tags.length > 0;
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 (
<SiteGrid
containerRef={ref}
contentMain={
<Link
href={pathForPhoto(photo)}

View File

@ -1,3 +1,5 @@
'use client';
import { Photo, altTextForPhoto } from '.';
import ImageSmall from '@/components/ImageSmall';
import Link from 'next/link';
@ -6,6 +8,7 @@ import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useEffect, useRef } from 'react';
export default function PhotoSmall({
photo,
@ -15,6 +18,7 @@ export default function PhotoSmall({
selected,
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: {
photo: Photo
tag?: string
@ -23,9 +27,28 @@ export default function PhotoSmall({
selected?: boolean
priority?: 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 (
<Link
ref={ref}
href={pathForPhoto(photo, tag, camera, simulation)}
className={clsx(
'flex w-full h-full',

View File

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