diff --git a/package.json b/package.json
index 517e1e20..02574855 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"react-dom": "18.2.0",
"react-icons": "^5.1.0",
"sonner": "^1.4.41",
+ "swr": "^2.2.5",
"tailwindcss": "3.4.3",
"ts-exif-parser": "^0.2.2",
"typescript": "5.4.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9e38b615..37808d87 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -134,6 +134,9 @@ importers:
sonner:
specifier: ^1.4.41
version: 1.4.41(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ swr:
+ specifier: ^2.2.5
+ version: 2.2.5(react@18.2.0)
tailwindcss:
specifier: 3.4.3
version: 3.4.3
@@ -3770,6 +3773,11 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0
+ swr@2.2.5:
+ resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0
+
swrev@4.0.0:
resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==}
@@ -8733,6 +8741,12 @@ snapshots:
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
+ swr@2.2.5(react@18.2.0):
+ dependencies:
+ client-only: 0.0.1
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+
swrev@4.0.0: {}
swrv@1.0.4(vue@3.4.21(typescript@5.4.5)):
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b44e0726..389b0f3c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -12,6 +12,7 @@ import { ThemeProvider } from 'next-themes';
import Nav from '@/site/Nav';
import Footer from '@/site/Footer';
import CommandK from '@/site/CommandK';
+import SWRConfigClient from '../state/SWRConfigClient';
import '../site/globals.css';
import '../site/sonner.css';
@@ -72,24 +73,26 @@ export default function RootLayout({
>
-
-
-
-
-
+
+
+
- {children}
-
-
-
-
-
-
+
+
+ {children}
+
+
+
+
+
+
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9a8699d2..6739ae2a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,4 +1,4 @@
-import { getPhotosCached, getPhotosCountCached } from '@/photo/cache';
+import { getPhotosCachedCached } from '@/photo/cache';
import {
INFINITE_SCROLL_MULTIPLE_HOME,
generateOgImageMetaForPhotos,
@@ -7,26 +7,25 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import PhotosLarge from '@/photo/PhotosLarge';
-import { MorePhotosRoot } from '@/photo/MorePhotosRoot';
+import InfinitePhotoScroll from '../photo/InfinitePhotoScroll';
export const dynamic = 'force-static';
export async function generateMetadata(): Promise {
- // Make homepage queries resilient to error on first time setup
- const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG })
+ const photos = await getPhotosCachedCached({
+ limit: MAX_PHOTOS_TO_SHOW_OG,
+ })
+ // Make homepage queries resilient to error on first time setup
.catch(() => []);
return generateOgImageMetaForPhotos(photos);
}
export default async function HomePage() {
- const [
- photos,
- count,
- ] = await Promise.all([
+ const photos = await getPhotosCachedCached({
+ limit: INFINITE_SCROLL_MULTIPLE_HOME,
+ })
// Make homepage queries resilient to error on first time setup
- getPhotosCached({ limit: INFINITE_SCROLL_MULTIPLE_HOME }).catch(() => []),
- getPhotosCountCached().catch(() => 0),
- ]);
+ .catch(() => []);
return (
photos.length > 0
@@ -35,10 +34,9 @@ export default async function HomePage() {
photos={photos}
prefetchFirstPhotoLinks={true}
/>
-
:
diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx
new file mode 100644
index 00000000..609c087f
--- /dev/null
+++ b/src/photo/InfinitePhotoScroll.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { preload } from 'swr';
+import useSwrInfinite from 'swr/infinite';
+import PhotosLarge from '@/photo/PhotosLarge';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import SiteGrid from '@/components/SiteGrid';
+import Spinner from '@/components/Spinner';
+import { getPhotosAction } from '@/photo/actions';
+
+export default function InfinitePhotoScroll({
+ key = 'PHOTOS',
+ initialOffset = 0,
+ itemsPerPage = 12,
+ prefetch = true,
+ triggerOnView = true,
+ debug,
+}: {
+ key?: string
+ initialOffset?: number
+ itemsPerPage?: number
+ prefetch?: boolean
+ triggerOnView?: boolean
+ debug?: boolean
+}) {
+ const buttonRef = useRef(null);
+
+ const fetcher = useCallback((key: string) => {
+ const offset = parseInt(key.split('-')[1]);
+ if (debug) { console.log('Fetching', offset); }
+ return getPhotosAction(
+ initialOffset + offset * itemsPerPage,
+ itemsPerPage,
+ );
+ }, [initialOffset, itemsPerPage, debug]);
+
+ const { data, isLoading, error, mutate, size, setSize } = useSwrInfinite(
+ (size, prev: []) => prev && prev.length === 0
+ ? null
+ :`${key}-${size}`,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateFirstPage: false,
+ }
+ );
+
+ const isFinished = useMemo(() =>
+ data && data[data.length - 1]?.length < itemsPerPage
+ , [data, itemsPerPage]);
+
+ useEffect(() => {
+ if (prefetch) {
+ preload(`${key}-${size}`, fetcher);
+ }
+ }, [prefetch, key, size, fetcher]);
+
+ const advance = useCallback(() => setSize(size => size + 1), [setSize]);
+
+ useEffect(() => {
+ // Only add observer if button is rendered
+ if (buttonRef.current) {
+ const observer = new IntersectionObserver(e => {
+ if (triggerOnView && e[0].isIntersecting) {
+ advance();
+ }
+ }, {
+ root: null,
+ threshold: 0,
+ });
+ observer.observe(buttonRef.current);
+ return () => observer.disconnect();
+ }
+ }, [triggerOnView, advance]);
+
+ const photos = useMemo(() => data?.flat(), [data]);
+
+ return (
+
+ {photos &&
}
+ {!isFinished &&
+
mutate() : advance}
+ disabled={isLoading}
+ className="w-full flex justify-center"
+ >
+ {error
+ ? 'Try Again'
+ : isLoading
+ ?
+ : 'Load More'}
+
+ } />}
+
+ );
+}
diff --git a/src/photo/actions.tsx b/src/photo/actions.tsx
index cc527dfa..cd4a24bb 100644
--- a/src/photo/actions.tsx
+++ b/src/photo/actions.tsx
@@ -20,6 +20,7 @@ import {
deleteStorageUrl,
} from '@/services/storage';
import {
+ getPhotosCachedCached,
revalidateAdminPaths,
revalidateAllKeysAndPaths,
revalidatePhoto,
@@ -218,3 +219,6 @@ export async function getPhotoItemsAction(query: string) {
}]
: [];
}
+
+export const getPhotosAction = async (offset: number, limit: number) =>
+ getPhotosCachedCached({ offset, limit });
diff --git a/src/state/SWRConfigClient.tsx b/src/state/SWRConfigClient.tsx
new file mode 100644
index 00000000..08b92a04
--- /dev/null
+++ b/src/state/SWRConfigClient.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SWRConfig } from 'swr';
+
+export default function SWRConfigClient({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return
+ {children}
+ ;
+}
diff --git a/src/tag/PhotoTags.tsx b/src/tag/PhotoTags.tsx
index 9e7bfbc1..802f76ee 100644
--- a/src/tag/PhotoTags.tsx
+++ b/src/tag/PhotoTags.tsx
@@ -2,6 +2,7 @@ import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.';
import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
+import { Fragment } from 'react';
export default function PhotoTags({
tags,
@@ -13,11 +14,11 @@ export default function PhotoTags({
return (
{tags.map(tag =>
- <>
+
{isTagFavs(tag)
- ?
- : }
- >)}
+ ?
+ : }
+ )}
);
}