Consolidate camera/tag pagination/date handling

This commit is contained in:
Sam Becker 2023-10-04 19:01:17 -05:00
parent 49b871ab13
commit 80823c8d14
24 changed files with 469 additions and 207 deletions

View File

@ -10,9 +10,14 @@ import {
absolutePathForPhotoImage, absolutePathForPhotoImage,
} from '@/site/paths'; } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage'; import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache'; import { getPhotoCached } from '@/cache';
import { getPhotos, getUniqueCameras } from '@/services/postgres'; import {
getPhotos,
getUniqueCameras,
} from '@/services/postgres';
import { cameraFromPhoto } from '@/camera'; import { cameraFromPhoto } from '@/camera';
import { getPhotosCameraDataCached } from '@/camera/data';
import { ReactNode } from 'react';
interface PhotoCameraProps { interface PhotoCameraProps {
params: { photoId: string, camera: string } params: { photoId: string, camera: string }
@ -69,19 +74,21 @@ export async function generateMetadata({
export default async function PhotoCameraPage({ export default async function PhotoCameraPage({
params: { photoId, camera: cameraProp }, params: { photoId, camera: cameraProp },
children, children,
}: PhotoCameraProps & { }: PhotoCameraProps & { children: ReactNode }) {
children: React.ReactNode
}) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { redirect(PATH_ROOT); } if (!photo) { redirect(PATH_ROOT); }
const camera = cameraFromPhoto(photo, cameraProp); const camera = cameraFromPhoto(photo, cameraProp);
const photos = await getPhotosCached({ camera }); const [
photos,
count,
dateRange,
] = await getPhotosCameraDataCached({ camera });
return <> return <>
{children} {children}
<PhotoDetailPage {...{ photo, photos, camera }} /> <PhotoDetailPage {...{ photo, photos, camera, count, dateRange }} />
</>; </>;
} }

View File

@ -1,16 +1,13 @@
import { getPhotosCached, getPhotosCountCameraCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import CameraHeader from '@/camera/CameraHeader';
import { getMakeModelFromCameraString } from '@/camera'; import { getMakeModelFromCameraString } from '@/camera';
import PhotoGrid from '@/photo/PhotoGrid';
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 { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { pathForCamera } from '@/site/paths'; import { PaginationParams } from '@/site/pagination';
import { import {
PaginationParams, getPhotosCameraDataCached,
getPaginationForSearchParams, getPhotosCameraDataCachedWithPagination,
} from '@/site/pagination'; } from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
export const runtime = 'edge'; export const runtime = 'edge';
@ -26,17 +23,18 @@ export async function generateMetadata({
const [ const [
photos, photos,
count, count,
] = await Promise.all([ dateRange,
getPhotosCached({ camera, limit: GRID_THUMBNAILS_TO_SHOW_MAX }), ] = await getPhotosCameraDataCached({
getPhotosCountCameraCached(camera), camera,
]); limit: GRID_THUMBNAILS_TO_SHOW_MAX,
});
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForCamera(camera, photos, count); } = generateMetaForCamera(camera, photos, count, dateRange);
return { return {
title, title,
@ -61,27 +59,17 @@ export default async function CameraPage({
}: CameraProps & PaginationParams) { }: CameraProps & PaginationParams) {
const camera = getMakeModelFromCameraString(params.camera); const camera = getMakeModelFromCameraString(params.camera);
const { offset, limit } = getPaginationForSearchParams(searchParams); const {
const [
photos, photos,
count, count,
] = await Promise.all([ showMorePath,
getPhotosCached({ camera, limit }), dateRange,
getPhotosCountCameraCached(camera), } = await getPhotosCameraDataCachedWithPagination({
]); camera,
searchParams,
const showMorePath = count > photos.length });
? pathForCamera(camera, offset + 1)
: undefined;
return ( return (
<SiteGrid <CameraOverview {...{ camera, photos, count, dateRange, showMorePath }} />
key="Camera Grid"
contentMain={<div className="space-y-8 mt-4">
<CameraHeader {...{ camera, photos, count }} />
<PhotoGrid {...{ photos, camera, showMorePath }} />
</div>}
/>
); );
} }

View File

@ -1,17 +1,14 @@
import { getPhotosCached, getPhotosCountCameraCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import { cameraFromPhoto, getMakeModelFromCameraString } from '@/camera'; import { cameraFromPhoto, getMakeModelFromCameraString } from '@/camera';
import CameraHeader from '@/camera/CameraHeader';
import CameraShareModal from '@/camera/CameraShareModal'; import CameraShareModal from '@/camera/CameraShareModal';
import { generateMetaForCamera } from '@/camera/meta'; import { generateMetaForCamera } from '@/camera/meta';
import PhotoGrid from '@/photo/PhotoGrid';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { pathForCamera } from '@/site/paths'; import { PaginationParams } from '@/site/pagination';
import { import {
PaginationParams, getPhotosCameraDataCached,
getPaginationForSearchParams, getPhotosCameraDataCachedWithPagination,
} from '@/site/pagination'; } from '@/camera/data';
import CameraOverview from '@/camera/CameraOverview';
export const runtime = 'edge'; export const runtime = 'edge';
@ -27,17 +24,18 @@ export async function generateMetadata({
const [ const [
photos, photos,
count, count,
] = await Promise.all([ dateRange,
getPhotosCached({ camera, limit: GRID_THUMBNAILS_TO_SHOW_MAX }), ] = await getPhotosCameraDataCached({
getPhotosCountCameraCached(camera), camera,
]); limit: GRID_THUMBNAILS_TO_SHOW_MAX,
});
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForCamera(camera, photos, count); } = generateMetaForCamera(camera, photos, count, dateRange);
return { return {
title, title,
@ -62,30 +60,23 @@ export default async function Share({
}: CameraProps & PaginationParams) { }: CameraProps & PaginationParams) {
const cameraFromParams = getMakeModelFromCameraString(params.camera); const cameraFromParams = getMakeModelFromCameraString(params.camera);
const { offset, limit } = getPaginationForSearchParams(searchParams); const {
const [
photos, photos,
count, count,
] = await Promise.all([ dateRange,
getPhotosCached({ camera: cameraFromParams, limit }), showMorePath,
getPhotosCountCameraCached(cameraFromParams), } = await getPhotosCameraDataCachedWithPagination({
]); camera: cameraFromParams,
searchParams,
});
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, count }} /> <CameraShareModal {...{ camera, photos, count, dateRange }} />
<SiteGrid <CameraOverview
key="Camera Grid" {...{ camera, photos, count, dateRange, showMorePath }}
contentMain={<div className="space-y-8 mt-4"> animateOnFirstLoadOnly
<CameraHeader {...{ camera, photos, count }} />
<PhotoGrid {...{ photos, camera, showMorePath, animate: false }} />
</div>}
/> />
</>; </>;
} }

View File

@ -10,11 +10,17 @@ import {
absolutePathForPhotoImage, absolutePathForPhotoImage,
} from '@/site/paths'; } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage'; import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache'; import { getPhotoCached } from '@/cache';
import { getPhotos, getUniqueTags } from '@/services/postgres'; import { getPhotos, getUniqueTags } from '@/services/postgres';
import { getPhotosTagDataCached } from '@/tag/data';
import { ReactNode } from 'react';
interface PhotoTagProps {
params: { photoId: string, tag: string }
}
export async function generateStaticParams() { export async function generateStaticParams() {
const params: { params: { photoId: string, tag: string }}[] = []; const params: PhotoTagProps[] = [];
const tags = await getUniqueTags(); const tags = await getUniqueTags();
tags.forEach(async tag => { tags.forEach(async tag => {
@ -29,9 +35,7 @@ export async function generateStaticParams() {
export async function generateMetadata({ export async function generateMetadata({
params: { photoId, tag }, params: { photoId, tag },
}: { }: PhotoTagProps): Promise<Metadata> {
params: { photoId: string, tag: string }
}): Promise<Metadata> {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { return {}; } if (!photo) { return {}; }
@ -62,18 +66,19 @@ export async function generateMetadata({
export default async function PhotoTagPage({ export default async function PhotoTagPage({
params: { photoId, tag }, params: { photoId, tag },
children, children,
}: { }: PhotoTagProps & { children: ReactNode }) {
params: { photoId: string, tag: string }
children: React.ReactNode
}) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { redirect(PATH_ROOT); } if (!photo) { redirect(PATH_ROOT); }
const photos = await getPhotosCached({ tag }); const [
photos,
count,
dateRange,
] = await getPhotosTagDataCached({ tag });
return <> return <>
{children} {children}
<PhotoDetailPage {...{ photo, photos, tag }} /> <PhotoDetailPage {...{ photo, photos, tag, count, dateRange }} />
</>; </>;
} }

View File

@ -4,8 +4,12 @@ import { getPhotos, getUniqueTags } from '@/services/postgres';
import { PATH_ROOT } from '@/site/paths'; import { PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
interface PhotoTagProps {
params: { photoId: string, tag: string }
}
export async function generateStaticParams() { export async function generateStaticParams() {
const params: { params: { photoId: string, tag: string }}[] = []; const params: PhotoTagProps[] = [];
const tags = await getUniqueTags(); const tags = await getUniqueTags();
tags.forEach(async tag => { tags.forEach(async tag => {
@ -20,9 +24,7 @@ export async function generateStaticParams() {
export default async function Share({ export default async function Share({
params: { photoId, tag }, params: { photoId, tag },
}: { }: PhotoTagProps) {
params: { photoId: string, tag: string }
}) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { return redirect(PATH_ROOT); } if (!photo) { return redirect(PATH_ROOT); }

View File

@ -1,14 +1,11 @@
import { getPhotosCached, getPhotosCountTagCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import { PaginationParams } from '@/site/pagination';
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 TagOverview from '@/tag/TagOverview';
import {
getPhotosTagDataCached,
getPhotosTagDataCachedWithPagination,
} from '@/tag/data';
import { Metadata } from 'next'; import { Metadata } from 'next';
export const runtime = 'edge'; export const runtime = 'edge';
@ -23,10 +20,10 @@ export async function generateMetadata({
const [ const [
photos, photos,
count, count,
] = await Promise.all([ ] = await getPhotosTagDataCached({
getPhotosCached({ tag, limit: GRID_THUMBNAILS_TO_SHOW_MAX }), tag,
getPhotosCountTagCached(tag), limit: GRID_THUMBNAILS_TO_SHOW_MAX,
]); });
const { const {
url, url,
@ -56,27 +53,17 @@ export default async function TagPage({
params: { tag }, params: { tag },
searchParams, searchParams,
}:TagProps & PaginationParams) { }:TagProps & PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams); const {
const [
photos, photos,
count, count,
] = await Promise.all([ showMorePath,
getPhotosCached({ tag, limit }), dateRange,
getPhotosCountTagCached(tag), } = await getPhotosTagDataCachedWithPagination({
]); tag,
searchParams,
const showMorePath = count > photos.length });
? pathForTag(tag, offset + 1)
: undefined;
return ( return (
<SiteGrid <TagOverview {...{ tag, photos, count, dateRange, showMorePath }} />
key="Tag Grid"
contentMain={<div className="space-y-8 mt-4">
<TagHeader {...{ tag, photos, count }} />
<PhotoGrid {...{ photos, tag, showMorePath }} />
</div>}
/>
); );
} }

View File

@ -1,15 +1,12 @@
import { getPhotosCached, getPhotosCountTagCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import { PaginationParams } from '@/site/pagination';
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 TagOverview from '@/tag/TagOverview';
import TagShareModal from '@/tag/TagShareModal'; import TagShareModal from '@/tag/TagShareModal';
import {
getPhotosTagDataCached,
getPhotosTagDataCachedWithPagination,
} from '@/tag/data';
import { Metadata } from 'next'; import { Metadata } from 'next';
export const runtime = 'edge'; export const runtime = 'edge';
@ -24,17 +21,18 @@ export async function generateMetadata({
const [ const [
photos, photos,
count, count,
] = await Promise.all([ dateRange,
getPhotosCached({ tag, limit: GRID_THUMBNAILS_TO_SHOW_MAX }), ] = await getPhotosTagDataCached({
getPhotosCountTagCached(tag), tag,
]); limit: GRID_THUMBNAILS_TO_SHOW_MAX,
});
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForTag(tag, photos, count); } = generateMetaForTag(tag, photos, count, dateRange);
return { return {
title, title,
@ -57,28 +55,21 @@ export default async function Share({
params: { tag }, params: { tag },
searchParams, searchParams,
}: TagProps & PaginationParams) { }: TagProps & PaginationParams) {
const { offset, limit } = getPaginationForSearchParams(searchParams); const {
const [
photos, photos,
count, count,
] = await Promise.all([ dateRange,
getPhotosCached({ tag, limit }), showMorePath,
getPhotosCountTagCached(tag), } = await getPhotosTagDataCachedWithPagination({
]); tag,
searchParams,
const showMorePath = count > photos.length });
? pathForTag(tag, offset + 1)
: undefined;
return <> return <>
<TagShareModal {...{ tag, photos, count }} /> <TagShareModal {...{ tag, photos, count }} />
<SiteGrid <TagOverview
key="Tag Grid" {...{ tag, photos, count, dateRange, showMorePath }}
contentMain={<div className="space-y-8 mt-4"> animateOnFirstLoadOnly
<TagHeader {...{ tag, photos }} />
<PhotoGrid {...{ photos, tag, showMorePath, animate: false }} />
</div>}
/> />
</>; </>;
} }

67
src/cache/index.ts vendored
View File

@ -4,11 +4,13 @@ import {
getPhoto, getPhoto,
getPhotos, getPhotos,
getPhotosCount, getPhotosCount,
getPhotosCountCamera, getPhotosCameraCount,
getPhotosCountIncludingHidden, getPhotosCountIncludingHidden,
getPhotosCountTag, getPhotosTagCount,
getUniqueCameras, getUniqueCameras,
getUniqueTags, getUniqueTags,
getPhotosTagDateRange,
getPhotosCameraDateRange,
} from '@/services/postgres'; } from '@/services/postgres';
import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo'; import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo';
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
@ -16,7 +18,8 @@ import { AuthSession } from 'next-auth';
import { Camera, createCameraKey } from '@/camera'; import { Camera, createCameraKey } from '@/camera';
const TAG_PHOTOS = 'photos'; const TAG_PHOTOS = 'photos';
const TAG_PHOTOS_COUNT = 'photos-count'; const TAG_PHOTOS_COUNT = `${TAG_PHOTOS}-count`;
const TAG_PHOTOS_DATE_RANGE = `${TAG_PHOTOS}-date-range`;;
const TAG_TAGS = 'tags'; const TAG_TAGS = 'tags';
const TAG_CAMERAS = 'cameras'; const TAG_CAMERAS = 'cameras';
const TAG_BLOB = 'blob'; const TAG_BLOB = 'blob';
@ -69,6 +72,12 @@ const getPhotoTagCountTag = (tag: string) =>
const getPhotoCameraCountTag = ({ make, model }: Camera) => const getPhotoCameraCountTag = ({ make, model }: Camera) =>
`${TAG_PHOTOS_COUNT}-${TAG_CAMERAS}-${createCameraKey(make, model)}`; `${TAG_PHOTOS_COUNT}-${TAG_CAMERAS}-${createCameraKey(make, model)}`;
const getPhotoTagDateRangeTag = (tag: string) =>
`${TAG_PHOTOS_DATE_RANGE}-${TAG_TAGS}-${tag}`;
const getPhotoCameraDateRangeTag = ({ make, model }: Camera) =>
`${TAG_PHOTOS_DATE_RANGE}-${TAG_CAMERAS}-${createCameraKey(make, model)}`;
export const revalidatePhotosTag = () => export const revalidatePhotosTag = () =>
revalidateTag(TAG_PHOTOS); revalidateTag(TAG_PHOTOS);
@ -109,23 +118,6 @@ export const getPhotosCountCached: typeof getPhotosCount = (...args) =>
} }
)(); )();
export const getPhotosCountTagCached: typeof getPhotosCountTag = (...args) =>
unstable_cache(
() => getPhotosCountTag(...args),
[TAG_PHOTOS, getPhotoTagCountTag(...args)], {
tags: [TAG_PHOTOS, getPhotoTagCountTag(...args)],
}
)();
// eslint-disable-next-line max-len
export const getPhotosCountCameraCached: typeof getPhotosCountCamera = (...args) =>
unstable_cache(
() => getPhotosCountCamera(...args),
[TAG_PHOTOS, getPhotoCameraCountTag(...args)], {
tags: [TAG_PHOTOS, getPhotoCameraCountTag(...args)],
}
)();
export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount = export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount =
(...args) => (...args) =>
unstable_cache( unstable_cache(
@ -135,6 +127,41 @@ export const getPhotosCountIncludingHiddenCached: typeof getPhotosCount =
} }
)(); )();
export const getPhotosTagCountCached: typeof getPhotosTagCount = (...args) =>
unstable_cache(
() => getPhotosTagCount(...args),
[TAG_PHOTOS, getPhotoTagCountTag(...args)], {
tags: [TAG_PHOTOS, getPhotoTagCountTag(...args)],
}
)();
// eslint-disable-next-line max-len
export const getPhotosCameraCountCached: typeof getPhotosCameraCount = (...args) =>
unstable_cache(
() => getPhotosCameraCount(...args),
[TAG_PHOTOS, getPhotoCameraCountTag(...args)], {
tags: [TAG_PHOTOS, getPhotoCameraCountTag(...args)],
}
)();
// eslint-disable-next-line max-len
export const getPhotosTagDateRangeCached: typeof getPhotosTagDateRange = (...args) =>
unstable_cache(
() => getPhotosTagDateRange(...args),
[TAG_PHOTOS, getPhotoTagDateRangeTag(...args)], {
tags: [TAG_PHOTOS, getPhotoTagDateRangeTag(...args)],
}
)();
// eslint-disable-next-line max-len
export const getPhotosCameraDateRangeCached: typeof getPhotosCameraDateRange = (...args) =>
unstable_cache(
() => getPhotosCameraDateRange(...args),
[TAG_PHOTOS, getPhotoCameraDateRangeTag(...args)], {
tags: [TAG_PHOTOS, getPhotoCameraDateRangeTag(...args)],
}
)();
export const getPhotoCached: typeof getPhoto = (...args) => export const getPhotoCached: typeof getPhoto = (...args) =>
unstable_cache( unstable_cache(
() => getPhoto(...args), () => getPhoto(...args),

View File

@ -1,4 +1,4 @@
import { Photo } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForCameraShare } from '@/site/paths'; import { pathForCameraShare } from '@/site/paths';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import { Camera, cameraFromPhoto } from '.'; import { Camera, cameraFromPhoto } from '.';
@ -10,22 +10,26 @@ export default function CameraHeader({
photos, photos,
selectedPhoto, selectedPhoto,
count, count,
dateRange,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
count?: number count?: number
dateRange?: PhotoDateRange
}) { }) {
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, undefined, count)} entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
sharePath={pathForCameraShare(camera)} sharePath={pathForCameraShare(camera)}
count={count} count={count}
dateRange={dateRange}
/> />
); );
} }

View File

@ -1,4 +1,4 @@
import { Photo } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForCameraImage, pathForCamera } from '@/site/paths'; import { absolutePathForCameraImage, pathForCamera } from '@/site/paths';
import OGTile from '@/components/OGTile'; import OGTile from '@/components/OGTile';
import { Camera } from '.'; import { Camera } from '.';
@ -15,6 +15,7 @@ export default function CameraOGTile({
onFail, onFail,
retryTime, retryTime,
count, count,
dateRange,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
@ -24,11 +25,12 @@ export default function CameraOGTile({
riseOnHover?: boolean riseOnHover?: boolean
retryTime?: number retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange
}) { }) {
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForCamera(camera, photos, count), title: titleForCamera(camera, photos, count),
description: descriptionForCameraPhotos(photos, true, count), description: descriptionForCameraPhotos(photos, true, count, dateRange),
path: pathForCamera(camera), path: pathForCamera(camera),
pathImageAbsolute: absolutePathForCameraImage(camera), pathImageAbsolute: absolutePathForCameraImage(camera),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -0,0 +1,42 @@
import { Photo, PhotoDateRange } from '@/photo';
import { Camera } from '.';
import SiteGrid from '@/components/SiteGrid';
import AnimateItems from '@/components/AnimateItems';
import CameraHeader from './CameraHeader';
import PhotoGrid from '@/photo/PhotoGrid';
export default function CameraOverview({
camera,
photos,
count,
dateRange,
showMorePath,
animateOnFirstLoadOnly,
}: {
camera: Camera,
photos: Photo[],
count: number,
dateRange: PhotoDateRange,
showMorePath?: string,
animateOnFirstLoadOnly?: boolean,
}) {
return (
<SiteGrid
contentMain={<div className="space-y-8 mt-4">
<AnimateItems
type="bottom"
items={[
<CameraHeader
key="CameraHeader"
{...{ camera, photos, count, dateRange }}
/>,
]}
animateOnFirstLoadOnly
/>
<PhotoGrid
{...{ photos, camera, showMorePath, animateOnFirstLoadOnly }}
/>
</div>}
/>
);
}

View File

@ -1,5 +1,5 @@
import { absolutePathForCamera, pathForCamera } from '@/site/paths'; import { absolutePathForCamera, pathForCamera } from '@/site/paths';
import { Photo } from '../photo'; import { Photo, PhotoDateRange } from '../photo';
import ShareModal from '@/components/ShareModal'; import ShareModal from '@/components/ShareModal';
import CameraOGTile from './CameraOGTile'; import CameraOGTile from './CameraOGTile';
import { Camera } from '.'; import { Camera } from '.';
@ -8,10 +8,12 @@ export default function CameraShareModal({
camera, camera,
photos, photos,
count, count,
dateRange,
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
count?: number count: number
dateRange: PhotoDateRange,
}) { }) {
return ( return (
<ShareModal <ShareModal
@ -19,7 +21,7 @@ export default function CameraShareModal({
pathShare={absolutePathForCamera(camera)} pathShare={absolutePathForCamera(camera)}
pathClose={pathForCamera(camera)} pathClose={pathForCamera(camera)}
> >
<CameraOGTile {...{ camera, photos, count }} /> <CameraOGTile {...{ camera, photos, count, dateRange }} />
</ShareModal> </ShareModal>
); );
}; };

53
src/camera/data.ts Normal file
View File

@ -0,0 +1,53 @@
import {
PaginationSearchParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { Camera } from '.';
import {
getPhotosCached,
getPhotosCameraCountCached,
getPhotosCameraDateRangeCached,
} from '@/cache';
import { pathForCamera } from '@/site/paths';
export const getPhotosCameraDataCached = ({
camera,
limit,
}: {
camera: Camera,
limit?: number,
}) =>
Promise.all([
getPhotosCached({ camera, limit }),
getPhotosCameraCountCached(camera),
getPhotosCameraDateRangeCached(camera),
]);
export const getPhotosCameraDataCachedWithPagination = async ({
camera,
limit: limitProp,
searchParams,
}: {
camera: Camera,
limit?: number,
searchParams?: PaginationSearchParams,
}) => {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const [photos, count, dateRange] =
await getPhotosCameraDataCached({
camera,
limit: limitProp ?? limit,
});
const showMorePath = count > photos.length
? pathForCamera(camera, offset + 1)
: undefined;
return {
photos,
count,
dateRange,
showMorePath,
};
};

View File

@ -1,4 +1,9 @@
import { Photo, descriptionForPhotoSet, photoQuantityText } from '@/photo'; import {
Photo,
PhotoDateRange,
descriptionForPhotoSet,
photoQuantityText,
} from '@/photo';
import { Camera, cameraFromPhoto, formatCameraText } from '.'; import { Camera, cameraFromPhoto, formatCameraText } from '.';
import { import {
absolutePathForCamera, absolutePathForCamera,
@ -23,16 +28,25 @@ export const descriptionForCameraPhotos = (
photos: Photo[], photos: Photo[],
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet(photos, undefined, dateBased, explicitCount); descriptionForPhotoSet(
photos,
undefined,
dateBased,
explicitCount,
explicitDateRange,
);
export const generateMetaForCamera = ( export const generateMetaForCamera = (
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForCamera(camera), url: absolutePathForCamera(camera),
title: titleForCamera(camera, photos, explicitCount), title: titleForCamera(camera, photos, explicitCount),
description: descriptionForCameraPhotos(photos, true, explicitCount), description:
descriptionForCameraPhotos(photos, true, explicitCount, explicitDateRange),
images: absolutePathForCameraImage(camera), images: absolutePathForCameraImage(camera),
}); });

View File

@ -1,10 +1,10 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { motion } from 'framer-motion'; import { Variant, motion } from 'framer-motion';
import { useAppState } from '@/state'; import { useAppState } from '@/state';
export type AnimationType = 'none' | 'scale' | 'left' | 'right'; export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
export interface AnimationConfig { export interface AnimationConfig {
type?: AnimationType type?: AnimationType
@ -58,7 +58,7 @@ function AnimateItems({
? (nextPhotoAnimationInitial.current?.duration ?? duration) ? (nextPhotoAnimationInitial.current?.duration ?? duration)
: duration; : duration;
const getInitialVariant = () => { const getInitialVariant = (): Variant => {
switch (typeResolved) { switch (typeResolved) {
case 'left': return { case 'left': return {
opacity: 0, opacity: 0,
@ -68,6 +68,10 @@ function AnimateItems({
opacity: 0, opacity: 0,
transform: `translateX(${-distanceOffset}px)`, transform: `translateX(${-distanceOffset}px)`,
}; };
case 'bottom': return {
opacity: 0,
transform: `translateY(${distanceOffset}px)`,
};
default: return { default: return {
opacity: 0, opacity: 0,
transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`, transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`,
@ -98,7 +102,6 @@ function AnimateItems({
<motion.div <motion.div
key={index} key={index}
className={classNameItem} className={classNameItem}
// style={getInitialVariant()}
variants={{ variants={{
hidden: getInitialVariant(), hidden: getInitialVariant(),
show: { show: {

View File

@ -47,8 +47,7 @@ export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
<SiteGrid <SiteGrid
contentMain={ contentMain={
<AnimateItems <AnimateItems
type={!shouldAnimate ? 'none' : undefined} type={!shouldAnimate ? 'none' : 'bottom'}
scaleOffset={1}
distanceOffset={10} distanceOffset={10}
items={showNav items={showNav
? [<div ? [<div

View File

@ -1,5 +1,5 @@
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { Photo } from '.'; import { Photo, PhotoDateRange } from '.';
import PhotoLarge from './PhotoLarge'; import PhotoLarge from './PhotoLarge';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
@ -15,12 +15,16 @@ export default function PhotoDetailPage({
photosGrid, photosGrid,
tag, tag,
camera, camera,
count,
dateRange,
}: { }: {
photo: Photo photo: Photo
photos: Photo[] photos: Photo[]
photosGrid?: Photo[] photosGrid?: Photo[]
tag?: string tag?: string
camera?: Camera camera?: Camera
count?: number
dateRange?: PhotoDateRange
}) { }) {
return ( return (
<div> <div>
@ -40,10 +44,11 @@ export default function PhotoDetailPage({
className="mt-4 mb-8" className="mt-4 mb-8"
contentMain={ contentMain={
<CameraHeader <CameraHeader
key={tag}
camera={camera} camera={camera}
photos={photos} photos={photos}
selectedPhoto={photo} selectedPhoto={photo}
count={count}
dateRange={dateRange}
/>} />}
/>} />}
<AnimateItems <AnimateItems

View File

@ -1,5 +1,5 @@
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import { Photo, dateRangeForPhotos } from '.'; import { Photo, PhotoDateRange, dateRangeForPhotos } from '.';
import ShareButton from '@/components/ShareButton'; import ShareButton from '@/components/ShareButton';
export default function PhotoHeader({ export default function PhotoHeader({
@ -10,6 +10,7 @@ export default function PhotoHeader({
selectedPhoto, selectedPhoto,
sharePath, sharePath,
count, count,
dateRange,
}: { }: {
entity: JSX.Element entity: JSX.Element
entityVerb: string entityVerb: string
@ -18,8 +19,9 @@ export default function PhotoHeader({
selectedPhoto?: Photo selectedPhoto?: Photo
sharePath: string sharePath: string
count?: number count?: number
dateRange?: PhotoDateRange
}) { }) {
const { start, end } = dateRangeForPhotos(photos); const { start, end } = dateRangeForPhotos(photos, dateRange);
const selectedPhotoIndex = selectedPhoto const selectedPhotoIndex = selectedPhoto
? photos.findIndex(photo => photo.id === selectedPhoto.id) ? photos.findIndex(photo => photo.id === selectedPhoto.id)

View File

@ -60,7 +60,6 @@ export interface Photo extends PhotoDb {
exposureTimeFormatted?: string exposureTimeFormatted?: string
exposureCompensationFormatted?: string exposureCompensationFormatted?: string
takenAtNaiveFormatted: string takenAtNaiveFormatted: string
takenAtNaiveFormattedShort: string
} }
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
@ -84,8 +83,6 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
formatExposureCompensation(photoDb.exposureCompensation), formatExposureCompensation(photoDb.exposureCompensation),
takenAtNaiveFormatted: takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive), formatDateFromPostgresString(photoDb.takenAtNaive),
takenAtNaiveFormattedShort:
formatDateFromPostgresString(photoDb.takenAtNaive, true),
}; };
}; };
@ -163,23 +160,35 @@ const photoLabelForCount = (count: number) =>
export const photoQuantityText = (count: number) => export const photoQuantityText = (count: number) =>
`(${count} ${photoLabelForCount(count)})`; `(${count} ${photoLabelForCount(count)})`;
export type PhotoDateRange = { start: string, end: string };
export const descriptionForPhotoSet = ( export const descriptionForPhotoSet = (
photos:Photo[], photos:Photo[],
descriptor?: string, descriptor?: string,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ) =>
dateBased dateBased
? dateRangeForPhotos(photos).description.toUpperCase() ? dateRangeForPhotos(photos, explicitDateRange).description.toUpperCase()
: [ : [
explicitCount ?? photos.length, explicitCount ?? photos.length,
descriptor, descriptor,
photoLabelForCount(explicitCount ?? photos.length), photoLabelForCount(explicitCount ?? photos.length),
].join(' '); ].join(' ');
export const dateRangeForPhotos = (photos: Photo[]) => { export const dateRangeForPhotos = (
const start = photos[0].takenAtNaiveFormattedShort; photos: Photo[],
const end = photos[photos.length - 1].takenAtNaiveFormattedShort; explicitDateRange?: PhotoDateRange,
) => {
const start = formatDateFromPostgresString(
explicitDateRange?.start ?? photos[0].takenAtNaive,
true,
);
const end = formatDateFromPostgresString(
explicitDateRange?.end ?? photos[photos.length - 1].takenAtNaive,
true
);
const description = start === end const description = start === end
? start ? start
: `${start}${end}`; : `${start}${end}`;

View File

@ -5,6 +5,7 @@ import {
translatePhotoId, translatePhotoId,
parsePhotoFromDb, parsePhotoFromDb,
Photo, Photo,
PhotoDateRange,
} from '@/photo'; } from '@/photo';
import { Camera, createCameraKey } from '@/camera'; import { Camera, createCameraKey } from '@/camera';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
@ -239,13 +240,13 @@ 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` const sqlGetPhotosTagCount = async (tag: string) => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
WHERE ${tag}=ANY(tags) AND WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10)); `.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosCountCamera = async (camera: Camera) => sql` const sqlGetPhotosCameraCount = async (camera: Camera) => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
WHERE WHERE
LOWER(make)=${parameterize(camera.make)} AND LOWER(make)=${parameterize(camera.make)} AND
@ -253,6 +254,22 @@ const sqlGetPhotosCountCamera = async (camera: Camera) => sql`
hidden IS NOT TRUE hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10)); `.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosTagDateRange = async (tag: string) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE
`.then(({ rows }) => rows[0] as PhotoDateRange);
const sqlGetPhotosCameraDateRange = async (camera: Camera) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE
LOWER(make)=${parameterize(camera.make)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model)} AND
hidden IS NOT TRUE
`.then(({ rows }) => rows[0] as PhotoDateRange);
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
@ -353,10 +370,15 @@ export const getPhoto = async (id: string): Promise<Photo | undefined> => {
export const getPhotosCount = () => export const getPhotosCount = () =>
safelyQueryPhotos(sqlGetPhotosCount); safelyQueryPhotos(sqlGetPhotosCount);
export const getPhotosCountTag = (tag: string) => export const getPhotosTagCount = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosCountTag(tag)); safelyQueryPhotos(() => sqlGetPhotosTagCount(tag));
export const getPhotosCountCamera = (camera: Camera) => export const getPhotosCameraCount = (camera: Camera) =>
safelyQueryPhotos(() => sqlGetPhotosCountCamera(camera)); safelyQueryPhotos(() => sqlGetPhotosCameraCount(camera));
export const getPhotosTagDateRange = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosTagDateRange(tag));
export const getPhotosCameraDateRange = (camera: Camera) =>
safelyQueryPhotos(() => sqlGetPhotosCameraDateRange(camera));
export const getPhotosCountIncludingHidden = () => export const getPhotosCountIncludingHidden = () =>
safelyQueryPhotos(sqlGetPhotosCountIncludingHidden); safelyQueryPhotos(sqlGetPhotosCountIncludingHidden);

View File

@ -1,4 +1,4 @@
type PaginationSearchParams = { next: string }; export type PaginationSearchParams = { next: string };
export interface PaginationParams { export interface PaginationParams {
searchParams?: PaginationSearchParams searchParams?: PaginationSearchParams

41
src/tag/TagOverview.tsx Normal file
View File

@ -0,0 +1,41 @@
import { Photo, PhotoDateRange } from '@/photo';
import SiteGrid from '@/components/SiteGrid';
import AnimateItems from '@/components/AnimateItems';
import PhotoGrid from '@/photo/PhotoGrid';
import TagHeader from './TagHeader';
export default function TagOverview({
tag,
photos,
count,
dateRange,
showMorePath,
animateOnFirstLoadOnly,
}: {
tag: string,
photos: Photo[],
count: number,
dateRange: PhotoDateRange,
showMorePath?: string,
animateOnFirstLoadOnly?: boolean,
}) {
return (
<SiteGrid
contentMain={<div className="space-y-8 mt-4">
<AnimateItems
type="bottom"
items={[
<TagHeader
key="TagHeader"
{...{ tag, photos, count, dateRange }}
/>,
]}
animateOnFirstLoadOnly
/>
<PhotoGrid
{...{ photos, tag, showMorePath, animateOnFirstLoadOnly }}
/>
</div>}
/>
);
}

52
src/tag/data.ts Normal file
View File

@ -0,0 +1,52 @@
import {
getPhotosCached,
getPhotosTagCountCached,
getPhotosTagDateRangeCached,
} from '@/cache';
import {
PaginationSearchParams,
getPaginationForSearchParams,
} from '@/site/pagination';
import { pathForTag } from '@/site/paths';
export const getPhotosTagDataCached = ({
tag,
limit,
}: {
tag: string,
limit?: number,
}) =>
Promise.all([
getPhotosCached({ tag, limit }),
getPhotosTagCountCached(tag),
getPhotosTagDateRangeCached(tag),
]);
export const getPhotosTagDataCachedWithPagination = async ({
tag,
limit: limitProp,
searchParams,
}: {
tag: string,
limit?: number,
searchParams?: PaginationSearchParams,
}) => {
const { offset, limit } = getPaginationForSearchParams(searchParams);
const [photos, count, dateRange] =
await getPhotosTagDataCached({
tag,
limit: limitProp ?? limit,
});
const showMorePath = count > photos.length
? pathForTag(tag, offset + 1)
: undefined;
return {
photos,
count,
dateRange,
showMorePath,
};
};

View File

@ -1,4 +1,9 @@
import { Photo, descriptionForPhotoSet, photoQuantityText } from '@/photo'; import {
Photo,
PhotoDateRange,
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';
@ -15,16 +20,25 @@ export const descriptionForTaggedPhotos = (
photos: Photo[], photos: Photo[],
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet(photos, 'tagged', dateBased, explicitCount); descriptionForPhotoSet(
photos,
'tagged',
dateBased,
explicitCount,
explicitDateRange,
);
export const generateMetaForTag = ( export const generateMetaForTag = (
tag: string, tag: string,
photos: Photo[], photos: Photo[],
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForTag(tag), url: absolutePathForTag(tag),
title: titleForTag(tag, photos, explicitCount), title: titleForTag(tag, photos, explicitCount),
description: descriptionForTaggedPhotos(photos, true), description:
descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange),
images: absolutePathForTagImage(tag), images: absolutePathForTagImage(tag),
}); });