Add robust support for device-based views

This commit is contained in:
Sam Becker 2023-10-03 11:23:07 -05:00
parent af7af53401
commit 6c55377257
32 changed files with 717 additions and 115 deletions

View File

@ -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);
});
});

View File

@ -59,16 +59,19 @@ export default async function GridPage({
title='Tags'
icon={<FaTag size={12} />}
items={tags.map(tag =>
<PhotoTag key={tag} tag={tag} showIcon={false} />)}
<PhotoTag
key={tag}
tag={tag}
showIcon={false}
/>)}
/>}
{devices.length > 0 && <HeaderList
title="Devices"
icon={<IoMdCamera size={13} />}
items={devices.map(({ device, make, model }) =>
items={devices.map(({ deviceKey, device }) =>
<PhotoDevice
key={device}
make={make}
model={model}
key={deviceKey}
device={device}
showIcon={false}
hideApple
/>)}

View File

@ -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 },

View File

@ -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<Metadata> {
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}
<PhotoDetailPage
photo={photo}
photos={photos}
device={device}
/>
</>;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View File

@ -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 <PhotoShareModal {...{ photo, device }} />;
}

View File

@ -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(
<DeviceImageResponse {...{
device,
photos,
width,
height,
fontFamily,
}}/>,
{ width, height, fonts, headers },
);
}

View File

@ -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<Metadata> {
const photos = await getPhotosCached({
device: getMakeModelFromDevice(device),
});
params,
}: DeviceProps): Promise<Metadata> {
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 (
<SiteGrid
key="Device Grid"
contentMain={<div className="space-y-8 mt-4">
<DeviceHeader device={deviceFormatted} photos={photos} />
<PhotoGrid photos={photos} tag={deviceFormatted} />
<DeviceHeader device={device} photos={photos} />
<PhotoGrid photos={photos} device={device} />
</div>}
/>
);

View File

@ -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<Metadata> {
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 <>
<DeviceShareModal {...{ device, photos }} />
<SiteGrid
key="Device Grid"
contentMain={<div className="space-y-8 mt-4">
<DeviceHeader device={device} photos={photos} />
<PhotoGrid photos={photos} device={device} />
</div>}
/>
</>;
}

View File

@ -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,

View File

@ -54,7 +54,7 @@ export default async function Share({
}) {
const photos = await getPhotosCached({ tag });
return <>
<TagShareModal tag={tag} photos={photos} />
<TagShareModal {...{ tag, photos }} />
<SiteGrid
key="Tag Grid"
contentMain={<div className="space-y-8 mt-4">

View File

@ -14,7 +14,8 @@ export default function HeaderList({
<AnimateItems
items={[
<div key="header" className={cc(
'text-gray-900 dark:text-gray-100',
'text-gray-900',
'dark:text-gray-100',
'flex items-center mb-0.5',
'uppercase',
)}>

View File

@ -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 (
<IconButton
icon={icon}
onClick={() => 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]',

View File

@ -20,6 +20,7 @@ export default function ShareButton({
: undefined} />,
prefetch,
shouldScroll,
shouldReplace: true,
}} />
);
}

View File

@ -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 (
<PhotoHeader
entity={<PhotoDevice {...getMakeModelFromDevice(device)} />}
entity={<PhotoDevice {...{ device }} />}
entityVerb="Device"
entityDescription={descriptionForDevicePhotos(photos)}
photos={photos}
selectedPhoto={selectedPhoto}
sharePath={pathForTagShare(device)}
sharePath={pathForDeviceShare(device)}
/>
);
}

View File

@ -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 (
<OGTile {...{
title: titleForDevice(device, photos),
description: descriptionForDevicePhotos(photos, true),
path: pathForDevice(device),
pathImageAbsolute: absolutePathForDeviceImage(device),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/>
);
};

View File

@ -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 (
<ShareModal
title="Share Photos"
pathShare={absolutePathForDevice(device)}
pathClose={pathForDevice(device)}
>
<DeviceOGTile {...{ device, photos }} />
</ShareModal>
);
};

View File

@ -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 (
<Link
href={pathForDevice(make, model)}
href={pathForDevice(device)}
className={cc(
'inline-flex items-center self-start',
'uppercase',
'hover:text-gray-900 dark:hover:text-gray-100',
)}
>
{showIcon && <>
<IoMdCamera size={13} />
&nbsp;
</>}
{!(hideApple && make?.toLowerCase() === 'apple') &&
{!(hideApple && device.make?.toLowerCase() === 'apple') &&
<>
{make?.toLowerCase() === 'apple'
{device.make?.toLowerCase() === 'apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
size={14}
/>
: make}
: device.make}
&nbsp;
</>}
{model}
{device.model}
</Link>
);
}

View File

@ -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;

26
src/device/meta.ts Normal file
View File

@ -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),
});

View File

@ -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 (
<div>
@ -31,6 +35,17 @@ export default function PhotoDetailPage({
selectedPhoto={photo}
/>}
/>}
{device &&
<SiteGrid
className="mt-4 mb-8"
contentMain={
<DeviceHeader
key={tag}
device={device}
photos={photos}
selectedPhoto={photo}
/>}
/>}
<AnimateItems
className="md:mb-8"
animateFromAppState
@ -41,7 +56,9 @@ export default function PhotoDetailPage({
tag={tag}
priority
prefetchShare
shareDevice={device !== undefined}
shouldScrollOnShare={false}
showDevice={false}
/>,
]}
/>
@ -58,7 +75,12 @@ export default function PhotoDetailPage({
'md:flex md:gap-4',
'user-select-none',
)}>
<PhotoLinks photo={photo} photos={photos} tag={tag} />
<PhotoLinks {...{
photo,
photos,
tag,
device,
}} />
</div>}
/>
</div>

View File

@ -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}
/>)}
/>

