Refactor infinite scroll
This commit is contained in:
parent
1dbfc2d592
commit
08451cff13
@ -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'
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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())}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user