Paginate camera and tag views

This commit is contained in:
Sam Becker 2023-10-04 13:14:19 -05:00
parent ee841518ec
commit e93e23f428
23 changed files with 320 additions and 130 deletions

View File

@ -19,7 +19,7 @@ import {
pathForPhoto, pathForPhoto,
pathForPhotoEdit, pathForPhotoEdit,
} from '@/site/paths'; } from '@/site/paths';
import { getPhotosLimitForQuery, titleForPhoto } from '@/photo'; import { titleForPhoto } from '@/photo';
import MorePhotos from '@/components/MorePhotos'; import MorePhotos from '@/components/MorePhotos';
import { import {
getBlobPhotoUrlsCached, getBlobPhotoUrlsCached,
@ -29,17 +29,17 @@ import {
} from '@/cache'; } from '@/cache';
import { AiOutlineEyeInvisible } from 'react-icons/ai'; import { AiOutlineEyeInvisible } from 'react-icons/ai';
import { BiTrash } from 'react-icons/bi'; import { BiTrash } from 'react-icons/bi';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
export const runtime = 'edge'; export const runtime = 'edge';
const DEBUG_PHOTO_BLOBS = false; const DEBUG_PHOTO_BLOBS = false;
export default async function AdminPage({ export default async function AdminPage({ searchParams }: PaginationParams) {
searchParams, const { offset, limit } = getPaginationForSearchParams(searchParams);
}: {
searchParams: { next: string };
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const [ const [
photos, photos,

View File

@ -5,9 +5,8 @@ import {
getUniqueTagsCached, getUniqueTagsCached,
} from '@/cache'; } from '@/cache';
import HeaderList from '@/components/HeaderList'; import HeaderList from '@/components/HeaderList';
import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoCamera from '@/camera/PhotoCamera'; import PhotoCamera from '@/camera/PhotoCamera';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
@ -17,6 +16,10 @@ import PhotoTag from '@/tag/PhotoTag';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { FaTag } from 'react-icons/fa'; import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io'; import { IoMdCamera } from 'react-icons/io';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
export const runtime = 'edge'; export const runtime = 'edge';
@ -25,12 +28,8 @@ export async function generateMetadata(): Promise<Metadata> {
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
} }
export default async function GridPage({ export default async function GridPage({ searchParams }: PaginationParams) {
searchParams, const { offset, limit } = getPaginationForSearchParams(searchParams);
}: {
searchParams: { next: string };
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const [ const [
photos, photos,
@ -44,16 +43,14 @@ export default async function GridPage({
getUniqueCamerasCached(), getUniqueCamerasCached(),
]); ]);
const showMorePhotos = count > photos.length; const showMorePath = count > photos.length
? pathForGrid(offset + 1)
: undefined;
return ( return (
photos.length > 0 photos.length > 0
? <SiteGrid ? <SiteGrid
contentMain={<div className="space-y-4"> contentMain={<PhotoGrid {...{ photos, showMorePath }} />}
<PhotoGrid photos={photos} />
{showMorePhotos &&
<MorePhotos path={pathForGrid(offset + 1)} />}
</div>}
contentSide={<div className="sticky top-4 space-y-4"> contentSide={<div className="sticky top-4 space-y-4">
{tags.length > 0 && <HeaderList {tags.length > 0 && <HeaderList
title='Tags' title='Tags'

View File

@ -1,17 +1,16 @@
import { getPhotosCached, getPhotosCountCached } from '@/cache'; import { getPhotosCached, getPhotosCountCached } from '@/cache';
import MorePhotos from '@/components/MorePhotos'; import MorePhotos from '@/components/MorePhotos';
import { getPhotosLimitForQuery } from '@/photo';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos'; import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForOg } from '@/site/paths'; import { pathForOg } from '@/site/paths';
export const runtime = 'edge'; export const runtime = 'edge';
export default async function GridPage({ export default async function GridPage({ searchParams }: PaginationParams) {
searchParams, const { offset, limit } = getPaginationForSearchParams(searchParams);
}: {
searchParams: { next: string };
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next);
const [ const [
photos, photos,

View File

@ -2,9 +2,13 @@ import { getPhotosCached, getPhotosCountCached } from '@/cache';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import MorePhotos from '@/components/MorePhotos'; import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoLarge from '@/photo/PhotoLarge'; import PhotoLarge from '@/photo/PhotoLarge';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForRoot } from '@/site/paths'; import { pathForRoot } from '@/site/paths';
import { Metadata } from 'next'; import { Metadata } from 'next';
@ -15,12 +19,8 @@ export async function generateMetadata(): Promise<Metadata> {
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
} }
export default async function HomePage({ export default async function HomePage({ searchParams }: PaginationParams) {
searchParams, const { offset, limit } = getPaginationForSearchParams(searchParams, 12);
}: {
searchParams: { next: string };
}) {
const { offset, limit } = getPhotosLimitForQuery(searchParams.next, 12);
const [ const [
photos, photos,

View File

@ -1,4 +1,4 @@
import { getPhotosCached } from '@/cache'; import { getPhotosCached, getPhotosCountCameraCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import CameraHeader from '@/camera/CameraHeader'; import CameraHeader from '@/camera/CameraHeader';
import { getMakeModelFromCameraString } from '@/camera'; import { getMakeModelFromCameraString } from '@/camera';
@ -6,9 +6,15 @@ import PhotoGrid from '@/photo/PhotoGrid';
import { getUniqueCameras } from '@/services/postgres'; import { getUniqueCameras } from '@/services/postgres';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { generateMetaForCamera } from '@/camera/meta'; import { generateMetaForCamera } from '@/camera/meta';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { pathForCamera } from '@/site/paths';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
interface CameraProps { interface CameraProps {
params: { camera: string } params: { camera: string },
} }
export async function generateStaticParams() { export async function generateStaticParams() {
@ -22,14 +28,21 @@ export async function generateMetadata({
params, params,
}: CameraProps): Promise<Metadata> { }: CameraProps): Promise<Metadata> {
const camera = getMakeModelFromCameraString(params.camera); const camera = getMakeModelFromCameraString(params.camera);
const photos = await getPhotosCached({ camera });
const [
photos,
count,
] = await Promise.all([
getPhotosCached({ camera, limit: GRID_THUMBNAILS_TO_SHOW_MAX }),
getPhotosCountCameraCached(camera),
]);
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForCamera(camera, photos); } = generateMetaForCamera(camera, photos, count);
return { return {
title, title,
@ -48,17 +61,32 @@ export async function generateMetadata({
}; };
} }
export default async function CameraPage({ params }:CameraProps) { export default async function CameraPage({
params,
searchParams,
}: CameraProps & PaginationParams) {
const camera = getMakeModelFromCameraString(params.camera); const camera = getMakeModelFromCameraString(params.camera);
const photos = await getPhotosCached({ camera }); const { offset, limit } = getPaginationForSearchParams(searchParams);
const [
photos,
count,
] = await Promise.all([
getPhotosCached({ camera, limit }),
getPhotosCountCameraCached(camera),
]);
const showMorePath = count > photos.length
? pathForCamera(camera, offset + 1)
: undefined;
return ( return (
<SiteGrid <SiteGrid
key="Camera Grid" key="Camera Grid"
contentMain={<div className="space-y-8 mt-4"> contentMain={<div className="space-y-8 mt-4">
<CameraHeader camera={camera} photos={photos} /> <CameraHeader {...{ camera, photos, count }} />
<PhotoGrid photos={photos} camera={camera} /> <PhotoGrid {...{ photos, camera, showMorePath }} />
</div>} </div>}
/> />
); );

View File

@ -1,4 +1,4 @@
import { getPhotosCached } from '@/cache'; import { getPhotosCached, getPhotosCountCameraCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { cameraFromPhoto, getMakeModelFromCameraString } from '@/camera'; import { cameraFromPhoto, getMakeModelFromCameraString } from '@/camera';
import CameraHeader from '@/camera/CameraHeader'; import CameraHeader from '@/camera/CameraHeader';
@ -7,6 +7,12 @@ import { generateMetaForCamera } from '@/camera/meta';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
import { getUniqueCameras } from '@/services/postgres'; import { getUniqueCameras } from '@/services/postgres';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { pathForCamera } from '@/site/paths';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
interface CameraProps { interface CameraProps {
params: { camera: string } params: { camera: string }
@ -24,14 +30,20 @@ export async function generateMetadata({
}: CameraProps): Promise<Metadata> { }: CameraProps): Promise<Metadata> {
const camera = getMakeModelFromCameraString(params.camera); const camera = getMakeModelFromCameraString(params.camera);
const photos = await getPhotosCached({ camera }); const [
photos,
count,
] = await Promise.all([
getPhotosCached({ camera, limit: GRID_THUMBNAILS_TO_SHOW_MAX }),
getPhotosCountCameraCached(camera),
]);
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForCamera(camera, photos); } = generateMetaForCamera(camera, photos, count);
return { return {
title, title,
@ -50,20 +62,35 @@ export async function generateMetadata({
}; };
} }
export default async function Share({ params }: CameraProps) { export default async function Share({
params,
searchParams,
}: CameraProps & PaginationParams) {
const cameraFromParams = getMakeModelFromCameraString(params.camera); const cameraFromParams = getMakeModelFromCameraString(params.camera);
const photos = await getPhotosCached({ camera: cameraFromParams }); const { offset, limit } = getPaginationForSearchParams(searchParams);
const [
photos,
count,
] = await Promise.all([
getPhotosCached({ camera: cameraFromParams, limit }),
getPhotosCountCameraCached(cameraFromParams),
]);
const camera = cameraFromPhoto(photos[0], cameraFromParams); const camera = cameraFromPhoto(photos[0], cameraFromParams);
const showMorePath = count > photos.length
? pathForCamera(camera, offset + 1)
: undefined;
return <> return <>
<CameraShareModal {...{ camera, photos }} /> <CameraShareModal {...{ camera, photos, count }} />
<SiteGrid <SiteGrid
key="Camera Grid" key="Camera Grid"
contentMain={<div className="space-y-8 mt-4"> contentMain={<div className="space-y-8 mt-4">
<CameraHeader camera={camera} photos={photos} /> <CameraHeader {...{ camera, photos, count }} />
<PhotoGrid photos={photos} camera={camera} /> <PhotoGrid {...{ photos, camera, showMorePath, animate: false }} />
</div>} </div>}
/> />
</>; </>;

View File

@ -1,7 +1,13 @@
import { getPhotosCached } from '@/cache'; import { getPhotosCached, getPhotosCountTagCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
import { getUniqueTags } from '@/services/postgres'; import { getUniqueTags } from '@/services/postgres';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForTag } from '@/site/paths';
import { generateMetaForTag } from '@/tag'; import { generateMetaForTag } from '@/tag';
import TagHeader from '@/tag/TagHeader'; import TagHeader from '@/tag/TagHeader';
import { Metadata } from 'next'; import { Metadata } from 'next';
@ -20,14 +26,20 @@ export async function generateStaticParams() {
export async function generateMetadata({ export async function generateMetadata({
params: { tag }, params: { tag },
}: TagProps): Promise<Metadata> { }: TagProps): Promise<Metadata> {
const photos = await getPhotosCached({ tag }); const [
photos,
count,
] = await Promise.all([
getPhotosCached({ tag, limit: GRID_THUMBNAILS_TO_SHOW_MAX }),
getPhotosCountTagCached(tag),
]);
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForTag(tag, photos); } = generateMetaForTag(tag, photos, count);
return { return {
title, title,
@ -46,15 +58,30 @@ export async function generateMetadata({
}; };
} }
export default async function TagPage({ params: { tag } }:TagProps) { export default async function TagPage({
const photos = await getPhotosCached({ tag }); params: { tag },
searchParams,
}:TagProps & PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const [
photos,
count,
] = await Promise.all([
getPhotosCached({ tag, limit }),
getPhotosCountTagCached(tag),
]);
const showMorePath = count > photos.length
? pathForTag(tag, offset + 1)
: undefined;
return ( return (
<SiteGrid <SiteGrid
key="Tag Grid" key="Tag Grid"
contentMain={<div className="space-y-8 mt-4"> contentMain={<div className="space-y-8 mt-4">
<TagHeader tag={tag} photos={photos} /> <TagHeader {...{ tag, photos, count }} />
<PhotoGrid photos={photos} tag={tag} /> <PhotoGrid {...{ photos, tag, showMorePath }} />
</div>} </div>}
/> />
); );

View File

@ -1,7 +1,13 @@
import { getPhotosCached } from '@/cache'; import { getPhotosCached, getPhotosCountTagCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
import { getUniqueTags } from '@/services/postgres'; import { getUniqueTags } from '@/services/postgres';
import {
PaginationParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForTag } from '@/site/paths';
import { generateMetaForTag } from '@/tag'; import { generateMetaForTag } from '@/tag';
import TagHeader from '@/tag/TagHeader'; import TagHeader from '@/tag/TagHeader';
import TagShareModal from '@/tag/TagShareModal'; import TagShareModal from '@/tag/TagShareModal';
@ -21,14 +27,20 @@ export async function generateStaticParams() {
export async function generateMetadata({ export async function generateMetadata({
params: { tag }, params: { tag },
}: TagProps): Promise<Metadata> { }: TagProps): Promise<Metadata> {
const photos = await getPhotosCached({ tag }); const [
photos,
count,
] = await Promise.all([
getPhotosCached({ tag, limit: GRID_THUMBNAILS_TO_SHOW_MAX }),
getPhotosCountTagCached(tag),
]);
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForTag(tag, photos); } = generateMetaForTag(tag, photos, count);
return { return {
title, title,
@ -49,17 +61,29 @@ export async function generateMetadata({
export default async function Share({ export default async function Share({
params: { tag }, params: { tag },
}: { searchParams,
params: { tag: string } }: TagProps & PaginationParams) {
}) { const { offset, limit } = getPaginationForSearchParams(searchParams);
const photos = await getPhotosCached({ tag });
const [
photos,
count,
] = await Promise.all([
getPhotosCached({ tag, limit }),
getPhotosCountTagCached(tag),
]);
const showMorePath = count > photos.length
? pathForTag(tag, offset + 1)
: undefined;
return <> return <>
<TagShareModal {...{ tag, photos }} /> <TagShareModal {...{ tag, photos, count }} />
<SiteGrid <SiteGrid
key="Tag Grid" key="Tag Grid"
contentMain={<div className="space-y-8 mt-4"> contentMain={<div className="space-y-8 mt-4">
<TagHeader tag={tag} photos={photos} /> <TagHeader {...{ tag, photos }} />
<PhotoGrid photos={photos} tag={tag} animate={false} /> <PhotoGrid {...{ photos, tag, showMorePath, animate: false }} />
</div>} </div>}
/> />
</>; </>;

21
src/cache/index.ts vendored
View File

@ -4,7 +4,9 @@ import {
getPhoto, getPhoto,
getPhotos, getPhotos,
getPhotosCount, getPhotosCount,
getPhotosCountCamera,
getPhotosCountIncludingHidden, getPhotosCountIncludingHidden,
getPhotosCountTag,
getUniqueCameras, getUniqueCameras,
getUniqueTags, getUniqueTags,
} from '@/services/postgres'; } from '@/services/postgres';
@ -15,7 +17,9 @@ import { AuthSession } from 'next-auth';
const TAG_PHOTOS = 'photos'; const TAG_PHOTOS = 'photos';
const TAG_PHOTOS_COUNT = 'photos-count'; const TAG_PHOTOS_COUNT = 'photos-count';
const TAG_TAGS = 'tags'; const TAG_TAGS = 'tags';
const TAG_TAGS_COUNT = 'tags-count';
const TAG_CAMERAS = 'cameras'; const TAG_CAMERAS = 'cameras';
const TAG_CAMERAS_COUNT = 'cameras-count';
const TAG_BLOB = 'blob'; const TAG_BLOB = 'blob';
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
@ -100,6 +104,23 @@ export const getPhotosCountCached: typeof getPhotosCount = (...args) =>
} }
)(); )();
export const getPhotosCountTagCached: typeof getPhotosCountTag = (...args) =>
unstable_cache(
() => getPhotosCountTag(...args),
[TAG_PHOTOS, TAG_TAGS_COUNT], {
tags: [TAG_PHOTOS, TAG_TAGS_COUNT],
}
)();
// eslint-disable-next-line max-len
export const getPhotosCountCameraCached: typeof getPhotosCountCamera = (...args) =>
unstable_cache(
() => getPhotosCountCamera(...args),
[TAG_PHOTOS, TAG_CAMERAS_COUNT], {
tags: [TAG_PHOTOS, TAG_CAMERAS_COUNT],
}
)();
export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount = export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount =
(...args) => (...args) =>
unstable_cache( unstable_cache(

View File

@ -9,20 +9,23 @@ export default function CameraHeader({
camera: cameraProp, camera: cameraProp,
photos, photos,
selectedPhoto, selectedPhoto,
count,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
count?: number
}) { }) {
const camera = cameraFromPhoto(photos[0], cameraProp); const camera = cameraFromPhoto(photos[0], cameraProp);
return ( return (
<PhotoHeader <PhotoHeader
entity={<PhotoCamera {...{ camera }} />} entity={<PhotoCamera {...{ camera }} />}
entityVerb="Photo" entityVerb="Photo"
entityDescription={descriptionForCameraPhotos(photos)} entityDescription={descriptionForCameraPhotos(photos, undefined, count)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
sharePath={pathForCameraShare(camera)} sharePath={pathForCameraShare(camera)}
count={count}
/> />
); );
} }

View File

@ -14,6 +14,7 @@ export default function CameraOGTile({
onLoad, onLoad,
onFail, onFail,
retryTime, retryTime,
count,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
@ -22,11 +23,12 @@ export default function CameraOGTile({
onFail?: () => void onFail?: () => void
riseOnHover?: boolean riseOnHover?: boolean
retryTime?: number retryTime?: number
count?: number
}) { }) {
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForCamera(camera, photos), title: titleForCamera(camera, photos, count),
description: descriptionForCameraPhotos(photos, true), description: descriptionForCameraPhotos(photos, true, count),
path: pathForCamera(camera), path: pathForCamera(camera),
pathImageAbsolute: absolutePathForCameraImage(camera), pathImageAbsolute: absolutePathForCameraImage(camera),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -7,9 +7,11 @@ import { Camera } from '.';
export default function CameraShareModal({ export default function CameraShareModal({
camera, camera,
photos, photos,
count,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
count?: number
}) { }) {
return ( return (
<ShareModal <ShareModal
@ -17,7 +19,7 @@ export default function CameraShareModal({
pathShare={absolutePathForCamera(camera)} pathShare={absolutePathForCamera(camera)}
pathClose={pathForCamera(camera)} pathClose={pathForCamera(camera)}
> >
<CameraOGTile {...{ camera, photos }} /> <CameraOGTile {...{ camera, photos, count }} />
</ShareModal> </ShareModal>
); );
}; };

View File

@ -12,24 +12,27 @@ import {
export const titleForCamera = ( export const titleForCamera = (
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
explicitCount?: number,
) => [ ) => [
'Shot on', 'Shot on',
formatCameraText(cameraFromPhoto(photos[0], camera)), formatCameraText(cameraFromPhoto(photos[0], camera)),
photoQuantityText(photos), photoQuantityText(explicitCount ?? photos.length),
].join(' '); ].join(' ');
export const descriptionForCameraPhotos = ( export const descriptionForCameraPhotos = (
photos: Photo[], photos: Photo[],
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number,
) => ) =>
descriptionForPhotoSet(photos, undefined, dateBased); descriptionForPhotoSet(photos, undefined, dateBased, explicitCount);
export const generateMetaForCamera = ( export const generateMetaForCamera = (
camera: Camera, camera: Camera,
photos: Photo[] photos: Photo[],
explicitCount?: number,
) => ({ ) => ({
url: absolutePathForCamera(camera), url: absolutePathForCamera(camera),
title: titleForCamera(camera, photos), title: titleForCamera(camera, photos, explicitCount),
description: descriptionForCameraPhotos(photos, true), description: descriptionForCameraPhotos(photos, true, explicitCount),
images: absolutePathForCameraImage(camera), images: absolutePathForCameraImage(camera),
}); });

View File

@ -3,6 +3,7 @@ import PhotoSmall from './PhotoSmall';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import MorePhotos from '@/components/MorePhotos';
export default function PhotoGrid({ export default function PhotoGrid({
photos, photos,
@ -13,6 +14,7 @@ export default function PhotoGrid({
animate = true, animate = true,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,
staggerOnFirstLoadOnly = true, staggerOnFirstLoadOnly = true,
showMorePath,
}: { }: {
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
@ -22,28 +24,33 @@ export default function PhotoGrid({
animate?: boolean animate?: boolean
animateOnFirstLoadOnly?: boolean animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean staggerOnFirstLoadOnly?: boolean
showMorePath?: string
}) { }) {
return ( return (
<AnimateItems <div className="space-y-4">
className={cc( <AnimateItems
'grid gap-1', className={cc(
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4', 'grid gap-1',
'items-center', 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
)} 'items-center',
type={animate === false ? 'none' : undefined} )}
duration={fast ? 0.3 : undefined} type={animate === false ? 'none' : undefined}
staggerDelay={0.075} duration={fast ? 0.3 : undefined}
distanceOffset={40} staggerDelay={0.075}
animateOnFirstLoadOnly={animateOnFirstLoadOnly} distanceOffset={40}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly} animateOnFirstLoadOnly={animateOnFirstLoadOnly}
items={photos.map(photo => staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
<PhotoSmall items={photos.map(photo =>
key={photo.id} <PhotoSmall
photo={photo} key={photo.id}
tag={tag} photo={photo}
camera={camera} tag={tag}
selected={photo.id === selectedPhoto?.id} camera={camera}
/>)} selected={photo.id === selectedPhoto?.id}
/> />)}
/>
{showMorePath &&
<MorePhotos path={showMorePath} />}
</div>
); );
}; };

View File

@ -9,6 +9,7 @@ export default function PhotoHeader({
photos, photos,
selectedPhoto, selectedPhoto,
sharePath, sharePath,
count,
}: { }: {
entity: JSX.Element entity: JSX.Element
entityVerb: string entityVerb: string
@ -16,6 +17,7 @@ export default function PhotoHeader({
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
sharePath: string sharePath: string
count?: number
}) { }) {
const { start, end } = dateRangeForPhotos(photos); const { start, end } = dateRangeForPhotos(photos);
@ -35,7 +37,8 @@ export default function PhotoHeader({
'sm:col-span-2 md:col-span-1 lg:col-span-2', 'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}> )}>
{selectedPhotoIndex !== undefined {selectedPhotoIndex !== undefined
? `${entityVerb} ${selectedPhotoIndex + 1} of ${photos.length}` // eslint-disable-next-line max-len
? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}`
: entityDescription} : entityDescription}
{selectedPhotoIndex === undefined && {selectedPhotoIndex === undefined &&
<ShareButton path={sharePath} dim />} <ShareButton path={sharePath} dim />}

View File

@ -130,18 +130,6 @@ export const getNextPhoto = (photo: Photo, photos: Photo[]) => {
: undefined; : undefined;
}; };
export const getPhotosLimitForQuery = (
query?: string,
photosPerRequest = 24,
) => {
const offsetInt = parseInt(query ?? '0');
const offset = (Number.isNaN(offsetInt) ? 0 : offsetInt);
return {
offset,
limit: photosPerRequest + offset * photosPerRequest,
};
};
export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => { export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
if (photos.length > 0) { if (photos.length > 0) {
return { return {
@ -169,23 +157,24 @@ export const translatePhotoId = (id: string) =>
export const titleForPhoto = (photo: Photo) => export const titleForPhoto = (photo: Photo) =>
photo.title || 'Untitled'; photo.title || 'Untitled';
const labelForPhotos = (photos: Photo[]) => const photoLabelForCount = (count: number) =>
photos.length === 1 ? 'Photo' : 'Photos'; count === 1 ? 'Photo' : 'Photos';
export const photoQuantityText = (photos: Photo[]) => export const photoQuantityText = (count: number) =>
`(${photos.length} ${labelForPhotos(photos)})`; `(${count} ${photoLabelForCount(count)})`;
export const descriptionForPhotoSet = ( export const descriptionForPhotoSet = (
photos:Photo[], photos:Photo[],
descriptor?: string, descriptor?: string,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number,
) => ) =>
dateBased dateBased
? dateRangeForPhotos(photos).description.toUpperCase() ? dateRangeForPhotos(photos).description.toUpperCase()
: [ : [
photos.length, explicitCount ?? photos.length,
descriptor, descriptor,
labelForPhotos(photos), photoLabelForCount(explicitCount ?? photos.length),
].join(' '); ].join(' ');
export const dateRangeForPhotos = (photos: Photo[]) => { export const dateRangeForPhotos = (photos: Photo[]) => {

View File

@ -239,6 +239,20 @@ const sqlGetPhotosCountIncludingHidden = async () => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
`.then(({ rows }) => parseInt(rows[0].count, 10)); `.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosCountTag = async (tag: string) => sql`
SELECT COUNT(*) FROM photos
WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosCountCamera = async (camera: Camera) => sql`
SELECT COUNT(*) FROM photos
WHERE
LOWER(make)=${parameterize(camera.make)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model)} AND
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetUniqueTags = async () => sql` const sqlGetUniqueTags = async () => sql`
SELECT DISTINCT unnest(tags) as tag FROM photos SELECT DISTINCT unnest(tags) as tag FROM photos
WHERE hidden IS NOT TRUE WHERE hidden IS NOT TRUE
@ -337,7 +351,12 @@ export const getPhoto = async (id: string): Promise<Photo | undefined> => {
.then(photos => photos.length > 0 ? photos[0] : undefined); .then(photos => photos.length > 0 ? photos[0] : undefined);
}; };
export const getPhotosCount = () => safelyQueryPhotos(sqlGetPhotosCount); export const getPhotosCount = () =>
safelyQueryPhotos(sqlGetPhotosCount);
export const getPhotosCountTag = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosCountTag(tag));
export const getPhotosCountCamera = (camera: Camera) =>
safelyQueryPhotos(() => sqlGetPhotosCountCamera(camera));
export const getPhotosCountIncludingHidden = () => export const getPhotosCountIncludingHidden = () =>
safelyQueryPhotos(sqlGetPhotosCountIncludingHidden); safelyQueryPhotos(sqlGetPhotosCountIncludingHidden);

17
src/site/pagination.ts Normal file
View File

@ -0,0 +1,17 @@
type PaginationSearchParams = { next: string };
export interface PaginationParams {
searchParams?: PaginationSearchParams
}
export const getPaginationForSearchParams = (
query?: PaginationSearchParams,
limitPerOffset = 24,
) => {
const offsetInt = parseInt(query?.next ?? '0');
const offset = (Number.isNaN(offsetInt) ? 0 : offsetInt);
return {
offset,
limit: limitPerOffset + offset * limitPerOffset,
};
};

View File

@ -72,14 +72,20 @@ export const pathForPhotoShare = (
export const pathForPhotoEdit = (photo: PhotoOrPhotoId) => export const pathForPhotoEdit = (photo: PhotoOrPhotoId) =>
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`; `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`;
export const pathForTag = (tag: string) => export const pathForTag = (tag: string, next?: number) =>
`${PREFIX_TAG}/${tag}`; pathWithNext(
`${PREFIX_TAG}/${tag}`,
next,
);
export const pathForTagShare = (tag: string) => export const pathForTagShare = (tag: string) =>
`${pathForTag(tag)}/${SHARE}`; `${pathForTag(tag)}/${SHARE}`;
export const pathForCamera = ({ make, model }: Camera) => export const pathForCamera = ({ make, model }: Camera, next?: number) =>
`${PREFIX_CAMERA}/${createCameraKey(make, model)}`; pathWithNext(
`${PREFIX_CAMERA}/${createCameraKey(make, model)}`,
next,
);
export const pathForCameraShare = (camera: Camera) => export const pathForCameraShare = (camera: Camera) =>
`${pathForCamera(camera)}/${SHARE}`; `${pathForCamera(camera)}/${SHARE}`;

View File

@ -8,19 +8,22 @@ export default function TagHeader({
tag, tag,
photos, photos,
selectedPhoto, selectedPhoto,
count,
}: { }: {
tag: string tag: string
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
count?: number
}) { }) {
return ( return (
<PhotoHeader <PhotoHeader
entity={<PhotoTag tag={tag} />} entity={<PhotoTag tag={tag} />}
entityVerb="Tagged" entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos)} entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
sharePath={pathForTagShare(tag)} sharePath={pathForTagShare(tag)}
count={count}
/> />
); );
} }

View File

@ -13,6 +13,7 @@ export default function TagOGTile({
onLoad, onLoad,
onFail, onFail,
retryTime, retryTime,
count,
}: { }: {
tag: string tag: string
photos: Photo[] photos: Photo[]
@ -21,11 +22,12 @@ export default function TagOGTile({
onFail?: () => void onFail?: () => void
riseOnHover?: boolean riseOnHover?: boolean
retryTime?: number retryTime?: number
count?: number
}) { }) {
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForTag(tag, photos), title: titleForTag(tag, photos),
description: descriptionForTaggedPhotos(photos, true), description: descriptionForTaggedPhotos(photos, true, count),
path: pathForTag(tag), path: pathForTag(tag),
pathImageAbsolute: absolutePathForTagImage(tag), pathImageAbsolute: absolutePathForTagImage(tag),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -6,9 +6,11 @@ import TagOGTile from './TagOGTile';
export default function TagShareModal({ export default function TagShareModal({
tag, tag,
photos, photos,
count,
}: { }: {
tag: string tag: string
photos: Photo[] photos: Photo[]
count?: number
}) { }) {
return ( return (
<ShareModal <ShareModal
@ -16,7 +18,7 @@ export default function TagShareModal({
pathShare={absolutePathForTag(tag)} pathShare={absolutePathForTag(tag)}
pathClose={pathForTag(tag)} pathClose={pathForTag(tag)}
> >
<TagOGTile tag={tag} photos={photos} /> <TagOGTile {...{ tag, photos, count }} />
</ShareModal> </ShareModal>
); );
}; };

View File

@ -2,20 +2,29 @@ import { Photo, descriptionForPhotoSet, photoQuantityText } from '@/photo';
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths'; import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
import { capitalizeWords } from '@/utility/string'; import { capitalizeWords } from '@/utility/string';
export const titleForTag = (tag: string, photos:Photo[]) => [ export const titleForTag = (
tag: string,
photos:Photo[],
explicitCount?: number,
) => [
capitalizeWords(tag.replaceAll('-', ' ')), capitalizeWords(tag.replaceAll('-', ' ')),
photoQuantityText(photos), photoQuantityText(explicitCount ?? photos.length),
].join(' '); ].join(' ');
export const descriptionForTaggedPhotos = ( export const descriptionForTaggedPhotos = (
photos: Photo[], photos: Photo[],
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number,
) => ) =>
descriptionForPhotoSet(photos, 'tagged', dateBased); descriptionForPhotoSet(photos, 'tagged', dateBased, explicitCount);
export const generateMetaForTag = (tag: string, photos: Photo[]) => ({ export const generateMetaForTag = (
tag: string,
photos: Photo[],
explicitCount?: number,
) => ({
url: absolutePathForTag(tag), url: absolutePathForTag(tag),
title: titleForTag(tag, photos), title: titleForTag(tag, photos, explicitCount),
description: descriptionForTaggedPhotos(photos, true), description: descriptionForTaggedPhotos(photos, true),
images: absolutePathForTagImage(tag), images: absolutePathForTagImage(tag),
}); });