Refactor infinite scroll pattern, use for admin photos

This commit is contained in:
Sam Becker 2024-04-28 17:36:20 -05:00
parent eb59e58b1c
commit 6e7e46d602
14 changed files with 128 additions and 77 deletions

View File

@ -19,19 +19,29 @@ import {
syncPhotoExifDataAction,
} from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
export default function AdminPhotoTable({
photos,
onLastPhotoVisible,
revalidatePhoto,
}: {
photos: Photo[],
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
}) {
const { invalidateSwr } = useAppState();
return (
<AdminTable>
{photos.map(photo =>
{photos.map((photo, index) =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<PhotoTiny
photo={photo}
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}
/>
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
@ -43,7 +53,7 @@ export default function AdminPhotoTable({
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
<span>{titleForPhoto(photo)}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
@ -91,6 +101,7 @@ export default function AdminPhotoTable({
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
onSubmit={() => revalidatePhoto?.(photo.id, true)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />

View File

@ -0,0 +1,28 @@
'use client';
import InfinitePhotoScroll, {
InfinitePhotoScrollExternalProps,
} from '../photo/InfinitePhotoScroll';
import AdminPhotoTable from './AdminPhotoTable';
export default function AdminPhotoTableInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
return (
<InfinitePhotoScroll
cacheKey="AdminPhotoTable"
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
useCachedPhotos={false}
includeHiddenPhotos
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
<AdminPhotoTable
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -1,40 +1,36 @@
import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import { pathForAdminPhotos } from '@/site/paths';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import {
PaginationParams,
getPaginationFromSearchParams,
} from '@/site/pagination';
import StorageUrls from '@/admin/StorageUrls';
import { PRO_MODE_ENABLED } from '@/site/config';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams';
import { getPhotos } from '@/services/vercel-postgres';
import { revalidatePath } from 'next/cache';
import AdminPhotoTable from '@/admin/AdminPhotoTable';
import AdminPhotoTableInfinite from
'@/admin/AdminPhotoTableInfinite';
const DEBUG_PHOTO_BLOBS = false;
export default async function AdminPhotosPage({
searchParams,
}: PaginationParams) {
const { offset, limit } = getPaginationFromSearchParams(searchParams);
const INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS = 25;
const INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS = 50;
export default async function AdminPhotosPage() {
const [
photos,
count,
photosCount,
blobPhotoUrls,
] = await Promise.all([
getPhotos({ includeHidden: true, sortBy: 'createdAt', limit }),
getPhotos({
includeHidden: true,
sortBy: 'createdAt',
limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
}),
getPhotosCountIncludingHiddenCached(),
DEBUG_PHOTO_BLOBS ? getStoragePhotoUrlsNoStore() : [],
]);
const showMorePhotos = count > photos.length;
return (
<SiteGrid
contentMain={
@ -59,10 +55,10 @@ export default async function AdminPhotosPage({
</div>}
<div className="space-y-4">
<AdminPhotoTable photos={photos} />
{showMorePhotos &&
<MoreComponentsFromSearchParams
label="More photos"
path={pathForAdminPhotos(offset + 1)}
{photosCount > photos.length &&
<AdminPhotoTableInfinite
initialOffset={INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS}
/>}
</div>
</div>}

View File

@ -12,7 +12,7 @@ import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarData } from '@/photo/data';
import { getPhotos } from '@/services/vercel-postgres';
import { cache } from 'react';
import InfinitePhotoScrollGrid from '@/photo/InfinitePhotoScrollGrid';
import PhotoGridInfinite from '@/photo/PhotoGridInfinite';
export const dynamic = 'force-static';
@ -41,7 +41,7 @@ export default async function GridPage() {
contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScrollGrid
<PhotoGridInfinite
initialOffset={INFINITE_SCROLL_INITIAL_GRID}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_GRID}
/>}

View File

@ -9,8 +9,7 @@ import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import PhotosLarge from '@/photo/PhotosLarge';
import { cache } from 'react';
import { getPhotos, getPhotosCount } from '@/services/vercel-postgres';
import InfinitePhotoScrollPhotosLarge from
'@/photo/InfinitePhotoScrollPhotosLarge';
import PhotosLargeInfinite from '@/photo/PhotosLargeInfinite';
export const dynamic = 'force-static';
@ -44,7 +43,7 @@ export default async function HomePage() {
? <div className="space-y-1">
<PhotosLarge {...{ photos }} />
{photosCount > photos.length &&
<InfinitePhotoScrollPhotosLarge
<PhotosLargeInfinite
initialOffset={INFINITE_SCROLL_INITIAL_HOME}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_HOME}
/>}

View File

@ -30,11 +30,13 @@ export default function InfinitePhotoScroll({
itemsPerPage,
wrapMoreButtonInGrid,
useCachedPhotos = true,
includeHiddenPhotos,
children,
}: InfinitePhotoScrollExternalProps & {
cacheKey: string
wrapMoreButtonInGrid: boolean
wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean
includeHiddenPhotos?: boolean
children: (props: {
photos: Photo[]
onLastPhotoVisible: () => void
@ -56,12 +58,14 @@ export default function InfinitePhotoScroll({
? getPhotosCachedAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
includeHiddenPhotos,
)
: getPhotosAction(
initialOffset + size * itemsPerPage,
itemsPerPage,
includeHiddenPhotos,
)
, [useCachedPhotos, initialOffset, itemsPerPage]);
, [useCachedPhotos, initialOffset, itemsPerPage, includeHiddenPhotos]);
const { data, isLoading, isValidating, error, mutate, setSize } =
useSwrInfinite<Photo[]>(

View File

@ -5,7 +5,7 @@ import InfinitePhotoScroll, {
} from './InfinitePhotoScroll';
import PhotoGrid from './PhotoGrid';
export default function InfinitePhotoScrollGrid({
export default function PhotoGridInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
@ -14,13 +14,12 @@ export default function InfinitePhotoScrollGrid({
cacheKey="Grid"
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
wrapMoreButtonInGrid={false}
>
{({ photos, onLastPhotoVisible }) =>
<PhotoGrid {...{
photos,
onLastPhotoVisible,
}} />}
<PhotoGrid
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -22,7 +22,8 @@ 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';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoLarge({
photo,
@ -63,20 +64,7 @@ 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]);
useOnVisible(ref, onVisible);
return (
<SiteGrid

View File

@ -8,7 +8,8 @@ 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';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoSmall({
photo,
@ -31,20 +32,7 @@ export default function PhotoSmall({
}) {
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]);
useOnVisible(ref, onVisible);
return (
<Link

View File

@ -4,6 +4,8 @@ import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoTiny({
photo,
@ -11,15 +13,22 @@ export default function PhotoTiny({
selected,
className,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: {
photo: Photo
tag?: string
selected?: boolean
className?: string
prefetch?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLAnchorElement>(null);
useOnVisible(ref, onVisible);
return (
<Link
ref={ref}
href={pathForPhoto(photo, tag)}
className={clsx(
className,

View File

@ -7,14 +7,14 @@ export default function PhotosLarge({
photos,
animate = true,
prefetchFirstPhotoLinks,
revalidatePhoto,
onLastPhotoVisible,
revalidatePhoto,
}: {
photos: Photo[]
animate?: boolean
prefetchFirstPhotoLinks?: boolean
revalidatePhoto?: RevalidatePhoto
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
}) {
return (
<AnimateItems

View File

@ -5,7 +5,7 @@ import InfinitePhotoScroll, {
} from './InfinitePhotoScroll';
import PhotosLarge from './PhotosLarge';
export default function InfinitePhotoScrollPhotosLarge({
export default function PhotosLargeInfinite({
initialOffset,
itemsPerPage,
}: InfinitePhotoScrollExternalProps) {
@ -17,11 +17,11 @@ export default function InfinitePhotoScrollPhotosLarge({
wrapMoreButtonInGrid
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
<PhotosLarge {...{
photos,
onLastPhotoVisible,
revalidatePhoto,
}} />}
<PhotosLarge
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}
/>}
</InfinitePhotoScroll>
);
}

View File

@ -195,11 +195,19 @@ export async function streamAiImageQueryAction(
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
export const getPhotosCachedAction = async (offset: number, limit: number) =>
getPhotosCachedCached({ offset, limit });
export const getPhotosCachedAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotosCachedCached({ offset, includeHidden, limit });
export const getPhotosAction = async (offset: number, limit: number) =>
getPhotos({ offset, limit });
export const getPhotosAction = async (
offset: number,
limit: number,
includeHidden?: boolean,
) =>
getPhotos({ offset, includeHidden, limit });
export const queryPhotosByTitleAction = async (query: string) =>
(await getPhotos({ query, limit: 10 }))

View File

@ -0,0 +1,21 @@
import { useEffect } from 'react';
export default function useOnVisible(
ref: React.RefObject<HTMLElement>,
onVisible?: () => void
) {
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();
}
}, [ref, onVisible]);
}