diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx
index 60b902a7..ee792f59 100644
--- a/src/admin/AdminPhotoMenuClient.tsx
+++ b/src/admin/AdminPhotoMenuClient.tsx
@@ -25,7 +25,7 @@ export default function AdminPhotoMenuClient({
const isFav = isPhotoFav(photo);
const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav;
- const shouldRedirectDelete = pathForPhoto(photo.id) === path;
+ const shouldRedirectDelete = pathForPhoto({ photo: photo.id }) === path;
return (
isUserSignedIn
diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx
index 34dcf7de..15d6c6fc 100644
--- a/src/admin/AdminPhotosTable.tsx
+++ b/src/admin/AdminPhotosTable.tsx
@@ -45,7 +45,7 @@ export default function AdminPhotosTable({
diff --git a/src/app/film/[simulation]/[photoId]/layout.tsx b/src/app/film/[simulation]/[photoId]/layout.tsx
index 4773e8da..4219e838 100644
--- a/src/app/film/[simulation]/[photoId]/layout.tsx
+++ b/src/app/film/[simulation]/[photoId]/layout.tsx
@@ -41,7 +41,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
- const url = absolutePathForPhoto(photo, simulation);
+ const url = absolutePathForPhoto({ photo, simulation });
return {
title,
diff --git a/src/app/focal/[focal]/image/route.tsx b/src/app/focal/[focal]/image/route.tsx
new file mode 100644
index 00000000..22db2d06
--- /dev/null
+++ b/src/app/focal/[focal]/image/route.tsx
@@ -0,0 +1,41 @@
+import { getPhotosCached } from '@/photo/cache';
+import {
+ IMAGE_OG_DIMENSION_SMALL,
+ MAX_PHOTOS_TO_SHOW_PER_TAG,
+} from '@/image-response';
+import { getIBMPlexMonoMedium } from '@/site/font';
+import { ImageResponse } from 'next/og';
+import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
+import FocalLengthImageResponse from
+ '@/image-response/FocalLengthImageResponse';
+import { getFocalLengthFromString } from '@/focal';
+
+export async function GET(
+ _: Request,
+ context: { params: { focal: string } },
+) {
+ const focal = getFocalLengthFromString(context.params.focal);
+
+ const [
+ photos,
+ { fontFamily, fonts },
+ headers,
+ ] = await Promise.all([
+ getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, focal }),
+ getIBMPlexMonoMedium(),
+ getImageResponseCacheControlHeaders(),
+ ]);
+
+ const { width, height } = IMAGE_OG_DIMENSION_SMALL;
+
+ return new ImageResponse(
+
,
+ { width, height, fonts, headers },
+ );
+}
diff --git a/src/app/focal/[focal]/page.tsx b/src/app/focal/[focal]/page.tsx
new file mode 100644
index 00000000..f8b01767
--- /dev/null
+++ b/src/app/focal/[focal]/page.tsx
@@ -0,0 +1,71 @@
+import { generateMetaForFocalLength, getFocalLengthFromString } from '@/focal';
+import FocalLengthOverview from '@/focal/FocalLengthOverview';
+import { getPhotosFocalDataCached } from '@/focal/data';
+import { INFINITE_SCROLL_GRID_PHOTO_INITIAL } from '@/photo';
+import { PATH_ROOT } from '@/site/paths';
+import type { Metadata } from 'next';
+import { redirect } from 'next/navigation';
+import { cache } from 'react';
+
+const getPhotosFocalDataCachedCached = cache((focal: number) =>
+ getPhotosFocalDataCached({
+ focal,
+ limit: INFINITE_SCROLL_GRID_PHOTO_INITIAL,
+ }));
+
+interface FocalLengthProps {
+ params: { focal: string }
+}
+
+export async function generateMetadata({
+ params: { focal: focalString },
+}: FocalLengthProps): Promise
{
+ const focal = getFocalLengthFromString(focalString);
+
+ const [
+ photos,
+ { count, dateRange },
+ ] = await getPhotosFocalDataCachedCached(focal);
+
+ if (photos.length === 0) { return {}; }
+
+ const {
+ url,
+ title,
+ description,
+ images,
+ } = generateMetaForFocalLength(focal, photos, count, dateRange);
+
+ return {
+ title,
+ openGraph: {
+ title,
+ description,
+ images,
+ url,
+ },
+ twitter: {
+ images,
+ description,
+ card: 'summary_large_image',
+ },
+ description,
+ };
+}
+
+export default async function TagPage({
+ params: { focal: focalString },
+}:FocalLengthProps) {
+ const focal = getFocalLengthFromString(focalString);
+
+ const [
+ photos,
+ { count, dateRange },
+ ] = await getPhotosFocalDataCachedCached(focal);
+
+ if (photos.length === 0) { redirect(PATH_ROOT); }
+
+ return (
+
+ );
+}
diff --git a/src/app/p/[photoId]/layout.tsx b/src/app/p/[photoId]/layout.tsx
index e40e7d9c..1a00868a 100644
--- a/src/app/p/[photoId]/layout.tsx
+++ b/src/app/p/[photoId]/layout.tsx
@@ -44,7 +44,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
- const url = absolutePathForPhoto(photo);
+ const url = absolutePathForPhoto({ photo });
return {
title,
diff --git a/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx b/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx
index 42efcca0..b2908928 100644
--- a/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx
+++ b/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx
@@ -44,11 +44,10 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
- const url = absolutePathForPhoto(
+ const url = absolutePathForPhoto({
photo,
- undefined,
- cameraFromPhoto(photo, { make, model }),
- );
+ camera: cameraFromPhoto(photo, { make, model }),
+ });
return {
title,
diff --git a/src/app/tag/[tag]/[photoId]/layout.tsx b/src/app/tag/[tag]/[photoId]/layout.tsx
index 5de147fd..cb8ebd85 100644
--- a/src/app/tag/[tag]/[photoId]/layout.tsx
+++ b/src/app/tag/[tag]/[photoId]/layout.tsx
@@ -35,7 +35,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
- const url = absolutePathForPhoto(photo, tag);
+ const url = absolutePathForPhoto({ photo, tag });
return {
title,
diff --git a/src/app/tag/hidden/[photoId]/page.tsx b/src/app/tag/hidden/[photoId]/page.tsx
index 954fca0c..796447c0 100644
--- a/src/app/tag/hidden/[photoId]/page.tsx
+++ b/src/app/tag/hidden/[photoId]/page.tsx
@@ -33,7 +33,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
- const url = absolutePathForPhoto(photo, TAG_HIDDEN);
+ const url = absolutePathForPhoto({ photo, tag: TAG_HIDDEN });
return {
title,
diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx
index 4f5af280..61d6bd10 100644
--- a/src/components/CommandKClient.tsx
+++ b/src/components/CommandKClient.tsx
@@ -147,7 +147,7 @@ export default function CommandKClient({
keywords: getKeywordsForPhoto(photo),
annotation: ,
accessory: ,
- path: pathForPhoto(photo),
+ path: pathForPhoto({ photo }),
})),
}]
: []);
diff --git a/src/focal/FocalLengthHeader.tsx b/src/focal/FocalLengthHeader.tsx
new file mode 100644
index 00000000..9babb486
--- /dev/null
+++ b/src/focal/FocalLengthHeader.tsx
@@ -0,0 +1,39 @@
+import { Photo, PhotoDateRange } from '@/photo';
+import { descriptionForFocalLengthPhotos } from '.';
+import { pathForFocalLength } from '@/site/paths';
+import PhotoSetHeader from '@/photo/PhotoSetHeader';
+import PhotoFocalLength from './PhotoFocalLength';
+
+export default function FocalLengthHeader({
+ focal,
+ photos,
+ selectedPhoto,
+ indexNumber,
+ count,
+ dateRange,
+}: {
+ focal: number
+ photos: Photo[]
+ selectedPhoto?: Photo
+ indexNumber?: number
+ count?: number
+ dateRange?: PhotoDateRange
+}) {
+ return (
+ }
+ entityVerb="Tagged"
+ entityDescription={descriptionForFocalLengthPhotos(
+ photos,
+ undefined,
+ count,
+ )}
+ photos={photos}
+ selectedPhoto={selectedPhoto}
+ sharePath={pathForFocalLength(focal)}
+ indexNumber={indexNumber}
+ count={count}
+ dateRange={dateRange}
+ />
+ );
+}
diff --git a/src/focal/FocalLengthOverview.tsx b/src/focal/FocalLengthOverview.tsx
new file mode 100644
index 00000000..a5d23463
--- /dev/null
+++ b/src/focal/FocalLengthOverview.tsx
@@ -0,0 +1,33 @@
+import { Photo, PhotoDateRange } from '@/photo';
+import PhotoGridPage from '@/photo/PhotoGridPage';
+import FocalLengthHeader from './FocalLengthHeader';
+
+export default function FocalLengthOverview({
+ focal,
+ photos,
+ count,
+ dateRange,
+ animateOnFirstLoadOnly,
+}: {
+ focal: number,
+ photos: Photo[],
+ count: number,
+ dateRange?: PhotoDateRange,
+ animateOnFirstLoadOnly?: boolean,
+}) {
+ return (
+ ,
+ animateOnFirstLoadOnly,
+ }} />
+ );
+}
diff --git a/src/focal/PhotoFocalLength.tsx b/src/focal/PhotoFocalLength.tsx
new file mode 100644
index 00000000..b242e43b
--- /dev/null
+++ b/src/focal/PhotoFocalLength.tsx
@@ -0,0 +1,31 @@
+import { pathForFocalLength } from '@/site/paths';
+import EntityLink, {
+ EntityLinkExternalProps,
+} from '@/components/primitives/EntityLink';
+import { TbCone } from 'react-icons/tb';
+import { formatFocalLength } from '.';
+
+export default function PhotoFocalLength({
+ focal,
+ type,
+ badged,
+ contrast,
+ prefetch,
+ countOnHover,
+}: {
+ focal: number
+ countOnHover?: number
+} & EntityLinkExternalProps) {
+ return (
+ }
+ type={type}
+ badged={badged}
+ contrast={contrast}
+ prefetch={prefetch}
+ hoverEntity={countOnHover}
+ />
+ );
+}
diff --git a/src/focal/data.ts b/src/focal/data.ts
new file mode 100644
index 00000000..be5b539d
--- /dev/null
+++ b/src/focal/data.ts
@@ -0,0 +1,17 @@
+import {
+ getPhotosCached,
+ getPhotosMetaCached,
+} from '@/photo/cache';
+
+export const getPhotosFocalDataCached = ({
+ focal,
+ limit,
+}: {
+ focal: number,
+ limit?: number,
+}) =>
+ Promise.all([
+ getPhotosCached({ focal, limit }),
+ getPhotosMetaCached({ focal }),
+ ]);
+
diff --git a/src/focal/index.ts b/src/focal/index.ts
index 712e8a25..9104df94 100644
--- a/src/focal/index.ts
+++ b/src/focal/index.ts
@@ -9,12 +9,21 @@ import {
absolutePathForFocalLengthImage,
} from '@/site/paths';
+export const getFocalLengthFromString = (focalString?: string) => {
+ const focal = focalString?.match(/^([0-9]+)mm/)?.[1];
+ return focal ? parseInt(focal, 10) : 0;
+};
+
+export const formatFocalLength = (focal?: number) => focal ?
+ `${focal}mm`
+ : undefined;
+
export const titleForFocalLength = (
focal: number,
photos: Photo[],
explicitCount?: number,
) => [
- `${focal}mm`,
+ formatFocalLength(focal),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
diff --git a/src/image-response/FocalLengthImageResponse.tsx b/src/image-response/FocalLengthImageResponse.tsx
new file mode 100644
index 00000000..498f9cfd
--- /dev/null
+++ b/src/image-response/FocalLengthImageResponse.tsx
@@ -0,0 +1,46 @@
+import type { Photo } from '../photo';
+import ImageCaption from './components/ImageCaption';
+import ImagePhotoGrid from './components/ImagePhotoGrid';
+import ImageContainer from './components/ImageContainer';
+import type { NextImageSize } from '@/services/next-image';
+import { TbCone } from 'react-icons/tb';
+import { formatFocalLength } from '@/focal';
+
+export default function FocalLengthImageResponse({
+ focal,
+ photos,
+ width,
+ height,
+ fontFamily,
+}: {
+ focal: number,
+ photos: Photo[]
+ width: NextImageSize
+ height: number
+ fontFamily: string
+}) {
+ return (
+
+
+
+
+ {formatFocalLength(focal)}
+
+
+ );
+}
diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx
index 32e669d4..6dbc57f4 100644
--- a/src/photo/PhotoGrid.tsx
+++ b/src/photo/PhotoGrid.tsx
@@ -12,6 +12,7 @@ export default function PhotoGrid({
tag,
camera,
simulation,
+ focal,
photoPriority,
fast,
animate = true,
@@ -28,6 +29,7 @@ export default function PhotoGrid({
tag?: string
camera?: Camera
simulation?: FilmSimulation
+ focal?: number
photoPriority?: boolean
fast?: boolean
animate?: boolean
@@ -77,6 +79,7 @@ export default function PhotoGrid({
tag,
camera,
simulation,
+ focal,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1
diff --git a/src/photo/PhotoGridInfinite.tsx b/src/photo/PhotoGridInfinite.tsx
index 98b43667..0b9a1428 100644
--- a/src/photo/PhotoGridInfinite.tsx
+++ b/src/photo/PhotoGridInfinite.tsx
@@ -13,6 +13,7 @@ export default function PhotoGridInfinite({
tag,
camera,
simulation,
+ focal,
animateOnFirstLoadOnly,
}: {
cacheKey: string
@@ -21,6 +22,7 @@ export default function PhotoGridInfinite({
tag?: string
camera?: Camera
simulation?: FilmSimulation
+ focal?: number
animateOnFirstLoadOnly?: boolean
}) {
return (
@@ -39,6 +41,7 @@ export default function PhotoGridInfinite({
tag,
camera,
simulation,
+ focal,
onLastPhotoVisible,
animateOnFirstLoadOnly,
}} />}
diff --git a/src/photo/PhotoGridPage.tsx b/src/photo/PhotoGridPage.tsx
index 4e1fdc74..eb3430f0 100644
--- a/src/photo/PhotoGridPage.tsx
+++ b/src/photo/PhotoGridPage.tsx
@@ -17,6 +17,7 @@ export default function PhotoGridPage({
tag,
camera,
simulation,
+ focal,
animateOnFirstLoadOnly,
header,
sidebar,
@@ -27,6 +28,7 @@ export default function PhotoGridPage({
tag?: string
camera?: Camera
simulation?: FilmSimulation
+ focal?: number
animateOnFirstLoadOnly?: boolean
header?: JSX.Element
sidebar?: JSX.Element
@@ -58,6 +60,7 @@ export default function PhotoGridPage({
tag,
camera,
simulation,
+ focal,
animateOnFirstLoadOnly,
onAnimationComplete,
}} />
@@ -69,6 +72,7 @@ export default function PhotoGridPage({
tag,
camera,
simulation,
+ focal,
animateOnFirstLoadOnly,
}} />}
diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx
index 92862170..e7e7c561 100644
--- a/src/photo/PhotoLarge.tsx
+++ b/src/photo/PhotoLarge.tsx
@@ -40,6 +40,7 @@ export default function PhotoLarge({
shouldShareTag,
shouldShareCamera,
shouldShareSimulation,
+ shouldShareFocalLength,
shouldScrollOnShare,
onVisible,
}: {
@@ -54,6 +55,7 @@ export default function PhotoLarge({
shouldShareTag?: boolean
shouldShareCamera?: boolean
shouldShareSimulation?: boolean
+ shouldShareFocalLength?: boolean
shouldScrollOnShare?: boolean
onVisible?: () => void
}) {
@@ -76,7 +78,7 @@ export default function PhotoLarge({
containerRef={ref}
contentMain={