diff --git a/src/app/(static)/home-image/route.tsx b/src/app/(static)/home-image/route.tsx index 5cc37f48..b0d35fcc 100644 --- a/src/app/(static)/home-image/route.tsx +++ b/src/app/(static)/home-image/route.tsx @@ -1,29 +1,27 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth } from '@/cache'; +import { + IMAGE_OG_SMALL_SIZE, + MAX_PHOTOS_TO_SHOW_HOME, +} from '@/photo/image-response'; import HomeImageResponse from '@/photo/image-response/HomeImageResponse'; import { getPhotos } from '@/services/postgres'; -import { IMAGE_OG_SMALL_HEIGHT, IMAGE_OG_SMALL_WIDTH } from '@/site'; import { ImageResponse } from '@vercel/og'; export const runtime = 'edge'; export async function GET(request: Request) { - const photos = await getPhotos(); + const photos = await getPhotos( + undefined, + MAX_PHOTOS_TO_SHOW_HOME, + ); + const headers = await getImageCacheHeadersForAuth(await auth()); + const { width, height } = IMAGE_OG_SMALL_SIZE; + return new ImageResponse( - ( - - ), - { - width: IMAGE_OG_SMALL_WIDTH, - height: IMAGE_OG_SMALL_HEIGHT, - headers, - }, + , + { width, height, headers }, ); } diff --git a/src/app/(static)/p/[photoId]/image/route.tsx b/src/app/(static)/p/[photoId]/image/route.tsx index a2b26e0d..b6a34116 100644 --- a/src/app/(static)/p/[photoId]/image/route.tsx +++ b/src/app/(static)/p/[photoId]/image/route.tsx @@ -1,42 +1,29 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth } from '@/cache'; -import PhotoOGImageResponse from '@/photo/image-response/PhotoOGImageResponse'; +import { IMAGE_OG_SIZE } from '@/photo/image-response'; +import PhotoImageResponse from '@/photo/image-response/PhotoImageResponse'; import { getPhoto } from '@/services/postgres'; -import { IMAGE_OG_WIDTH, IMAGE_OG_HEIGHT } from '@/site'; -import { FONT_FAMILY_IBM_PLEX_MONO, getIBMPlexMonoMedium } from '@/site/font'; +import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from '@vercel/og'; export const runtime = 'edge'; export async function GET(request: Request, context: any) { const photo = await getPhoto(context.params.photoId); - const fontData = await getIBMPlexMonoMedium(); + + const { + fontFamily, + fonts, + } = await getIBMPlexMonoMedium(); + const headers = await getImageCacheHeadersForAuth(await auth()); if (!photo) { return null; } + + const { width, height } = IMAGE_OG_SIZE; return new ImageResponse( - ( - - ), - { - width: IMAGE_OG_WIDTH, - height: IMAGE_OG_HEIGHT, - fonts: [ - { - name: FONT_FAMILY_IBM_PLEX_MONO, - data: fontData, - weight: 500, - style: 'normal', - }, - ], - headers, - }, + , + { width, height, fonts, headers }, ); } diff --git a/src/app/(static)/t/[tag]/image/route.tsx b/src/app/(static)/t/[tag]/image/route.tsx index 17351a4b..8a5facce 100644 --- a/src/app/(static)/t/[tag]/image/route.tsx +++ b/src/app/(static)/t/[tag]/image/route.tsx @@ -1,30 +1,35 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth } from '@/cache'; -import HomeImageResponse from '@/photo/image-response/HomeImageResponse'; +import { + IMAGE_OG_SMALL_SIZE, + MAX_PHOTOS_TO_SHOW_PER_TAG, +} from '@/photo/image-response'; +import TagImageResponse from '@/photo/image-response/TagImageResponse'; import { getPhotos } from '@/services/postgres'; -import { IMAGE_OG_SMALL_HEIGHT, IMAGE_OG_SMALL_WIDTH } from '@/site'; +import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from '@vercel/og'; export const runtime = 'edge'; export async function GET(request: Request, context: any) { const photos = await getPhotos( - undefined, undefined, undefined, context.params.tag); + undefined, + MAX_PHOTOS_TO_SHOW_PER_TAG, + undefined, + context.params.tag, + ); + + const { + fontFamily, + fonts, + } = await getIBMPlexMonoMedium(); + const headers = await getImageCacheHeadersForAuth(await auth()); + const { width, height } = IMAGE_OG_SMALL_SIZE; + return new ImageResponse( - ( - - ), - { - width: IMAGE_OG_SMALL_WIDTH, - height: IMAGE_OG_SMALL_HEIGHT, - headers, - }, + , + { width, height, fonts, headers }, ); } diff --git a/src/app/(static)/t/[tag]/page.tsx b/src/app/(static)/t/[tag]/page.tsx index ce6e2d04..91556271 100644 --- a/src/app/(static)/t/[tag]/page.tsx +++ b/src/app/(static)/t/[tag]/page.tsx @@ -53,13 +53,13 @@ export default async function TagPage({ params: { tag } }: TagProps) {
{descriptionForTaggedPhotos(photos)} diff --git a/src/app/(static)/template-image-tight/route.tsx b/src/app/(static)/template-image-tight/route.tsx index 1379a992..1854305c 100644 --- a/src/app/(static)/template-image-tight/route.tsx +++ b/src/app/(static)/template-image-tight/route.tsx @@ -1,41 +1,45 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth } from '@/cache'; -import DeployImageResponse from '@/photo/image-response/DeployImageResponse'; +import { + IMAGE_OG_SIZE, + MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT, +} from '@/photo/image-response'; +import TemplateImageResponse from + '@/photo/image-response/TemplateImageResponse'; import { getPhotos } from '@/services/postgres'; -import { IMAGE_OG_HEIGHT, IMAGE_OG_WIDTH } from '@/site'; -import { FONT_FAMILY_IBM_PLEX_MONO, getIBMPlexMonoMedium } from '@/site/font'; +import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from '@vercel/og'; export const runtime = 'edge'; export async function GET(request: Request) { - const photos = await getPhotos('priority'); - const fontData = await getIBMPlexMonoMedium(); + const photos = await getPhotos('priority', MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT); + + const { + fontFamily, + fonts, + } = await getIBMPlexMonoMedium(); + const headers = await getImageCacheHeadersForAuth(await auth()); + const { width, height } = IMAGE_OG_SIZE; + return new ImageResponse( ( - ), { - width: IMAGE_OG_WIDTH, - height: IMAGE_OG_HEIGHT, - fonts: [ - { - name: FONT_FAMILY_IBM_PLEX_MONO, - data: fontData, - style: 'normal', - }, - ], + width, + height, + fonts, headers, }, ); diff --git a/src/app/(static)/template-image/route.tsx b/src/app/(static)/template-image/route.tsx index fa102f73..1ce81dfa 100644 --- a/src/app/(static)/template-image/route.tsx +++ b/src/app/(static)/template-image/route.tsx @@ -1,39 +1,37 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth } from '@/cache'; -import DeployImageResponse from '@/photo/image-response/DeployImageResponse'; +import { + GRID_OG_SIZE, + MAX_PHOTOS_TO_SHOW_TEMPLATE, +} from '@/photo/image-response'; +import TemplateImageResponse from + '@/photo/image-response/TemplateImageResponse'; import { getPhotos } from '@/services/postgres'; -import { GRID_OG_WIDTH, GRID_OG_HEIGHT } from '@/site'; -import { FONT_FAMILY_IBM_PLEX_MONO, getIBMPlexMonoMedium } from '@/site/font'; +import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from '@vercel/og'; export const runtime = 'edge'; export async function GET(request: Request) { - const photos = await getPhotos('priority'); - const fontData = await getIBMPlexMonoMedium(); + const photos = await getPhotos('priority', MAX_PHOTOS_TO_SHOW_TEMPLATE); + const { + fontFamily, + fonts, + } = await getIBMPlexMonoMedium(); const headers = await getImageCacheHeadersForAuth(await auth()); + + const { width, height } = GRID_OG_SIZE; return new ImageResponse( ( - ), - { - width: GRID_OG_WIDTH, - height: GRID_OG_HEIGHT, - fonts: [ - { - name: FONT_FAMILY_IBM_PLEX_MONO, - data: fontData, - style: 'normal', - }, - ], - headers, - }, + { width, height, fonts, headers }, ); } diff --git a/src/photo/PhotoOGTile.tsx b/src/photo/PhotoOGTile.tsx index 670a89ca..a1b68ecc 100644 --- a/src/photo/PhotoOGTile.tsx +++ b/src/photo/PhotoOGTile.tsx @@ -9,9 +9,9 @@ import { import { cc } from '@/utility/css'; import Link from 'next/link'; import { BiError } from 'react-icons/bi'; -import { IMAGE_OG_HEIGHT, IMAGE_OG_RATIO, IMAGE_OG_WIDTH } from '@/site'; import { absolutePathForPhotoImage, pathForPhoto } from '@/site/paths'; import Spinner from '@/components/Spinner'; +import { IMAGE_OG_SIZE } from './image-response'; export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; @@ -44,6 +44,8 @@ export default function PhotoOGTile({ } }, [loadingStateExternal, loadingStateInternal]); + const { width, height, ratio } = IMAGE_OG_SIZE; + return (
{loadingState === 'loading' &&
{ if (onLoad) { onLoad(); diff --git a/src/photo/image-response/HomeImageResponse.tsx b/src/photo/image-response/HomeImageResponse.tsx index 07e96e67..8ade1cd8 100644 --- a/src/photo/image-response/HomeImageResponse.tsx +++ b/src/photo/image-response/HomeImageResponse.tsx @@ -1,5 +1,6 @@ import { Photo } from '..'; -import PhotoGridImageResponse from './PhotoGridImageResponse'; +import ImageContainer from './components/ImageContainer'; +import ImagePhotoGrid from './components/ImagePhotoGrid'; export default function HomeImageResponse({ photos, @@ -12,31 +13,16 @@ export default function HomeImageResponse({ width: number height: number }) { - const grid = photos.length >= 12 - ? { colCount: 4, rowCount: 3 } - : { colCount: 3, rowCount: 2 }; - - const photosPerGrid = grid.colCount * grid.rowCount; - return ( -
- -
+ + + ); } diff --git a/src/photo/image-response/PhotoGridImageResponse.tsx b/src/photo/image-response/PhotoGridImageResponse.tsx deleted file mode 100644 index 9c30ddc6..00000000 --- a/src/photo/image-response/PhotoGridImageResponse.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { getNextImageUrlForRequest } from '@/utility/image'; -import { Photo, titleForPhoto } from '..'; - -export default function PhotoGridImageResponse({ - photos, - request, - nextImageWidth = 400, - height, - width, - colCount, - rowCount, - gap = 12, - verticalOffset, -}: { - photos: Photo[] - request: Request - nextImageWidth?: 200 | 400, - height?: number - width: number - colCount: number - rowCount: number - gap?: number - verticalOffset?: number -}) { - const imageWidth = (width - ((colCount - 1) * gap)) / colCount; - const imageHeight = height - ? (height - ((rowCount - 1) * gap)) / rowCount - : undefined; - - return ( -
- {photos - .slice(0, colCount * rowCount) - .map((photo, index) => { - const photoWidth = imageHeight - ? imageHeight * photo.aspectRatio - : imageWidth; - const photoHeight = imageHeight ?? imageWidth / photo.aspectRatio; - const horizontalOffset = imageHeight - ? Math.abs((imageHeight * photo.aspectRatio - imageWidth) / 2) - : undefined; - - return ( -
- {titleForPhoto(photo)} -
- ); - })} -
- ); -} diff --git a/src/photo/image-response/PhotoImageResponse.tsx b/src/photo/image-response/PhotoImageResponse.tsx new file mode 100644 index 00000000..23110080 --- /dev/null +++ b/src/photo/image-response/PhotoImageResponse.tsx @@ -0,0 +1,55 @@ +import { Photo } from '..'; +import { NextImageWidth } from '@/utility/image'; +import { formatModelShort } from '@/utility/exif'; +import { AiFillApple } from 'react-icons/ai'; +import ImageCaption from './components/ImageCaption'; +import ImagePhotoGrid from './components/ImagePhotoGrid'; + +export default function PhotoImageResponse({ + photo, + request, + width, + height, + fontFamily, +}: { + photo: Photo + request: Request + width: NextImageWidth + height: number + fontFamily: string +}) { + return ( +
+ + + {photo.make === 'Apple' && +
+ +
} +
+ {formatModelShort(photo.model)} +
+
+ {photo.focalLengthFormatted} +
+
+ {photo.fNumberFormatted} +
+
+ {photo.isoFormatted} +
+
+
+ ); +}; diff --git a/src/photo/image-response/PhotoOGImageResponse.tsx b/src/photo/image-response/PhotoOGImageResponse.tsx deleted file mode 100644 index c615fc89..00000000 --- a/src/photo/image-response/PhotoOGImageResponse.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Photo, titleForPhoto } from '..'; -import { getNextImageUrlForRequest } from '@/utility/image'; -import { formatModelShort } from '@/utility/exif'; -import { AiFillApple } from 'react-icons/ai'; - -export default function PhotoOGImageResponse({ - photo, - requestOrPhotoPath, - width, - height, - fontFamily, -}: { - photo: Photo - requestOrPhotoPath: Request | string - width: number - height: number - fontFamily: string -}) { - return ( -
- {titleForPhoto(photo)} -
- {photo.make === 'Apple' && -
- -
} -
- {formatModelShort(photo.model)} -
-
- {photo.focalLengthFormatted} -
-
- {photo.fNumberFormatted} -
-
- {photo.isoFormatted} -
-
-
- ); -}; diff --git a/src/photo/image-response/TagImageResponse.tsx b/src/photo/image-response/TagImageResponse.tsx new file mode 100644 index 00000000..2e620378 --- /dev/null +++ b/src/photo/image-response/TagImageResponse.tsx @@ -0,0 +1,43 @@ +import { Photo } from '..'; +import { FaTag } from 'react-icons/fa'; +import ImageCaption from './components/ImageCaption'; +import ImagePhotoGrid from './components/ImagePhotoGrid'; +import ImageContainer from './components/ImageContainer'; + +export default function TagImageResponse({ + photos, + request, + width, + height, + fontFamily, +}: { + photos: Photo[] + request: Request + width: number + height: number + fontFamily: string +}) { + return ( + + + + + {'Door County'.toUpperCase()} + + + ); +} diff --git a/src/photo/image-response/DeployImageResponse.tsx b/src/photo/image-response/TemplateImageResponse.tsx similarity index 68% rename from src/photo/image-response/DeployImageResponse.tsx rename to src/photo/image-response/TemplateImageResponse.tsx index d0526fde..396f2465 100644 --- a/src/photo/image-response/DeployImageResponse.tsx +++ b/src/photo/image-response/TemplateImageResponse.tsx @@ -1,9 +1,9 @@ import { Photo } from '..'; -import PhotoGridImageResponse from './PhotoGridImageResponse'; import IconFullFrame from '@/icons/IconFullFrame'; import IconGrid from '@/icons/IconGrid'; +import ImagePhotoGrid from './components/ImagePhotoGrid'; -export default function DeployImageResponse({ +export default function TemplateImageResponse({ photos, request, width, @@ -40,16 +40,16 @@ export default function DeployImageResponse({ height, fontFamily, }}> -
- {includeHeader && + {includeHeader && +
-
} -
- photos.sambecker.com -
+
+
+ photos.sambecker.com +
+
} +
+
-
); } diff --git a/src/photo/image-response/components/ImageCaption.tsx b/src/photo/image-response/components/ImageCaption.tsx new file mode 100644 index 00000000..39a8a740 --- /dev/null +++ b/src/photo/image-response/components/ImageCaption.tsx @@ -0,0 +1,35 @@ +export default function ImageCaption({ + height, + children, + fontFamily, +}: { + width: number + height: number + fontFamily: string + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/photo/image-response/components/ImageContainer.tsx b/src/photo/image-response/components/ImageContainer.tsx new file mode 100644 index 00000000..ac37446e --- /dev/null +++ b/src/photo/image-response/components/ImageContainer.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +export default function ImageContainer({ + width, + height, + background = 'transparent', + children, +}: { + width: number + height: number + background?: 'transparent' | 'black' + children: ReactNode +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/photo/image-response/components/ImagePhotoGrid.tsx b/src/photo/image-response/components/ImagePhotoGrid.tsx new file mode 100644 index 00000000..91cc9a22 --- /dev/null +++ b/src/photo/image-response/components/ImagePhotoGrid.tsx @@ -0,0 +1,64 @@ +/* eslint-disable jsx-a11y/alt-text */ + +import { Photo } from '@/photo'; +import { getNextImageUrlForRequest } from '@/utility/image'; + +export default function ImagePhotoGrid({ + photos, + request, + width, + height, + gap = 3, +}: { + photos: Photo[] + request: Request + width: number + height: number + gap?: number +}) { + let count = 1; + if (photos.length >= 12) { count = 12; } + else if (photos.length >= 6) { count = 6; } + else if (photos.length >= 4) { count = 4; } + else if (photos.length >= 2) { count = 2; } + + const imageQuality = count <= 2 ? 1050 : 400; + + let rows = 1; + if (count > 12) { rows = 4; } + else if (count > 6) { rows = 3; } + else if (count > 3) { rows = 2; } + + const imagesPerRow = count / rows; + + return ( +
+ {photos.slice(0, count).map(photo => +
+ +
)} +
+ ); +} diff --git a/src/photo/image-response/index.ts b/src/photo/image-response/index.ts new file mode 100644 index 00000000..4f231e34 --- /dev/null +++ b/src/photo/image-response/index.ts @@ -0,0 +1,35 @@ +import { NextImageWidth } from '@/utility/image'; + +export const MAX_PHOTOS_TO_SHOW_HOME = 12; +export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6; +export const MAX_PHOTOS_TO_SHOW_TEMPLATE = 16; +export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12; + +// 16:9 og image ratio +const IMAGE_OG_RATIO = 16 / 9; +const IMAGE_OG_WIDTH: NextImageWidth = 1200; +const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO); +export const IMAGE_OG_SIZE = { + width: IMAGE_OG_WIDTH, + height: IMAGE_OG_HEIGHT, + ratio: IMAGE_OG_RATIO, +}; + +// 16:9 og image ratio, small +const IMAGE_OG_SMALL_WIDTH = 800; +const IMAGE_OG_SMALL_HEIGHT = IMAGE_OG_SMALL_WIDTH * (1 / IMAGE_OG_RATIO); +export const IMAGE_OG_SMALL_SIZE = { + width: IMAGE_OG_SMALL_WIDTH, + height: IMAGE_OG_SMALL_HEIGHT, + ratio: IMAGE_OG_RATIO, +}; + +// 3:2 og grid ratio +const GRID_OG_RATIO = 1.33; +const GRID_OG_WIDTH = 2000; +const GRID_OG_HEIGHT = GRID_OG_WIDTH * (1 / GRID_OG_RATIO); +export const GRID_OG_SIZE = { + width: GRID_OG_WIDTH, + height: GRID_OG_HEIGHT, + ratio: GRID_OG_RATIO, +}; diff --git a/src/services/blob.ts b/src/services/blob.ts index 67db51f6..74f583bb 100644 --- a/src/services/blob.ts +++ b/src/services/blob.ts @@ -1,8 +1,14 @@ -import { BLOB_BASE_URL } from '@/site'; import { PATH_ADMIN_UPLOAD_BLOB_HANDLER } from '@/site/paths'; import { del, list, put } from '@vercel/blob'; import { upload } from '@vercel/blob/client'; +const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( + /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, +)?.[1].toLowerCase(); + +export const BLOB_BASE_URL = + `https://${STORE_ID}.public.blob.vercel-storage.com`; + export const ACCEPTED_PHOTO_FILE_TYPES = [ 'image/jpg', 'image/jpeg', diff --git a/src/site/font.ts b/src/site/font.ts index 57164754..6fb91a8e 100644 --- a/src/site/font.ts +++ b/src/site/font.ts @@ -1,7 +1,16 @@ -export const FONT_FAMILY_IBM_PLEX_MONO = 'IBMPlexMono'; +const FONT_FAMILY_IBM_PLEX_MONO = 'IBMPlexMono'; export const getIBMPlexMonoMedium = () => fetch(new URL( '/public/fonts/IBMPlexMono-Medium.ttf', import.meta.url )) - .then(res => res.arrayBuffer()); + .then(res => res.arrayBuffer()) + .then(data => ({ + fontFamily: FONT_FAMILY_IBM_PLEX_MONO, + fonts: [{ + name: FONT_FAMILY_IBM_PLEX_MONO, + data, + weight: 500, + style: 'normal', + } as const], + })); diff --git a/src/site/index.ts b/src/site/index.ts index 19a16cb3..3f5bbebe 100644 --- a/src/site/index.ts +++ b/src/site/index.ts @@ -6,26 +6,3 @@ export const IMAGE_SMALL_WIDTH = 300; // Height determined by intrinsic photo aspect ratio export const IMAGE_LARGE_WIDTH = 900; - -// 16:9 og image ratio -export const IMAGE_OG_RATIO = 16 / 9; -export const IMAGE_OG_WIDTH = 1200; -export const IMAGE_OG_HEIGHT = - IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO); - -// 16:9 og image ratio, small -export const IMAGE_OG_SMALL_WIDTH = 800; -export const IMAGE_OG_SMALL_HEIGHT = - IMAGE_OG_SMALL_WIDTH * (1 / IMAGE_OG_RATIO); - -// 3:2 og grid ratio -export const GRID_OG_RATIO = 1.33; -export const GRID_OG_WIDTH = 2000; -export const GRID_OG_HEIGHT = GRID_OG_WIDTH * (1 / GRID_OG_RATIO); - -const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( - /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, -)?.[1].toLowerCase(); - -export const BLOB_BASE_URL = - `https://${STORE_ID}.public.blob.vercel-storage.com`; diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index ab8c23b4..b640fd4a 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { pathForTag } from '@/site/paths'; import { FaTag } from 'react-icons/fa'; +import { cc } from '@/utility/css'; export default function PhotoTag({ tag, @@ -15,7 +16,10 @@ export default function PhotoTag({ > {tag.replaceAll('-', ' ')} diff --git a/src/utility/image.ts b/src/utility/image.ts index 4312928f..25d74128 100644 --- a/src/utility/image.ts +++ b/src/utility/image.ts @@ -1,7 +1,10 @@ +// Must be explicity defined next.config.js `imageSizes` +export type NextImageWidth = 200 | 400 | 1050 | 1200; + export const getNextImageUrlForRequest = ( imageUrl: string, request: Request, - width: number, + width: NextImageWidth, quality = 75, ) => { const protocol = (request.headers.get('x-forwarded-proto') || 'https')