View File

@ -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) =>
<div className={cc(
@ -64,12 +71,12 @@ export default function PhotoLarge({
{tagsToShow.length > 0 &&
<PhotoTags tags={tagsToShow} />}
</div>
<PhotoDevice
make={photo.make}
model={photo.model}
showIcon={false}
hideApple={false}
/>
{showDevice && device &&
<PhotoDevice
device={device}
showIcon={false}
hideApple={false}
/>}
</>)}
{renderMiniGrid(<>
<ul className={cc(
@ -104,7 +111,11 @@ export default function PhotoLarge({
</div>
<div className="-translate-x-0.5">
<ShareButton
path={pathForPhotoShare(photo, tag)}
path={pathForPhotoShare(
photo,
tag,
shareDevice ? device : undefined,
)}
prefetch={prefetchShare}
shouldScroll={shouldScrollOnShare}
/>

View File

@ -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
? <Link
href={pathForPhoto(photo, tag)}
href={pathForPhoto(photo, tag, device)}
prefetch={prefetch}
onClick={() => {
if (nextPhotoAnimation) {

View File

@ -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

View File

@ -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 (
<ShareModal
title="Share Photo"
pathShare={absolutePathForPhoto(photo, tag)}
pathClose={pathForPhoto(photo, tag)}
pathShare={absolutePathForPhoto(photo, tag, device)}
pathClose={pathForPhoto(photo, tag, device)}
>
<PhotoOGTile photo={photo} />
</ShareModal>

View File

@ -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 (
<Link
href={pathForPhoto(photo, tag)}
href={pathForPhoto(photo, tag, device)}
className={cc(
'active:brightness-75',
selected && 'brightness-50',

View File

@ -0,0 +1,44 @@
import { Photo } from '..';
import ImageCaption from './components/ImageCaption';
import ImagePhotoGrid from './components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer';
import { Device, formatDevice } from '@/device';
import { IoMdCamera } from 'react-icons/io';
export default function DeviceImageResponse({
device: deviceFromProps,
photos,
width,
height,
fontFamily,
}: {
device: Device
photos: Photo[]
width: number
height: number
fontFamily: string
}) {
const { make, model } = formatDevice(deviceFromProps, photos[0]);
return (
<ImageContainer {...{
width,
height,
...photos.length === 0 && { background: 'black' },
}}>
<ImagePhotoGrid
{...{
photos,
width,
height,
}}
/>
<ImageCaption {...{ width, height, fontFamily }}>
<IoMdCamera size={height * .09} />
<span style={{textTransform: 'uppercase'}}>
{make.toLowerCase() !== 'apple' && make}
{model}
</span>
</ImageCaption>
</ImageContainer>
);
}

View File

@ -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 (
<div style={{
display: 'flex',
gap: height * .053,
flexDirection: 'column',
position: 'absolute',
paddingTop: height * .6,
paddingBottom: height * .075,
@ -28,11 +32,30 @@ export default function ImageCaption({
bottom: 0,
left: 0,
right: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{children}
{subhead &&
<div
style={{
display: 'flex',
gap: height * .053,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{subhead}
</div>}
<div
style={{
display: 'flex',
gap: height * .053,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{children}
</div>
</div>
);
}

View File

@ -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<PhotoDb>`
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

View File

@ -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);
}
};

View File

@ -12,9 +12,11 @@ export default function PhotoTag({
}) {
return (
<Link
key={tag}
href={pathForTag(tag)}
className="flex items-center gap-x-1.5 self-start"
className={cc(
'flex items-center gap-x-1.5 self-start',
'hover:text-gray-900 dark:hover:text-gray-100',
)}
>
{showIcon &&
<FaTag