diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index 8168e6d1..bd9d54b0 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -2,6 +2,10 @@ import '@testing-library/jest-dom'; import { getEscapePath, getPathComponents, + isPathDevice, + isPathDevicePhoto, + isPathDevicePhotoShare, + isPathDeviceShare, isPathPhoto, isPathPhotoShare, isPathTag, @@ -9,20 +13,30 @@ import { isPathTagPhotoShare, isPathTagShare, } from '@/site/paths'; +import { getMakeModelFromDeviceString } from '@/device'; const PHOTO_ID = 'UsKSGcbt'; const TAG = 'tag-name'; +const DEVICE = 'fujifilm-x-t1'; +const DEVICE_OBJECT = getMakeModelFromDeviceString(DEVICE); const SHARE = 'share'; -const PATH_ROOT = '/'; -const PATH_GRID = '/grid'; -const PATH_ADMIN = '/admin/photos'; -const PATH_PHOTO = `/p/${PHOTO_ID}`; -const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`; -const PATH_TAG = `/t/${TAG}`; -const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`; -const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; -const PATH_TAG_PHOTO_SHARE = `${PATH_TAG}/${PHOTO_ID}/${SHARE}`; +const PATH_ROOT = '/'; +const PATH_GRID = '/grid'; +const PATH_ADMIN = '/admin/photos'; + +const PATH_PHOTO = `/p/${PHOTO_ID}`; +const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`; + +const PATH_TAG = `/t/${TAG}`; +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_DEVICE = `/shot-on/${DEVICE}`; +const PATH_DEVICE_SHARE = `${PATH_DEVICE}/${SHARE}`; +const PATH_DEVICE_PHOTO = `${PATH_DEVICE}/${PHOTO_ID}`; +const PATH_DEVICE_PHOTO_SHARE = `${PATH_DEVICE_PHOTO}/${SHARE}`; describe('Paths', () => { it('can be classified', () => { @@ -33,6 +47,10 @@ describe('Paths', () => { expect(isPathTagShare(PATH_TAG_SHARE)).toBe(true); expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true); expect(isPathTagPhotoShare(PATH_TAG_PHOTO_SHARE)).toBe(true); + expect(isPathDevice(PATH_DEVICE)).toBe(true); + expect(isPathDeviceShare(PATH_DEVICE_SHARE)).toBe(true); + expect(isPathDevicePhoto(PATH_DEVICE_PHOTO)).toBe(true); + expect(isPathDevicePhotoShare(PATH_DEVICE_PHOTO_SHARE)).toBe(true); // Negative expect(isPathPhoto(PATH_TAG_PHOTO_SHARE)).toBe(false); expect(isPathPhotoShare(PATH_TAG_PHOTO)).toBe(false); @@ -40,6 +58,10 @@ describe('Paths', () => { expect(isPathTagShare(PATH_TAG)).toBe(false); expect(isPathTagPhoto(PATH_PHOTO_SHARE)).toBe(false); expect(isPathTagPhotoShare(PATH_PHOTO)).toBe(false); + expect(isPathDevice(PATH_TAG_SHARE)).toBe(false); + expect(isPathDeviceShare(PATH_TAG)).toBe(false); + expect(isPathDevicePhoto(PATH_PHOTO_SHARE)).toBe(false); + expect(isPathDevicePhotoShare(PATH_PHOTO)).toBe(false); }); it('can be parsed', () => { expect(getPathComponents(PATH_ROOT)).toEqual({}); @@ -63,6 +85,20 @@ describe('Paths', () => { photoId: PHOTO_ID, tag: TAG, }); + expect(getPathComponents(PATH_DEVICE)).toEqual({ + device: DEVICE_OBJECT, + }); + expect(getPathComponents(PATH_DEVICE_SHARE)).toEqual({ + device: DEVICE_OBJECT, + }); + expect(getPathComponents(PATH_DEVICE_PHOTO)).toEqual({ + photoId: PHOTO_ID, + device: DEVICE_OBJECT, + }); + expect(getPathComponents(PATH_DEVICE_PHOTO_SHARE)).toEqual({ + photoId: PHOTO_ID, + device: DEVICE_OBJECT, + }); }); it('can be escaped', () => { // Root views @@ -77,5 +113,10 @@ describe('Paths', () => { expect(getEscapePath(PATH_TAG_SHARE)).toEqual(PATH_TAG); expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG); expect(getEscapePath(PATH_TAG_PHOTO_SHARE)).toEqual(PATH_TAG_PHOTO); + // Device views + expect(getEscapePath(PATH_DEVICE)).toEqual(PATH_GRID); + expect(getEscapePath(PATH_DEVICE_SHARE)).toEqual(PATH_DEVICE); + expect(getEscapePath(PATH_DEVICE_PHOTO)).toEqual(PATH_DEVICE); + expect(getEscapePath(PATH_DEVICE_PHOTO_SHARE)).toEqual(PATH_DEVICE_PHOTO); }); }); diff --git a/src/app/(static)/grid/page.tsx b/src/app/(static)/grid/page.tsx index 9abea5ff..da635ae6 100644 --- a/src/app/(static)/grid/page.tsx +++ b/src/app/(static)/grid/page.tsx @@ -59,16 +59,19 @@ export default async function GridPage({ title='Tags' icon={} items={tags.map(tag => - )} + )} />} {devices.length > 0 && } - items={devices.map(({ device, make, model }) => + items={devices.map(({ deviceKey, device }) => )} diff --git a/src/app/(static)/p/[photoId]/image/route.tsx b/src/app/(static)/p/[photoId]/image/route.tsx index 1ff41876..eab06594 100644 --- a/src/app/(static)/p/[photoId]/image/route.tsx +++ b/src/app/(static)/p/[photoId]/image/route.tsx @@ -7,7 +7,10 @@ import { ImageResponse } from 'next/server'; export const runtime = 'edge'; -export async function GET(_request: Request, context: any){ +export async function GET( + _: Request, + context: { params: { photoId: string } }, +) { const [ photo, { fontFamily, fonts }, diff --git a/src/app/(static)/shot-on/[device]/[photoId]/layout.tsx b/src/app/(static)/shot-on/[device]/[photoId]/layout.tsx new file mode 100644 index 00000000..71d03728 --- /dev/null +++ b/src/app/(static)/shot-on/[device]/[photoId]/layout.tsx @@ -0,0 +1,86 @@ +import { + descriptionForPhoto, + titleForPhoto, +} from '@/photo'; +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { + PATH_ROOT, + absolutePathForPhoto, + absolutePathForPhotoImage, +} from '@/site/paths'; +import PhotoDetailPage from '@/photo/PhotoDetailPage'; +import { getPhotoCached, getPhotosCached } from '@/cache'; +import { getPhotos, getUniqueDevices } from '@/services/postgres'; +import { deviceFromPhoto } from '@/device'; + +export async function generateStaticParams() { + const params: { params: { photoId: string, device: string }}[] = []; + + const devices = await getUniqueDevices(); + devices.forEach(async ({ deviceKey, device }) => { + const photos = await getPhotos({ device }); + params.push(...photos.map(photo => ({ + params: { photoId: photo.id, device: deviceKey }, + }))); + }); + + return params; +} + +export async function generateMetadata({ + params: { photoId }, +}: { + params: { photoId: string, device: string } +}): Promise { + const photo = await getPhotoCached(photoId); + + if (!photo) { return {}; } + + const title = titleForPhoto(photo); + const description = descriptionForPhoto(photo); + const images = absolutePathForPhotoImage(photo); + const url = absolutePathForPhoto(photo, undefined, deviceFromPhoto(photo)); + + return { + title, + description, + openGraph: { + title, + images, + description, + url, + }, + twitter: { + title, + description, + images, + card: 'summary_large_image', + }, + }; +} + +export default async function PhotoDevicePage({ + params: { photoId }, + children, +}: { + params: { photoId: string, tag: string } + children: React.ReactNode +}) { + const photo = await getPhotoCached(photoId); + + if (!photo) { redirect(PATH_ROOT); } + + const device = deviceFromPhoto(photo); + + const photos = await getPhotosCached({ device }); + + return <> + {children} + + ; +} diff --git a/src/app/(static)/shot-on/[device]/[photoId]/page.tsx b/src/app/(static)/shot-on/[device]/[photoId]/page.tsx new file mode 100644 index 00000000..67e08591 --- /dev/null +++ b/src/app/(static)/shot-on/[device]/[photoId]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/src/app/(static)/shot-on/[device]/[photoId]/share/page.tsx b/src/app/(static)/shot-on/[device]/[photoId]/share/page.tsx new file mode 100644 index 00000000..1bab0fc6 --- /dev/null +++ b/src/app/(static)/shot-on/[device]/[photoId]/share/page.tsx @@ -0,0 +1,34 @@ +import { getPhotoCached } from '@/cache'; +import { deviceFromPhoto } from '@/device'; +import PhotoShareModal from '@/photo/PhotoShareModal'; +import { getPhotos, getUniqueDevices } from '@/services/postgres'; +import { PATH_ROOT } from '@/site/paths'; +import { redirect } from 'next/navigation'; + +export async function generateStaticParams() { + const params: { params: { photoId: string, device: string }}[] = []; + + const devices = await getUniqueDevices(); + devices.forEach(async ({ deviceKey, device }) => { + const photos = await getPhotos({ device }); + params.push(...photos.map(photo => ({ + params: { photoId: photo.id, device: deviceKey }, + }))); + }); + + return params; +} + +export default async function Share({ + params: { photoId }, +}: { + params: { photoId: string } +}) { + const photo = await getPhotoCached(photoId); + + if (!photo) { return redirect(PATH_ROOT); } + + const device = deviceFromPhoto(photo); + + return ; +} diff --git a/src/app/(static)/shot-on/[device]/image/route.tsx b/src/app/(static)/shot-on/[device]/image/route.tsx new file mode 100644 index 00000000..f04ec80f --- /dev/null +++ b/src/app/(static)/shot-on/[device]/image/route.tsx @@ -0,0 +1,45 @@ +import { auth } from '@/auth'; +import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache'; +import { getMakeModelFromDeviceString } from '@/device'; +import { + IMAGE_OG_SMALL_SIZE, + MAX_PHOTOS_TO_SHOW_PER_TAG, +} from '@/photo/image-response'; +import DeviceImageResponse from '@/photo/image-response/DeviceImageResponse'; +import { getIBMPlexMonoMedium } from '@/site/font'; +import { ImageResponse } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET( + _: Request, + context: { params: { device: string } }, +) { + const device = getMakeModelFromDeviceString(context.params.device); + + const [ + photos, + { fontFamily, fonts }, + headers, + ] = await Promise.all([ + getPhotosCached({ + limit: MAX_PHOTOS_TO_SHOW_PER_TAG, + device, + }), + getIBMPlexMonoMedium(), + getImageCacheHeadersForAuth(await auth()), + ]); + + const { width, height } = IMAGE_OG_SMALL_SIZE; + + return new ImageResponse( + , + { width, height, fonts, headers }, + ); +} diff --git a/src/app/(static)/d/[device]/page.tsx b/src/app/(static)/shot-on/[device]/page.tsx similarity index 51% rename from src/app/(static)/d/[device]/page.tsx rename to src/app/(static)/shot-on/[device]/page.tsx index 8c56e93c..795f87a0 100644 --- a/src/app/(static)/d/[device]/page.tsx +++ b/src/app/(static)/shot-on/[device]/page.tsx @@ -1,36 +1,35 @@ import { getPhotosCached } from '@/cache'; import SiteGrid from '@/components/SiteGrid'; import DeviceHeader from '@/device/DeviceHeader'; -import { getMakeModelFromDevice } from '@/device'; +import { getMakeModelFromDeviceString } from '@/device'; import PhotoGrid from '@/photo/PhotoGrid'; import { getUniqueDevices } from '@/services/postgres'; -import { generateMetaForTag } from '@/tag'; import { Metadata } from 'next'; +import { generateMetaForDevice } from '@/device/meta'; -interface TagProps { +interface DeviceProps { params: { device: string } } export async function generateStaticParams() { const devices = await getUniqueDevices(); - return devices.map(device => ({ - params: { device }, + return devices.map(({ deviceKey }): DeviceProps => ({ + params: { device: deviceKey }, })); } export async function generateMetadata({ - params: { device }, -}: TagProps): Promise { - const photos = await getPhotosCached({ - device: getMakeModelFromDevice(device), - }); + params, +}: DeviceProps): Promise { + const device = getMakeModelFromDeviceString(params.device); + const photos = await getPhotosCached({ device }); const { url, title, description, images, - } = generateMetaForTag(device, photos); + } = generateMetaForDevice(device, photos); return { title, @@ -49,22 +48,17 @@ export async function generateMetadata({ }; } -export default async function DevicePage({ params: { device } }:TagProps) { - const photos = await getPhotosCached({ - device: getMakeModelFromDevice(device), - }); - - // Harvest original make/model with proper spaces/slashes - const deviceFormatted = photos.length > 0 - ? `${photos[0].make} ${photos[0].model}` - : device; +export default async function DevicePage({ params }:DeviceProps) { + const device = getMakeModelFromDeviceString(params.device); + + const photos = await getPhotosCached({ device }); return ( - - + + } /> ); diff --git a/src/app/(static)/shot-on/[device]/share/page.tsx b/src/app/(static)/shot-on/[device]/share/page.tsx new file mode 100644 index 00000000..745fab85 --- /dev/null +++ b/src/app/(static)/shot-on/[device]/share/page.tsx @@ -0,0 +1,74 @@ +import { getPhotosCached } from '@/cache'; +import SiteGrid from '@/components/SiteGrid'; +import { deviceFromPhoto, getMakeModelFromDeviceString } from '@/device'; +import DeviceHeader from '@/device/DeviceHeader'; +import DeviceShareModal from '@/device/DeviceShareModal'; +import { generateMetaForDevice } from '@/device/meta'; +import PhotoGrid from '@/photo/PhotoGrid'; +import { getUniqueDevices } from '@/services/postgres'; +import { Metadata } from 'next'; + +interface DeviceProps { + params: { device: string } +} + +export async function generateStaticParams() { + const devices = await getUniqueDevices(); + return devices.map(({ deviceKey }): DeviceProps => ({ + params: { device: deviceKey }, + })); +} + +export async function generateMetadata({ + params, +}: DeviceProps): Promise { + const device = getMakeModelFromDeviceString(params.device); + + const photos = await getPhotosCached({ device }); + + const { + url, + title, + description, + images, + } = generateMetaForDevice(device, photos); + + return { + title, + openGraph: { + title, + description, + images, + url, + }, + twitter: { + images, + description, + card: 'summary_large_image', + }, + description, + }; +} + +export default async function Share({ + params, +}: { + params: { device: string } +}) { + const deviceFromParams = getMakeModelFromDeviceString(params.device); + + const photos = await getPhotosCached({ device: deviceFromParams }); + + const device = deviceFromPhoto(photos[0]) ?? deviceFromParams; + + return <> + + + + + } + /> + ; +} diff --git a/src/app/(static)/t/[tag]/image/route.tsx b/src/app/(static)/t/[tag]/image/route.tsx index e9175e47..860d258f 100644 --- a/src/app/(static)/t/[tag]/image/route.tsx +++ b/src/app/(static)/t/[tag]/image/route.tsx @@ -10,8 +10,11 @@ import { ImageResponse } from 'next/server'; export const runtime = 'edge'; -export async function GET(_request: Request, context: any) { - const tag = context.params.tag as string; +export async function GET( + _: Request, + context: { params: { tag: string } }, +) { + const tag = context.params.tag; const [ photos, diff --git a/src/app/(static)/t/[tag]/share/page.tsx b/src/app/(static)/t/[tag]/share/page.tsx index 2038d404..c81a936b 100644 --- a/src/app/(static)/t/[tag]/share/page.tsx +++ b/src/app/(static)/t/[tag]/share/page.tsx @@ -54,7 +54,7 @@ export default async function Share({ }) { const photos = await getPhotosCached({ tag }); return <> - + diff --git a/src/components/HeaderList.tsx b/src/components/HeaderList.tsx index 90f50269..fd8d6f46 100644 --- a/src/components/HeaderList.tsx +++ b/src/components/HeaderList.tsx @@ -14,7 +14,8 @@ export default function HeaderList({ diff --git a/src/components/IconPathButton.tsx b/src/components/IconPathButton.tsx index 64d6972b..9aa43036 100644 --- a/src/components/IconPathButton.tsx +++ b/src/components/IconPathButton.tsx @@ -11,12 +11,14 @@ export default function IconPathButton({ prefetch, loaderDelay = 250, shouldScroll = true, + shouldReplace, }: { icon: JSX.Element path: string prefetch?: boolean loaderDelay?: number shouldScroll?: boolean + shouldReplace?: boolean }) { const router = useRouter(); @@ -44,8 +46,13 @@ export default function IconPathButton({ return ( startTransition(() => - router.push(path, { scroll: shouldScroll }))} + onClick={() => startTransition(() => { + if (shouldReplace) { + router.replace(path, { scroll: shouldScroll }); + } else { + router.push(path, { scroll: shouldScroll }); + } + })} isLoading={shouldShowLoader} className={cc( 'translate-y-[-0.5px]', diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 936ab557..08ec476a 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -20,6 +20,7 @@ export default function ShareButton({ : undefined} />, prefetch, shouldScroll, + shouldReplace: true, }} /> ); } diff --git a/src/device/DeviceHeader.tsx b/src/device/DeviceHeader.tsx index 51a2b452..f11c21c4 100644 --- a/src/device/DeviceHeader.tsx +++ b/src/device/DeviceHeader.tsx @@ -1,26 +1,28 @@ import { Photo } from '@/photo'; -import { pathForTagShare } from '@/site/paths'; +import { pathForDeviceShare } from '@/site/paths'; import PhotoHeader from '@/photo/PhotoHeader'; -import { descriptionForDevicePhotos, getMakeModelFromDevice } from '.'; +import { Device, formatDevice } from '.'; import PhotoDevice from './PhotoDevice'; +import { descriptionForDevicePhotos } from './meta'; export default function DeviceHeader({ - device, + device: deviceFromProps, photos, selectedPhoto, }: { - device: string + device: Device photos: Photo[] selectedPhoto?: Photo }) { + const device = formatDevice(deviceFromProps, photos[0]); return ( } + entity={} entityVerb="Device" entityDescription={descriptionForDevicePhotos(photos)} photos={photos} selectedPhoto={selectedPhoto} - sharePath={pathForTagShare(device)} + sharePath={pathForDeviceShare(device)} /> ); } diff --git a/src/device/DeviceOGTile.tsx b/src/device/DeviceOGTile.tsx new file mode 100644 index 00000000..e68dcbe2 --- /dev/null +++ b/src/device/DeviceOGTile.tsx @@ -0,0 +1,39 @@ +import { Photo } from '@/photo'; +import { absolutePathForDeviceImage, pathForDevice } from '@/site/paths'; +import OGTile from '@/components/OGTile'; +import { Device, titleForDevice } from '.'; +import { descriptionForDevicePhotos } from './meta'; + +export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; + +export default function DeviceOGTile({ + device, + photos, + loadingState: loadingStateExternal, + riseOnHover, + onLoad, + onFail, + retryTime, +}: { + device: Device + photos: Photo[] + loadingState?: OGLoadingState + onLoad?: () => void + onFail?: () => void + riseOnHover?: boolean + retryTime?: number +}) { + return ( + + ); +}; diff --git a/src/device/DeviceShareModal.tsx b/src/device/DeviceShareModal.tsx new file mode 100644 index 00000000..5a0b415c --- /dev/null +++ b/src/device/DeviceShareModal.tsx @@ -0,0 +1,23 @@ +import { absolutePathForDevice, pathForDevice } from '@/site/paths'; +import { Photo } from '../photo'; +import ShareModal from '@/components/ShareModal'; +import DeviceOGTile from './DeviceOGTile'; +import { Device } from '.'; + +export default function DeviceShareModal({ + device, + photos, +}: { + device: Device + photos: Photo[] +}) { + return ( + + + + ); +}; diff --git a/src/device/PhotoDevice.tsx b/src/device/PhotoDevice.tsx index 0c0a3fa1..aea0f961 100644 --- a/src/device/PhotoDevice.tsx +++ b/src/device/PhotoDevice.tsx @@ -3,43 +3,42 @@ import { cc } from '@/utility/css'; import Link from 'next/link'; import { pathForDevice } from '@/site/paths'; import { IoMdCamera } from 'react-icons/io'; +import { Device } from '.'; export default function PhotoDevice({ - make, - model, + device, showIcon = true, hideApple = true, }: { - make?: string - model?: string + device: Device showIcon?: boolean hideApple?: boolean }) { - console.log({ make, model, showIcon, hideApple }); return ( {showIcon && <>   } - {!(hideApple && make?.toLowerCase() === 'apple') && + {!(hideApple && device.make?.toLowerCase() === 'apple') && <> - {make?.toLowerCase() === 'apple' + {device.make?.toLowerCase() === 'apple' ? - : make} + : device.make}   } - {model} + {device.model} ); } diff --git a/src/device/index.ts b/src/device/index.ts index 094323f0..43dd424a 100644 --- a/src/device/index.ts +++ b/src/device/index.ts @@ -1,14 +1,40 @@ -import { Photo, descriptionForPhotoSet } from '@/photo'; +import { Photo } from '@/photo'; +import { capitalizeWords, parameterize } from '@/utility/string'; + +export type Device = { + make: string + model: string +}; + +export const createDeviceKey = (make: string, model: string) => + parameterize(`${make}-${model}`); + +export const titleForDevice = ( + { make, model }: Device, + photos:Photo[], +) => [ + 'Shot on', + deviceTextFromPhoto(photos[0]) ?? capitalizeWords(`${make} ${model}`), +].join(' '); // Assumes no device makes ('Fujifilm,' 'Apple,' 'Canon', etc.) // will have dashes in them -export const getMakeModelFromDevice = (device: string) => { +export const getMakeModelFromDeviceString = (device: string): Device => { const [make, model] = device.toLowerCase().split(/[-| ](.*)/s); return { make, model }; }; -export const descriptionForDevicePhotos = ( - photos: Photo[], - dateBased?: boolean, -) => - descriptionForPhotoSet(photos, 'device', dateBased); +// Used to harvest original make/model with proper spaces/hyphens +export const deviceTextFromPhoto = (photo?: Photo) => photo + ? `${photo.make} ${photo.model}` + : undefined; + +export const deviceFromPhoto = (photo?: Photo): Device | undefined => + photo?.make && photo?.model + ? { make: photo.make, model: photo.model } + : undefined; + +export const formatDevice = (device: Device, photo?: Photo): Device => + photo?.make && photo?.model + ? { make: photo.make, model: photo.model } + : device; diff --git a/src/device/meta.ts b/src/device/meta.ts new file mode 100644 index 00000000..9102108e --- /dev/null +++ b/src/device/meta.ts @@ -0,0 +1,26 @@ +import { Photo, descriptionForPhotoSet } from '@/photo'; +import { Device, titleForDevice } from '.'; +import { + absolutePathForDevice, + absolutePathForDeviceImage, +} from '@/site/paths'; + +// Meta functions moved to separate file to avoid +// dependencies (camelcase-keys) found in photo/index.ts +// that cause Jest to crash + +export const descriptionForDevicePhotos = ( + photos: Photo[], + dateBased?: boolean, +) => + descriptionForPhotoSet(photos, 'device', dateBased); + +export const generateMetaForDevice = ( + device: Device, + photos: Photo[] +) => ({ + url: absolutePathForDevice(device), + title: titleForDevice(device, photos), + description: descriptionForDevicePhotos(photos, true), + images: absolutePathForDeviceImage(device), +}); diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index 20bc70a3..750061aa 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -6,17 +6,21 @@ import PhotoGrid from './PhotoGrid'; import { cc } from '@/utility/css'; import PhotoLinks from './PhotoLinks'; import TagHeader from '@/tag/TagHeader'; +import { Device } from '@/device'; +import DeviceHeader from '@/device/DeviceHeader'; export default function PhotoDetailPage({ photo, photos, photosGrid, tag, + device, }: { photo: Photo photos: Photo[] photosGrid?: Photo[] tag?: string + device?: Device }) { return (
@@ -31,6 +35,17 @@ export default function PhotoDetailPage({ selectedPhoto={photo} />} />} + {device && + } + />} , ]} /> @@ -58,7 +75,12 @@ export default function PhotoDetailPage({ 'md:flex md:gap-4', 'user-select-none', )}> - +
} /> diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index aa54697f..f94d9eaa 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -2,11 +2,13 @@ import { Photo } from '.'; import PhotoSmall from './PhotoSmall'; import { cc } from '@/utility/css'; import AnimateItems from '@/components/AnimateItems'; +import { Device } from '@/device'; export default function PhotoGrid({ photos, selectedPhoto, tag, + device, fast, animate = true, animateOnFirstLoadOnly, @@ -15,6 +17,7 @@ export default function PhotoGrid({ photos: Photo[] selectedPhoto?: Photo tag?: string + device?: Device fast?: boolean animate?: boolean animateOnFirstLoadOnly?: boolean @@ -38,6 +41,7 @@ export default function PhotoGrid({ key={photo.id} photo={photo} tag={tag} + device={device} selected={photo.id === selectedPhoto?.id} />)} /> diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 91b42e29..abaa8958 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -7,21 +7,28 @@ import { pathForPhoto, pathForPhotoShare } from '@/site/paths'; import PhotoTags from '@/tag/PhotoTags'; import ShareButton from '@/components/ShareButton'; import PhotoDevice from '../device/PhotoDevice'; +import { deviceFromPhoto } from '@/device'; export default function PhotoLarge({ photo, tag, priority, prefetchShare, + shareDevice, shouldScrollOnShare, + showDevice = true, }: { photo: Photo tag?: string priority?: boolean prefetchShare?: boolean + shareDevice?: boolean shouldScrollOnShare?: boolean + showDevice?: boolean }) { const tagsToShow = photo.tags.filter(t => t !== tag); + + const device = deviceFromPhoto(photo); const renderMiniGrid = (children: JSX.Element) =>
0 && }
- + {showDevice && device && + } )} {renderMiniGrid(<>
    diff --git a/src/photo/PhotoLink.tsx b/src/photo/PhotoLink.tsx index b881c8ac..ad2984b0 100644 --- a/src/photo/PhotoLink.tsx +++ b/src/photo/PhotoLink.tsx @@ -6,16 +6,19 @@ import Link from 'next/link'; import { AnimationConfig } from '../components/AnimateItems'; import { useAppState } from '@/state'; import { pathForPhoto } from '@/site/paths'; +import { Device } from '@/device'; export default function PhotoLink({ photo, tag, + device, prefetch, nextPhotoAnimation, children, }: { photo?: Photo tag?: string + device?: Device prefetch?: boolean nextPhotoAnimation?: AnimationConfig children: ReactNode @@ -25,7 +28,7 @@ export default function PhotoLink({ return ( photo ? { if (nextPhotoAnimation) { diff --git a/src/photo/PhotoLinks.tsx b/src/photo/PhotoLinks.tsx index ca01ca6f..fc62df2d 100644 --- a/src/photo/PhotoLinks.tsx +++ b/src/photo/PhotoLinks.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'; import { pathForPhoto } from '@/site/paths'; import { useAppState } from '@/state'; import { AnimationConfig } from '@/components/AnimateItems'; +import { Device } from '@/device'; const LISTENER_KEYUP = 'keyup'; @@ -17,10 +18,12 @@ export default function PhotoLinks({ photo, photos, tag, + device, }: { photo: Photo photos: Photo[] tag?: string + device?: Device }) { const router = useRouter(); @@ -36,14 +39,20 @@ export default function PhotoLinks({ case 'J': if (previousPhoto) { setNextPhotoAnimation?.(ANIMATION_RIGHT); - router.push(pathForPhoto(previousPhoto, tag), { scroll: false }); + router.push( + pathForPhoto(previousPhoto, tag, device), + { scroll: false }, + ); } break; case 'ARROWRIGHT': case 'L': if (nextPhoto) { setNextPhotoAnimation?.(ANIMATION_LEFT); - router.push(pathForPhoto(nextPhoto, tag), { scroll: false }); + router.push( + pathForPhoto(nextPhoto, tag, device), + { scroll: false }, + ); } break; }; @@ -56,6 +65,7 @@ export default function PhotoLinks({ previousPhoto, nextPhoto, tag, + device, ]); return ( @@ -64,6 +74,7 @@ export default function PhotoLinks({ photo={previousPhoto} nextPhotoAnimation={ANIMATION_RIGHT} tag={tag} + device={device} prefetch > PREV @@ -72,6 +83,7 @@ export default function PhotoLinks({ photo={nextPhoto} nextPhotoAnimation={ANIMATION_LEFT} tag={tag} + device={device} prefetch > NEXT diff --git a/src/photo/PhotoShareModal.tsx b/src/photo/PhotoShareModal.tsx index a78c5cb7..41004f79 100644 --- a/src/photo/PhotoShareModal.tsx +++ b/src/photo/PhotoShareModal.tsx @@ -2,19 +2,22 @@ import PhotoOGTile from '@/photo/PhotoOGTile'; import { absolutePathForPhoto, pathForPhoto } from '@/site/paths'; import { Photo } from '.'; import ShareModal from '@/components/ShareModal'; +import { Device } from '@/device'; export default function PhotoShareModal({ photo, tag, + device, }: { photo: Photo tag?: string + device?: Device }) { return ( diff --git a/src/photo/PhotoSmall.tsx b/src/photo/PhotoSmall.tsx index a86e1b67..6b5b44e4 100644 --- a/src/photo/PhotoSmall.tsx +++ b/src/photo/PhotoSmall.tsx @@ -3,19 +3,22 @@ import ImageSmall from '@/components/ImageSmall'; import Link from 'next/link'; import { cc } from '@/utility/css'; import { pathForPhoto } from '@/site/paths'; +import { Device } from '@/device'; export default function PhotoSmall({ photo, tag, + device, selected, }: { photo: Photo tag?: string + device?: Device selected?: boolean }) { return ( + + + + + {make.toLowerCase() !== 'apple' && make} + {model} + + + + ); +} diff --git a/src/photo/image-response/components/ImageCaption.tsx b/src/photo/image-response/components/ImageCaption.tsx index a3a0234a..bfb55ae2 100644 --- a/src/photo/image-response/components/ImageCaption.tsx +++ b/src/photo/image-response/components/ImageCaption.tsx @@ -1,17 +1,21 @@ +import { ReactNode } from 'react'; + export default function ImageCaption({ height, - children, fontFamily, + subhead, + children, }: { width: number height: number fontFamily: string - children: React.ReactNode + subhead?: ReactNode + children: ReactNode }) { return (
    - {children} + {subhead && +
    + {subhead} +
    } +
    + {children} +
    ); } diff --git a/src/services/postgres.ts b/src/services/postgres.ts index 11d8475d..35d5c9b1 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -6,6 +6,8 @@ import { parsePhotoFromDb, Photo, } from '@/photo'; +import { Device, createDeviceKey } from '@/device'; +import { parameterize } from '@/utility/string'; const PHOTO_DEFAULT_LIMIT = 100; @@ -195,8 +197,8 @@ const sqlGetPhotosByDevice = async ( ) => sql` SELECT * FROM photos WHERE - LOWER(make)=${make} AND - LOWER(REPLACE(model, ' ', '-'))=${model} + LOWER(make)=${parameterize(make)} AND + LOWER(REPLACE(model, ' ', '-'))=${parameterize(model)} ORDER BY taken_at DESC LIMIT ${limit} `; @@ -247,15 +249,17 @@ const sqlGetUniqueDevices = async () => sql` SELECT DISTINCT make||' '||model as device, make, model FROM photos WHERE hidden IS NOT TRUE ORDER BY device ASC -`.then(({ rows }) => - rows as { device: string, make: string, model: string }[]); +`.then(({ rows }) => rows.map(({ make, model }) => ({ + deviceKey: createDeviceKey(make, model), + device: { make, model } as Device, + }))); export type GetPhotosOptions = { sortBy?: 'createdAt' | 'takenAt' | 'priority' limit?: number offset?: number tag?: string - device?: { make: string, model: string } + device?: Device takenBefore?: Date takenAfterInclusive?: Date includeHidden?: boolean diff --git a/src/site/paths.ts b/src/site/paths.ts index 911c5f83..af8a8e3d 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -1,11 +1,15 @@ import { Photo } from '@/photo'; import { BASE_URL } from './config'; -import { parameterize } from '@/utility/string'; +import { + Device, + createDeviceKey, + getMakeModelFromDeviceString, +} from '@/device'; // Prefixes const PREFIX_PHOTO = '/p'; const PREFIX_TAG = '/t'; -const PREFIX_DEVICE = '/d'; +const PREFIX_DEVICE = '/shot-on'; // Modifiers const SHARE = 'share'; @@ -45,13 +49,23 @@ type PhotoOrPhotoId = Photo | string; const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) => typeof photoOrPhotoId === 'string' ? photoOrPhotoId : photoOrPhotoId.id; -export const pathForPhoto = (photo: PhotoOrPhotoId, tag?: string) => +export const pathForPhoto = ( + photo: PhotoOrPhotoId, + tag?: string, + device?: Device, +) => tag ? `${pathForTag(tag)}/${getPhotoId(photo)}` - : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; + : device + ? `${pathForDevice(device)}/${getPhotoId(photo)}` + : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; -export const pathForPhotoShare = (photo: PhotoOrPhotoId, tag?: string) => - `${pathForPhoto(photo, tag)}/${SHARE}`; +export const pathForPhotoShare = ( + photo: PhotoOrPhotoId, + tag?: string, + device?: Device, +) => + `${pathForPhoto(photo, tag, device)}/${SHARE}`; export const pathForPhotoEdit = (photo: PhotoOrPhotoId) => `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`; @@ -59,24 +73,37 @@ export const pathForPhotoEdit = (photo: PhotoOrPhotoId) => export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`; -export const pathForDevice = (make?: string, model?: string) => - `${PREFIX_DEVICE}/${parameterize(`${make}-${model}`)}`; - export const pathForTagShare = (tag: string) => `${pathForTag(tag)}/${SHARE}`; -export const absolutePathForPhoto = (photo: PhotoOrPhotoId, tag?: string) => - `${BASE_URL}${pathForPhoto(photo, tag)}`; +export const pathForDevice = ({ make, model }: Device) => + `${PREFIX_DEVICE}/${createDeviceKey(make, model)}`; + +export const pathForDeviceShare = (device: Device) => + `${pathForDevice(device)}/${SHARE}`; + +export const absolutePathForPhoto = ( + photo: PhotoOrPhotoId, + tag?: string, + device?: Device, +) => + `${BASE_URL}${pathForPhoto(photo, tag, device)}`; export const absolutePathForTag = (tag: string) => `${BASE_URL}${pathForTag(tag)}`; +export const absolutePathForDevice= (device: Device) => + `${BASE_URL}${pathForDevice(device)}`; + export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) => `${absolutePathForPhoto(photo)}/image`; export const absolutePathForTagImage = (tag: string) => `${absolutePathForTag(tag)}/image`; +export const absolutePathForDeviceImage= (device: Device) => + `${absolutePathForDevice(device)}/image`; + // p/[photoId] export const isPathPhoto = (pathname = '') => /^\/p\/[^/]+\/?$/.test(pathname); @@ -85,22 +112,38 @@ export const isPathPhoto = (pathname = '') => export const isPathPhotoShare = (pathname = '') => /^\/p\/[^/]+\/share\/?$/.test(pathname); -// t/[tagId] +// t/[tag] export const isPathTag = (pathname = '') => /^\/t\/[^/]+\/?$/.test(pathname); -// t/[tagId]/share +// t/[tag]/share export const isPathTagShare = (pathname = '') => /^\/t\/[^/]+\/share\/?$/.test(pathname); -// t/[tagId]/[photoId] +// t/[tag]/[photoId] export const isPathTagPhoto = (pathname = '') => /^\/t\/[^/]+\/[^/]+\/?$/.test(pathname); -// t/[tagId]/[photoId]/share +// t/[tag]/[photoId]/share export const isPathTagPhotoShare = (pathname = '') => /^\/t\/[^/]+\/[^/]+\/share\/?$/.test(pathname); +// shot-on/[device] +export const isPathDevice = (pathname = '') => + /^\/shot-on\/[^/]+\/?$/.test(pathname); + +// shot-on/[device]/share +export const isPathDeviceShare = (pathname = '') => + /^\/shot-on\/[^/]+\/share\/?$/.test(pathname); + +// shot-on/[device]/[photoId] +export const isPathDevicePhoto = (pathname = '') => + /^\/shot-on\/[^/]+\/[^/]+\/?$/.test(pathname); + +// shot-on/[device]/[photoId]/share +export const isPathDevicePhotoShare = (pathname = '') => + /^\/shot-on\/[^/]+\/[^/]+\/share\/?$/.test(pathname); + export const isPathGrid = (pathname = '') => pathname.startsWith(PATH_GRID); @@ -117,33 +160,51 @@ export const isPathProtected = (pathname = '') => export const getPathComponents = (pathname = ''): { photoId?: string tag?: string + device?: Device } => { const photoIdFromPhoto = pathname.match(/^\/p\/([^/]+)/)?.[1]; const photoIdFromTag = pathname.match(/^\/t\/[^/]+\/((?!share)[^/]+)/)?.[1]; + // eslint-disable-next-line max-len + const photoIdFromDevice = pathname.match(/^\/shot-on\/[^/]+\/((?!share)[^/]+)/)?.[1]; const tag = pathname.match(/^\/t\/([^/]+)/)?.[1]; + const deviceString = pathname.match(/^\/shot-on\/([^/]+)/)?.[1]; + const device = deviceString + ? getMakeModelFromDeviceString(deviceString) + : undefined; return { photoId: ( photoIdFromPhoto || - photoIdFromTag + photoIdFromTag || + photoIdFromDevice ), tag, + device, }; }; export const getEscapePath = (pathname?: string) => { - const { photoId, tag } = getPathComponents(pathname); + const { photoId, tag, device } = getPathComponents(pathname); if ( (photoId && isPathPhoto(pathname)) || - (tag && isPathTag(pathname)) + (tag && isPathTag(pathname)) || + (device && isPathDevice(pathname)) ) { return PATH_GRID; } else if (photoId && isPathTagPhotoShare(pathname)) { return pathForPhoto(photoId, tag); + } else if (photoId && isPathDevicePhotoShare(pathname)) { + return pathForPhoto(photoId, undefined, device); } else if (photoId && isPathPhotoShare(pathname)) { return pathForPhoto(photoId); - } else if (tag && (isPathTagPhoto(pathname) || isPathTagShare(pathname))) { - return pathForTag(tag); - } else if (tag && isPathTagShare(pathname)) { + } else if (tag && ( + isPathTagPhoto(pathname) || + isPathTagShare(pathname) + )) { return pathForTag(tag); + } else if (device && ( + isPathDevicePhoto(pathname) || + isPathDeviceShare(pathname) + )) { + return pathForDevice(device); } }; diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index 87bc5c68..6d016a74 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -12,9 +12,11 @@ export default function PhotoTag({ }) { return ( {showIcon &&