Add robust support for device-based views
This commit is contained in:
parent
af7af53401
commit
6c55377257
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
/>)}
|
||||
|
||||
@ -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 },
|
||||
|
||||
86
src/app/(static)/shot-on/[device]/[photoId]/layout.tsx
Normal file
86
src/app/(static)/shot-on/[device]/[photoId]/layout.tsx
Normal 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}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
3
src/app/(static)/shot-on/[device]/[photoId]/page.tsx
Normal file
3
src/app/(static)/shot-on/[device]/[photoId]/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
34
src/app/(static)/shot-on/[device]/[photoId]/share/page.tsx
Normal file
34
src/app/(static)/shot-on/[device]/[photoId]/share/page.tsx
Normal 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 }} />;
|
||||
}
|
||||
45
src/app/(static)/shot-on/[device]/image/route.tsx
Normal file
45
src/app/(static)/shot-on/[device]/image/route.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
74
src/app/(static)/shot-on/[device]/share/page.tsx
Normal file
74
src/app/(static)/shot-on/[device]/share/page.tsx
Normal 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>}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
)}>
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -20,6 +20,7 @@ export default function ShareButton({
|
||||
: undefined} />,
|
||||
prefetch,
|
||||
shouldScroll,
|
||||
shouldReplace: true,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
39
src/device/DeviceOGTile.tsx
Normal file
39
src/device/DeviceOGTile.tsx
Normal 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,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
23
src/device/DeviceShareModal.tsx
Normal file
23
src/device/DeviceShareModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
|
||||
</>}
|
||||
{!(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}
|
||||
|
||||
</>}
|
||||
{model}
|
||||
{device.model}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
26
src/device/meta.ts
Normal 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),
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>)}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
44
src/photo/image-response/DeviceImageResponse.tsx
Normal file
44
src/photo/image-response/DeviceImageResponse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user