diff --git a/__tests__/camera.test.ts b/__tests__/camera.test.ts index 41f11eb3..e4950b68 100644 --- a/__tests__/camera.test.ts +++ b/__tests__/camera.test.ts @@ -1,14 +1,30 @@ import { Camera, formatCameraText } from '@/camera'; +const APPLE : Camera = { make: 'Apple', model: 'iPhone 11 Pro' }; +const APPLE_01 : Camera = { make: 'Apple', model: 'iPhone 11' }; +const APPLE_02 : Camera = { make: 'Apple', model: 'iPhone 15 Pro Max' }; +const FUJIFILM : Camera = { make: 'Fujifilm', model: 'X-T5' }; +const CANON : Camera = { make: 'Canon', model: 'Canon EOS 800D' }; +const NIKON : Camera = { + make: 'Nikon Corporation', + model: 'Nikon D7000', +}; + describe('Camera', () => { - it('labels correctly', () => { - const apple: Camera = { make: 'Apple', model: 'iPhone 11 Pro' }; - expect(formatCameraText(apple, true)).toBe('Apple iPhone 11 Pro'); - expect(formatCameraText(apple, false)).toBe('iPhone 11 Pro'); - const fujifilm: Camera = { make: 'Fujifilm', model: 'X-T5' }; - expect(formatCameraText(fujifilm)).toBe('Fujifilm X-T5'); - const canon: Camera = { make: 'Canon', model: 'Canon EOS 800D' }; - expect(formatCameraText(canon)).toBe('Canon EOS 800D'); + it('labels full text correctly', () => { + expect(formatCameraText(APPLE)).toBe('iPhone 11 Pro'); + expect(formatCameraText(APPLE, 'always')).toBe('Apple iPhone 11 Pro'); + expect(formatCameraText(APPLE, 'if-not-apple')).toBe('iPhone 11 Pro'); + expect(formatCameraText(APPLE, 'never')).toBe('iPhone 11 Pro'); + expect(formatCameraText(FUJIFILM)).toBe('Fujifilm X-T5'); + expect(formatCameraText(CANON)).toBe('Canon EOS 800D'); + expect(formatCameraText(NIKON)).toBe('Nikon D7000'); + }); + it('labels models correctly', () => { + expect(formatCameraText(APPLE, 'never')).toBe('iPhone 11 Pro'); + expect(formatCameraText(APPLE, 'never', true)).toBe('11 Pro'); + expect(formatCameraText(APPLE_01, 'never', true)).toBe('iPhone 11'); + expect(formatCameraText(APPLE_02, 'never', true)).toBe('15 Pro Max'); }); }); diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index 4263daef..858dfede 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -18,12 +18,12 @@ import { isPathTagPhotoShare, isPathTagShare, } from '@/site/paths'; -import { getCameraFromKey } from '@/camera'; const PHOTO_ID = 'UsKSGcbt'; const TAG = 'tag-name'; -const CAMERA = 'fujifilm-x-t1'; -const CAMERA_OBJECT = getCameraFromKey(CAMERA); +const CAMERA_MAKE = 'fujifilm'; +const CAMERA_MODEL = 'x-t1'; +const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL }; const FILM_SIMULATION = 'acros'; const SHARE = 'share'; @@ -39,7 +39,7 @@ const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`; const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`; -const PATH_CAMERA = `/shot-on/${CAMERA}`; +const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`; const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`; const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`; const PATH_CAMERA_PHOTO_SHARE = `${PATH_CAMERA_PHOTO}/${SHARE}`; diff --git a/src/app/shot-on/[camera]/[photoId]/layout.tsx b/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx similarity index 83% rename from src/app/shot-on/[camera]/[photoId]/layout.tsx rename to src/app/shot-on/[make]/[model]/[photoId]/layout.tsx index a4a02c4f..55198d8a 100644 --- a/src/app/shot-on/[camera]/[photoId]/layout.tsx +++ b/src/app/shot-on/[make]/[model]/[photoId]/layout.tsx @@ -11,16 +11,12 @@ import { } from '@/site/paths'; import PhotoDetailPage from '@/photo/PhotoDetailPage'; import { getPhotoCached } from '@/photo/cache'; -import { cameraFromPhoto } from '@/camera'; +import { PhotoCameraProps, cameraFromPhoto } from '@/camera'; import { getPhotosCameraDataCached } from '@/camera/data'; import { ReactNode } from 'react'; -interface PhotoCameraProps { - params: { photoId: string, camera: string } -} - export async function generateMetadata({ - params: { photoId, camera }, + params: { photoId, make, model }, }: PhotoCameraProps): Promise { const photo = await getPhotoCached(photoId); @@ -32,7 +28,7 @@ export async function generateMetadata({ const url = absolutePathForPhoto( photo, undefined, - cameraFromPhoto(photo, camera), + cameraFromPhoto(photo, { make, model }), ); return { @@ -54,14 +50,14 @@ export async function generateMetadata({ } export default async function PhotoCameraPage({ - params: { photoId, camera: cameraProp }, + params: { photoId, make, model }, children, }: PhotoCameraProps & { children: ReactNode }) { const photo = await getPhotoCached(photoId); if (!photo) { redirect(PATH_ROOT); } - const camera = cameraFromPhoto(photo, cameraProp); + const camera = cameraFromPhoto(photo, { make, model }); const [ photos, diff --git a/src/app/shot-on/[camera]/[photoId]/page.tsx b/src/app/shot-on/[make]/[model]/[photoId]/page.tsx similarity index 100% rename from src/app/shot-on/[camera]/[photoId]/page.tsx rename to src/app/shot-on/[make]/[model]/[photoId]/page.tsx diff --git a/src/app/shot-on/[camera]/[photoId]/share/page.tsx b/src/app/shot-on/[make]/[model]/[photoId]/share/page.tsx similarity index 65% rename from src/app/shot-on/[camera]/[photoId]/share/page.tsx rename to src/app/shot-on/[make]/[model]/[photoId]/share/page.tsx index 15e02113..2c932248 100644 --- a/src/app/shot-on/[camera]/[photoId]/share/page.tsx +++ b/src/app/shot-on/[make]/[model]/[photoId]/share/page.tsx @@ -1,19 +1,17 @@ import { getPhotoCached } from '@/photo/cache'; -import { cameraFromPhoto } from '@/camera'; +import { PhotoCameraProps, cameraFromPhoto } from '@/camera'; import PhotoShareModal from '@/photo/PhotoShareModal'; import { PATH_ROOT } from '@/site/paths'; import { redirect } from 'next/navigation'; export default async function Share({ - params: { photoId, camera: cameraProp }, -}: { - params: { photoId: string, camera: string } -}) { + params: { photoId, make, model }, +}: PhotoCameraProps) { const photo = await getPhotoCached(photoId); if (!photo) { return redirect(PATH_ROOT); } - const camera = cameraFromPhoto(photo, cameraProp); + const camera = cameraFromPhoto(photo, { make, model }); return ; } diff --git a/src/app/shot-on/[camera]/image/route.tsx b/src/app/shot-on/[make]/[model]/image/route.tsx similarity index 86% rename from src/app/shot-on/[camera]/image/route.tsx rename to src/app/shot-on/[make]/[model]/image/route.tsx index 209454e0..cebecea5 100644 --- a/src/app/shot-on/[camera]/image/route.tsx +++ b/src/app/shot-on/[make]/[model]/image/route.tsx @@ -1,5 +1,5 @@ import { getPhotosCached } from '@/photo/cache'; -import { getCameraFromKey } from '@/camera'; +import { CameraProps, getCameraFromParams } from '@/camera'; import { IMAGE_OG_DIMENSION_SMALL, MAX_PHOTOS_TO_SHOW_PER_TAG, @@ -13,9 +13,9 @@ export const runtime = 'edge'; export async function GET( _: Request, - context: { params: { camera: string } }, + context: CameraProps, ) { - const camera = getCameraFromKey(context.params.camera); + const camera = getCameraFromParams(context.params); const [ photos, diff --git a/src/app/shot-on/[camera]/page.tsx b/src/app/shot-on/[make]/[model]/page.tsx similarity index 86% rename from src/app/shot-on/[camera]/page.tsx rename to src/app/shot-on/[make]/[model]/page.tsx index c726f350..404bb979 100644 --- a/src/app/shot-on/[camera]/page.tsx +++ b/src/app/shot-on/[make]/[model]/page.tsx @@ -1,4 +1,4 @@ -import { getCameraFromKey } from '@/camera'; +import { CameraProps, getCameraFromParams } from '@/camera'; import { Metadata } from 'next'; import { generateMetaForCamera } from '@/camera/meta'; import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; @@ -9,14 +9,10 @@ import { } from '@/camera/data'; import CameraOverview from '@/camera/CameraOverview'; -interface CameraProps { - params: { camera: string }, -} - export async function generateMetadata({ params, }: CameraProps): Promise { - const camera = getCameraFromKey(params.camera); + const camera = getCameraFromParams(params); const [ photos, @@ -55,7 +51,7 @@ export default async function CameraPage({ params, searchParams, }: CameraProps & PaginationParams) { - const camera = getCameraFromKey(params.camera); + const camera = getCameraFromParams(params); const { photos, diff --git a/src/app/shot-on/[camera]/share/page.tsx b/src/app/shot-on/[make]/[model]/share/page.tsx similarity index 87% rename from src/app/shot-on/[camera]/share/page.tsx rename to src/app/shot-on/[make]/[model]/share/page.tsx index cc684ea7..037b5b92 100644 --- a/src/app/shot-on/[camera]/share/page.tsx +++ b/src/app/shot-on/[make]/[model]/share/page.tsx @@ -1,4 +1,8 @@ -import { cameraFromPhoto, getCameraFromKey } from '@/camera'; +import { + CameraProps, + cameraFromPhoto, + getCameraFromParams, +} from '@/camera'; import CameraShareModal from '@/camera/CameraShareModal'; import { generateMetaForCamera } from '@/camera/meta'; import { Metadata } from 'next'; @@ -10,14 +14,10 @@ import { } from '@/camera/data'; import CameraOverview from '@/camera/CameraOverview'; -interface CameraProps { - params: { camera: string } -} - export async function generateMetadata({ params, }: CameraProps): Promise { - const camera = getCameraFromKey(params.camera); + const camera = getCameraFromParams(params); const [ photos, @@ -56,7 +56,7 @@ export default async function Share({ params, searchParams, }: CameraProps & PaginationParams) { - const cameraFromParams = getCameraFromKey(params.camera); + const cameraFromParams = getCameraFromParams(params); const { photos, diff --git a/src/camera/index.ts b/src/camera/index.ts index 8b609f9b..1a2e910a 100644 --- a/src/camera/index.ts +++ b/src/camera/index.ts @@ -8,6 +8,14 @@ export type Camera = { model: string }; +export interface CameraProps { + params: Camera +} + +export interface PhotoCameraProps { + params: Camera & { photoId: string } +} + export type CameraWithCount = { cameraKey: string camera: Camera @@ -19,11 +27,16 @@ export type Cameras = CameraWithCount[]; export const createCameraKey = ({ make, model }: Camera) => parameterize(`${make}-${model}`, true); -// Assumes no makes ('Fujifilm,' 'Apple,' 'Canon', etc.) have dashes -export const getCameraFromKey = (cameraKey: string): Camera => { - const [make, model] = cameraKey.toLowerCase().split(/[-| ](.*)/s); - return { make, model }; -}; +export const getCameraFromParams = ({ + make, + model, +}: { + make: string, + model: string, +}): Camera => ({ + make: parameterize(make, true), + model: parameterize(model, true), +}); export const sortCamerasWithCount = ( a: CameraWithCount, @@ -36,36 +49,34 @@ export const sortCamerasWithCount = ( export const cameraFromPhoto = ( photo: Photo | undefined, - fallback?: Camera | string, + fallback?: Camera, ): Camera => photo?.make && photo?.model ? { make: photo.make, model: photo.model } - : typeof fallback === 'string' - ? getCameraFromKey(fallback) - : fallback ?? CAMERA_PLACEHOLDER; + : fallback ?? CAMERA_PLACEHOLDER; export const formatCameraText = ( - { make, model: modelRaw }: Camera, - includeMakeApple?: boolean, + { make: makeRaw, model: modelRaw }: Camera, + includeMake: 'always' | 'never' | 'if-not-apple' = 'if-not-apple', + removeIPhoneOnLongModels?: boolean ) => { + // Remove 'Corporation' from makes like 'Nikon Corporation' + const make = makeRaw.replace(/ Corporation/i, ''); // Remove potential duplicate make from model - const model = modelRaw.replace(`${make} `, ''); - return make === 'Apple' && !includeMakeApple - ? model + let model = modelRaw.replace(`${make} `, ''); + if ( + removeIPhoneOnLongModels && + model.includes('iPhone') && + model.length > 9 + ) { + model = model.replace(/iPhone\s*/i, ''); + } + return ( + includeMake === 'never' || + includeMake === 'if-not-apple' && make === 'Apple' + ) ? model : `${make} ${model}`; }; -export const formatCameraModelText = ( - { make, model: modelRaw }: Camera, -) => { - // Remove potential duplicate make from model - const model = modelRaw.replace(`${make} `, ''); - const textLength = model?.length ?? 0; - if (textLength > 0 && textLength <= 8) { - return model; - } else if (model?.includes('iPhone')) { - return model.split('iPhone')[1]; - } else { - return undefined; - } -}; +export const formatCameraModelTextShort = (camera: Camera) => + formatCameraText(camera, 'never', true); diff --git a/src/image-response/PhotoImageResponse.tsx b/src/image-response/PhotoImageResponse.tsx index be8e7796..f633e513 100644 --- a/src/image-response/PhotoImageResponse.tsx +++ b/src/image-response/PhotoImageResponse.tsx @@ -5,7 +5,7 @@ import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImageContainer from './components/ImageContainer'; import { OG_TEXT_BOTTOM_ALIGNMENT } from '@/site/config'; import { NextImageSize } from '@/services/next-image'; -import { cameraFromPhoto, formatCameraModelText } from '@/camera'; +import { cameraFromPhoto, formatCameraModelTextShort } from '@/camera'; export default function PhotoImageResponse({ photo, @@ -19,7 +19,7 @@ export default function PhotoImageResponse({ fontFamily: string }) { const model = photo.model - ? formatCameraModelText(cameraFromPhoto(photo)) + ? formatCameraModelTextShort(cameraFromPhoto(photo)) : undefined; return ( diff --git a/src/services/vercel-postgres.ts b/src/services/vercel-postgres.ts index a0b67f6b..e6c82e5f 100644 --- a/src/services/vercel-postgres.ts +++ b/src/services/vercel-postgres.ts @@ -191,7 +191,7 @@ const sqlGetPhotosTagCount = async (tag: string) => sql` const sqlGetPhotosCameraCount = async (camera: Camera) => sql` SELECT COUNT(*) FROM photos WHERE - LOWER(make)=${parameterize(camera.make, true)} AND + LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND hidden IS NOT TRUE `.then(({ rows }) => parseInt(rows[0].count, 10)); @@ -225,7 +225,7 @@ 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, true)} AND + LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND hidden IS NOT TRUE `.then(({ rows }) => rows[0]?.start && rows[0]?.end @@ -380,7 +380,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { values.push(tag); } if (camera) { - wheres.push(`LOWER(make)=$${valueIndex++}`); + wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valueIndex++}`); wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valueIndex++}`); values.push(parameterize(camera.make, true)); values.push(parameterize(camera.model, true)); diff --git a/src/site/paths.ts b/src/site/paths.ts index ec049364..b8ae486a 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -1,11 +1,8 @@ import { Photo } from '@/photo'; import { BASE_URL } from './config'; -import { - Camera, - createCameraKey, - getCameraFromKey, -} from '@/camera'; +import { Camera } from '@/camera'; import { FilmSimulation } from '@/simulation'; +import { parameterize } from '@/utility/string'; // Core paths export const PATH_ROOT = '/'; @@ -24,7 +21,7 @@ export const PREFIX_FILM_SIMULATION = '/film'; // Dynamic paths const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; -const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[camera]`; +const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`; const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`; // Admin paths @@ -126,8 +123,11 @@ export const pathForTag = (tag: string, next?: number) => export const pathForTagShare = (tag: string) => `${pathForTag(tag)}/${SHARE}`; -export const pathForCamera = (camera: Camera, next?: number) => - pathWithNext(`${PREFIX_CAMERA}/${createCameraKey(camera)}`, next); +export const pathForCamera = ({ make, model }: Camera, next?: number) => + pathWithNext( + `${PREFIX_CAMERA}/${parameterize(make, true)}/${parameterize(model, true)}`, + next, + ); export const pathForCameraShare = (camera: Camera) => `${pathForCamera(camera)}/${SHARE}`; @@ -196,22 +196,22 @@ export const isPathTagPhoto = (pathname = '') => export const isPathTagPhotoShare = (pathname = '') => new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); -// shot-on/[camera] +// shot-on/[make]/[model] export const isPathCamera = (pathname = '') => - new RegExp(`^${PREFIX_CAMERA}/[^/]+/?$`).test(pathname); - -// shot-on/[camera]/share -export const isPathCameraShare = (pathname = '') => - new RegExp(`^${PREFIX_CAMERA}/[^/]+/${SHARE}/?$`).test(pathname); - -// shot-on/[camera]/[photoId] -export const isPathCameraPhoto = (pathname = '') => new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname); -// shot-on/[camera]/[photoId]/share -export const isPathCameraPhotoShare = (pathname = '') => +// shot-on/[make]/[model]/share +export const isPathCameraShare = (pathname = '') => new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); +// shot-on/[make]/[model]/[photoId] +export const isPathCameraPhoto = (pathname = '') => + new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname); + +// shot-on/[make]/[model]/[photoId]/share +export const isPathCameraPhotoShare = (pathname = '') => + new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/${SHARE}/?$`).test(pathname); + // film/[simulation] export const isPathFilmSimulation = (pathname = '') => new RegExp(`^${PREFIX_FILM_SIMULATION}/[^/]+/?$`).test(pathname); @@ -258,18 +258,20 @@ export const getPathComponents = (pathname = ''): { const photoIdFromTag = pathname.match( new RegExp(`^${PREFIX_TAG}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; const photoIdFromCamera = pathname.match( - new RegExp(`^${PREFIX_CAMERA}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; + new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; const photoIdFromFilmSimulation = pathname.match( new RegExp(`^${PREFIX_FILM_SIMULATION}/[^/]+/((?!${SHARE})[^/]+)`))?.[1]; const tag = pathname.match( new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; - const cameraString = pathname.match( + const cameraMake = pathname.match( new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; + const cameraModel = pathname.match( + new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1]; const simulation = pathname.match( new RegExp(`^${PREFIX_FILM_SIMULATION}/([^/]+)`))?.[1] as FilmSimulation; - const camera = cameraString - ? getCameraFromKey(cameraString) + const camera = cameraMake && cameraModel + ? { make: cameraMake, model: cameraModel } : undefined; return {