Create core lens pages
This commit is contained in:
parent
bb2c8dddc6
commit
ee265f1f33
93
app/lens/[make]/[model]/[photoId]/page.tsx
Normal file
93
app/lens/[make]/[model]/[photoId]/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import {
|
||||
RELATED_GRID_PHOTOS_TO_SHOW,
|
||||
descriptionForPhoto,
|
||||
titleForPhoto,
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next/types';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
PATH_ROOT,
|
||||
absolutePathForPhoto,
|
||||
absolutePathForPhotoImage,
|
||||
} from '@/app/paths';
|
||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||
import {
|
||||
getPhotosMetaCached,
|
||||
getPhotosNearIdCached,
|
||||
} from '@/photo/cache';
|
||||
import { cache } from 'react';
|
||||
import { getLensFromParams, lensFromPhoto, PhotoLensProps } from '@/lens';
|
||||
|
||||
const getPhotosNearIdCachedCached = cache((
|
||||
photoId: string,
|
||||
make: string,
|
||||
model: string,
|
||||
) =>
|
||||
getPhotosNearIdCached(
|
||||
photoId, {
|
||||
lens: getLensFromParams({ make, model }),
|
||||
limit: RELATED_GRID_PHOTOS_TO_SHOW + 2,
|
||||
},
|
||||
));
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PhotoLensProps): Promise<Metadata> {
|
||||
const { photoId, make, model } = await params;
|
||||
|
||||
const { photo } = await getPhotosNearIdCachedCached(photoId, make, model);
|
||||
|
||||
if (!photo) { return {}; }
|
||||
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const images = absolutePathForPhotoImage(photo);
|
||||
const url = absolutePathForPhoto({
|
||||
photo,
|
||||
lens: lensFromPhoto(photo, { make, model }),
|
||||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
images,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoLensPage({
|
||||
params,
|
||||
}: PhotoLensProps) {
|
||||
const { photoId, make, model } = await params;
|
||||
|
||||
const { photo, photos, photosGrid, indexNumber } =
|
||||
await getPhotosNearIdCachedCached(photoId, make, model);
|
||||
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const lens = lensFromPhoto(photo, { make, model });
|
||||
|
||||
const { count, dateRange } = await getPhotosMetaCached({ lens });
|
||||
|
||||
return (
|
||||
<PhotoDetailPage {...{
|
||||
photo,
|
||||
photos,
|
||||
photosGrid,
|
||||
lens,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
61
app/lens/[make]/[model]/image/route.tsx
Normal file
61
app/lens/[make]/[model]/image/route.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import {
|
||||
IMAGE_OG_DIMENSION_SMALL,
|
||||
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
} from '@/image-response';
|
||||
import { getIBMPlexMono } from '@/app/font';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
|
||||
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
|
||||
import { getUniqueLenses } from '@/photo/db/query';
|
||||
import {
|
||||
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
|
||||
IS_PRODUCTION,
|
||||
} from '@/app/config';
|
||||
import { getLensFromParams, Lens, LensProps } from '@/lens';
|
||||
import LensImageResponse from '@/image-response/LensImageResponse';
|
||||
|
||||
export let generateStaticParams:
|
||||
(() => Promise<{ lens: Lens }[]>) | undefined = undefined;
|
||||
|
||||
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES && IS_PRODUCTION) {
|
||||
generateStaticParams = async () => {
|
||||
const lenses = await getUniqueLenses();
|
||||
return lenses
|
||||
.slice(0, GENERATE_STATIC_PARAMS_LIMIT)
|
||||
.map(({ lens }) => ({ lens }));
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_: Request,
|
||||
context: LensProps,
|
||||
) {
|
||||
const lens = getLensFromParams(await context.params);
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
lens: lens,
|
||||
}),
|
||||
getIBMPlexMono(),
|
||||
getImageResponseCacheControlHeaders(),
|
||||
]);
|
||||
|
||||
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
|
||||
|
||||
return new ImageResponse(
|
||||
<LensImageResponse {...{
|
||||
lens,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}}/>,
|
||||
{ width, height, fonts, headers },
|
||||
);
|
||||
}
|
||||
80
app/lens/[make]/[model]/page.tsx
Normal file
80
app/lens/[make]/[model]/page.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { Metadata } from 'next/types';
|
||||
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
|
||||
import { cache } from 'react';
|
||||
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config';
|
||||
import { IS_PRODUCTION } from '@/app/config';
|
||||
import { getUniqueLenses } from '@/photo/db/query';
|
||||
import { generateMetaForLens } from '@/lens/meta';
|
||||
import { getPhotosLensDataCached } from '@/lens/data';
|
||||
import LensOverview from '@/lens/LensOverview';
|
||||
import { LensProps } from '@/lens';
|
||||
|
||||
const getPhotosLensDataCachedCached = cache((
|
||||
make: string,
|
||||
model: string,
|
||||
) => getPhotosLensDataCached(
|
||||
make,
|
||||
model,
|
||||
INFINITE_SCROLL_GRID_INITIAL,
|
||||
));
|
||||
|
||||
export let generateStaticParams:
|
||||
(() => Promise<{ make: string, model: string }[]>) | undefined = undefined;
|
||||
|
||||
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
|
||||
generateStaticParams = async () => {
|
||||
const lenses = await getUniqueLenses();
|
||||
return lenses.map(({ lens: { make, model } }) => ({ make, model }));
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: LensProps): Promise<Metadata> {
|
||||
const { make, model } = await params;
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
lens,
|
||||
] = await getPhotosLensDataCachedCached(make, model);
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
} = generateMetaForLens(lens, photos, count, dateRange);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
images,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LensPage({
|
||||
params,
|
||||
}: LensProps) {
|
||||
const { make, model } = await params;
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ count, dateRange },
|
||||
lens,
|
||||
] = await getPhotosLensDataCachedCached(make, model);
|
||||
|
||||
return (
|
||||
<LensOverview {...{ lens, photos, count, dateRange }} />
|
||||
);
|
||||
}
|
||||
@ -163,6 +163,9 @@ export const absolutePathForTag = (tag: string) =>
|
||||
export const absolutePathForCamera= (camera: Camera) =>
|
||||
`${BASE_URL}${pathForCamera(camera)}`;
|
||||
|
||||
export const absolutePathForLens= (lens: Lens) =>
|
||||
`${BASE_URL}${pathForLens(lens)}`;
|
||||
|
||||
export const absolutePathForFilmSimulation = (simulation: FilmSimulation) =>
|
||||
`${BASE_URL}${pathForFilmSimulation(simulation)}`;
|
||||
|
||||
@ -181,6 +184,9 @@ export const absolutePathForTagImage = (tag: string) =>
|
||||
export const absolutePathForCameraImage= (camera: Camera) =>
|
||||
`${absolutePathForCamera(camera)}/image`;
|
||||
|
||||
export const absolutePathForLensImage= (lens: Lens) =>
|
||||
`${absolutePathForLens(lens)}/image`;
|
||||
|
||||
export const absolutePathForFilmSimulationImage =
|
||||
(simulation: FilmSimulation) =>
|
||||
`${absolutePathForFilmSimulation(simulation)}/image`;
|
||||
|
||||
47
src/image-response/LensImageResponse.tsx
Normal file
47
src/image-response/LensImageResponse.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Photo } from '../photo';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import { NextImageSize } from '@/platforms/next-image';
|
||||
import { formatLensText, Lens, lensFromPhoto } from '@/lens';
|
||||
import { RiCameraLensLine } from 'react-icons/ri';
|
||||
|
||||
export default function LensImageResponse({
|
||||
lens: lensProp,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}: {
|
||||
lens: Lens
|
||||
photos: Photo[]
|
||||
width: NextImageSize
|
||||
height: number
|
||||
fontFamily: string
|
||||
}) {
|
||||
const lens = lensFromPhoto(photos[0], lensProp);
|
||||
return (
|
||||
<ImageContainer solidBackground={photos.length === 0}>
|
||||
<ImagePhotoGrid
|
||||
{...{
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <RiCameraLensLine
|
||||
size={height * .079}
|
||||
style={{
|
||||
marginRight: height * .015,
|
||||
marginTop: height * .003,
|
||||
}}
|
||||
/>,
|
||||
title: formatLensText(lens).toLocaleUpperCase(),
|
||||
}} />
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
37
src/lens/LensHeader.tsx
Normal file
37
src/lens/LensHeader.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { Lens, lensFromPhoto } from '.';
|
||||
import PhotoLens from './PhotoLens';
|
||||
import { descriptionForLensPhotos } from './meta';
|
||||
|
||||
export default function LensHeader({
|
||||
lens: lensProp,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
lens: Lens
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
const lens = lensFromPhoto(photos[0], lensProp);
|
||||
return (
|
||||
<PhotoHeader
|
||||
lens={lens}
|
||||
entity={<PhotoLens {...{ lens }} contrast="high" />}
|
||||
entityDescription={
|
||||
descriptionForLensPhotos(photos, undefined, count, dateRange)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
includeShareButton
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
src/lens/LensOGTile.tsx
Normal file
43
src/lens/LensOGTile.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
|
||||
import OGTile from '@/components/OGTile';
|
||||
import { Lens } from '.';
|
||||
import { titleForLens, descriptionForLensPhotos } from './meta';
|
||||
|
||||
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
|
||||
|
||||
export default function LensOGTile({
|
||||
lens,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
lens: Lens
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
return (
|
||||
<OGTile {...{
|
||||
title: titleForLens(lens, photos, count),
|
||||
description: descriptionForLensPhotos(photos, true, count, dateRange),
|
||||
path: pathForLens(lens),
|
||||
pathImageAbsolute: absolutePathForLensImage(lens),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
34
src/lens/LensOverview.tsx
Normal file
34
src/lens/LensOverview.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { Lens, createLensKey } from '.';
|
||||
import LensHeader from './LensHeader';
|
||||
import PhotoGridContainer from '@/photo/PhotoGridContainer';
|
||||
|
||||
export default function LensOverview({
|
||||
lens,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
animateOnFirstLoadOnly,
|
||||
}: {
|
||||
lens: Lens,
|
||||
photos: Photo[],
|
||||
count: number,
|
||||
dateRange?: PhotoDateRange,
|
||||
animateOnFirstLoadOnly?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<PhotoGridContainer {...{
|
||||
cacheKey: `lens-${createLensKey(lens)}`,
|
||||
photos,
|
||||
count,
|
||||
lens,
|
||||
animateOnFirstLoadOnly,
|
||||
header: <LensHeader {...{
|
||||
lens,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}} />,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
24
src/lens/LensShareModal.tsx
Normal file
24
src/lens/LensShareModal.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { absolutePathForLens } from '@/app/paths';
|
||||
import { PhotoSetAttributes } from '../photo/set';
|
||||
import ShareModal from '@/share/ShareModal';
|
||||
import { Lens } from '.';
|
||||
import { shareTextForLens } from './meta';
|
||||
import LensOGTile from './LensOGTile';
|
||||
|
||||
export default function LensShareModal({
|
||||
lens,
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
lens: Lens
|
||||
} & PhotoSetAttributes) {
|
||||
return (
|
||||
<ShareModal
|
||||
pathShare={absolutePathForLens(lens)}
|
||||
socialText={shareTextForLens(lens, photos)}
|
||||
>
|
||||
<LensOGTile {...{ lens, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
);
|
||||
};
|
||||
@ -15,12 +15,11 @@ export default function PhotoLens({
|
||||
className,
|
||||
}: {
|
||||
lens: Lens
|
||||
hideAppleIcon?: boolean
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
label={formatLensText(lens)}
|
||||
label={formatLensText(lens, 'short')}
|
||||
href={pathForLens(lens)}
|
||||
icon={<RiCameraLensLine
|
||||
size={14}
|
||||
|
||||
22
src/lens/data.ts
Normal file
22
src/lens/data.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { getLensFromParams, lensFromPhoto } from '.';
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosMetaCached,
|
||||
} from '@/photo/cache';
|
||||
|
||||
export const getPhotosLensDataCached = async (
|
||||
make: string,
|
||||
model: string,
|
||||
limit: number,
|
||||
) => {
|
||||
const lens = getLensFromParams({ make, model });
|
||||
return Promise.all([
|
||||
getPhotosCached({ lens, limit }),
|
||||
getPhotosMetaCached({ lens }),
|
||||
])
|
||||
.then(([photos, meta]) => [
|
||||
photos,
|
||||
meta,
|
||||
lensFromPhoto(photos[0], lens),
|
||||
] as const);
|
||||
};
|
||||
@ -26,8 +26,9 @@ export type LensWithCount = {
|
||||
|
||||
export type Lenses = LensWithCount[];
|
||||
|
||||
export const createLensKey = ({ make, model }: Lens) =>
|
||||
parameterize(`${make}-${model}`, true);
|
||||
// Support keys for make-only and model-only lens queries
|
||||
export const createLensKey = ({ make, model }: Partial<Lens>) =>
|
||||
parameterize(`${make ?? 'ANY'}-${model ?? 'ANY'}`, true);
|
||||
|
||||
export const getLensFromParams = ({
|
||||
make,
|
||||
@ -54,5 +55,28 @@ const isLensMakeApple = (make?: string) =>
|
||||
export const isLensApple = ({ make }: Lens) =>
|
||||
isLensMakeApple(make);
|
||||
|
||||
export const formatLensText = ({ make, model }: Lens, short = true) =>
|
||||
short ? model : `${make} ${model}`;
|
||||
export const formatLensText = (
|
||||
{ make, model: modelRaw }: Lens,
|
||||
length:
|
||||
'long' | // Unmodified make and model
|
||||
'medium' | // Make and model, with modifiers removed
|
||||
'short' // Model only
|
||||
= 'medium',
|
||||
) => {
|
||||
// Capture simple make without modifiers like 'Corporation' or 'Company'
|
||||
const makeSimple = make.match(/^(\S+)/)?.[1];
|
||||
const doesModelStartWithMake = (
|
||||
makeSimple &&
|
||||
modelRaw.toLocaleLowerCase().startsWith(makeSimple.toLocaleLowerCase())
|
||||
);
|
||||
const model = modelRaw;
|
||||
switch (length) {
|
||||
case 'long':
|
||||
case 'medium':
|
||||
return `${make} ${model}`;
|
||||
case 'short':
|
||||
return doesModelStartWithMake
|
||||
? model.replace(makeSimple, '').trim()
|
||||
: model;
|
||||
}
|
||||
};
|
||||
|
||||
61
src/lens/meta.ts
Normal file
61
src/lens/meta.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {
|
||||
Photo,
|
||||
PhotoDateRange,
|
||||
descriptionForPhotoSet,
|
||||
photoQuantityText,
|
||||
} from '@/photo';
|
||||
import { Lens, lensFromPhoto, formatLensText } from '.';
|
||||
import {
|
||||
absolutePathForLens,
|
||||
absolutePathForLensImage,
|
||||
} from '@/app/paths';
|
||||
|
||||
// Meta functions moved to separate file to avoid
|
||||
// dependencies (camelcase-keys) found in photo/index.ts
|
||||
// which cause Jest to crash
|
||||
|
||||
export const titleForLens = (
|
||||
lens: Lens,
|
||||
photos: Photo[],
|
||||
explicitCount?: number,
|
||||
) => [
|
||||
'Shot on',
|
||||
formatLensText(lensFromPhoto(photos[0], lens)),
|
||||
photoQuantityText(explicitCount ?? photos.length),
|
||||
].join(' ');
|
||||
|
||||
export const shareTextForLens = (
|
||||
lens: Lens,
|
||||
photos: Photo[],
|
||||
) =>
|
||||
[
|
||||
'Photos shot on',
|
||||
formatLensText(lensFromPhoto(photos[0], lens)),
|
||||
].join(' ');
|
||||
|
||||
export const descriptionForLensPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
) =>
|
||||
descriptionForPhotoSet(
|
||||
photos,
|
||||
undefined,
|
||||
dateBased,
|
||||
explicitCount,
|
||||
explicitDateRange,
|
||||
);
|
||||
|
||||
export const generateMetaForLens = (
|
||||
lens: Lens,
|
||||
photos: Photo[],
|
||||
explicitCount?: number,
|
||||
explicitDateRange?: PhotoDateRange,
|
||||
) => ({
|
||||
url: absolutePathForLens(lens),
|
||||
title: titleForLens(lens, photos, explicitCount),
|
||||
description:
|
||||
descriptionForLensPhotos(photos, true, explicitCount, explicitDateRange),
|
||||
images: absolutePathForLensImage(lens),
|
||||
});
|
||||
@ -31,6 +31,7 @@ export default function InfinitePhotoScroll({
|
||||
sortBy,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
wrapMoreButtonInGrid,
|
||||
useCachedPhotos = true,
|
||||
@ -69,8 +70,9 @@ export default function InfinitePhotoScroll({
|
||||
sortBy,
|
||||
limit: itemsPerPage,
|
||||
hidden: includeHiddenPhotos ? 'include' : 'exclude',
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
simulation,
|
||||
}, warmOnly)
|
||||
, [
|
||||
@ -79,8 +81,9 @@ export default function InfinitePhotoScroll({
|
||||
initialOffset,
|
||||
itemsPerPage,
|
||||
includeHiddenPhotos,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
simulation,
|
||||
]);
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import FocalLengthHeader from '@/focal/FocalLengthHeader';
|
||||
import PhotoHeader from './PhotoHeader';
|
||||
import RecipeHeader from '@/recipe/RecipeHeader';
|
||||
import { ReactNode } from 'react';
|
||||
import LensHeader from '@/lens/LensHeader';
|
||||
|
||||
export default function PhotoDetailPage({
|
||||
photo,
|
||||
@ -20,6 +21,7 @@ export default function PhotoDetailPage({
|
||||
photosGrid,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
recipe,
|
||||
focal,
|
||||
@ -66,6 +68,15 @@ export default function PhotoDetailPage({
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>;
|
||||
} else if (lens) {
|
||||
customHeader = <LensHeader
|
||||
lens={lens}
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>;
|
||||
} else if (simulation) {
|
||||
customHeader = <FilmSimulationHeader
|
||||
simulation={simulation}
|
||||
@ -117,11 +128,13 @@ export default function PhotoDetailPage({
|
||||
showTitle={Boolean(customHeader)}
|
||||
showTitleAsH1
|
||||
showCamera={!camera}
|
||||
showLens={!lens}
|
||||
showSimulation={!simulation}
|
||||
showRecipe={!recipe}
|
||||
shouldShare={shouldShare}
|
||||
shouldShareTag={tag !== undefined}
|
||||
shouldShareCamera={camera !== undefined}
|
||||
shouldShareLens={lens !== undefined}
|
||||
shouldShareTag={tag !== undefined}
|
||||
shouldShareSimulation={simulation !== undefined}
|
||||
shouldShareRecipe={recipe !== undefined}
|
||||
shouldShareFocalLength={focal !== undefined}
|
||||
|
||||
@ -16,6 +16,7 @@ export default function PhotoGrid({
|
||||
selectedPhoto,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
@ -96,6 +97,7 @@ export default function PhotoGrid({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
|
||||
@ -14,6 +14,7 @@ export default function PhotoGridContainer({
|
||||
count,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
@ -51,6 +52,7 @@ export default function PhotoGridContainer({
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
@ -65,6 +67,7 @@ export default function PhotoGridContainer({
|
||||
canStart: shouldAnimateDynamicItems,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
|
||||
@ -19,11 +19,6 @@ import { useAppState } from '@/state/AppState';
|
||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
|
||||
export default function PhotoHeader({
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
entity,
|
||||
@ -33,6 +28,7 @@ export default function PhotoHeader({
|
||||
count,
|
||||
dateRange,
|
||||
includeShareButton,
|
||||
...categories
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
@ -62,25 +58,21 @@ export default function PhotoHeader({
|
||||
? 'photo-detail-with-entity'
|
||||
: 'photo-detail';
|
||||
|
||||
const renderPrevNext = () =>
|
||||
const renderPrevNext =
|
||||
<PhotoPrevNext {...{
|
||||
photo: selectedPhoto,
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
...categories,
|
||||
}} />;
|
||||
|
||||
const renderDateRange = () =>
|
||||
const renderDateRange =
|
||||
<span className="text-dim uppercase text-right">
|
||||
{start === end
|
||||
? start
|
||||
: <>{end}<br />– {start}</>}
|
||||
</span>;
|
||||
|
||||
const renderContentA = () => entity ?? (
|
||||
const renderContentA = entity ?? (
|
||||
selectedPhoto !== undefined &&
|
||||
<PhotoLink
|
||||
photo={selectedPhoto}
|
||||
@ -121,13 +113,13 @@ export default function PhotoHeader({
|
||||
: 'col-span-3 md:col-span-2 lg:col-span-3 w-[110%] xl:w-full',
|
||||
)}>
|
||||
{headerType === 'photo-detail-with-entity'
|
||||
? renderContentA()
|
||||
? renderContentA
|
||||
// Necessary for title truncation
|
||||
: <h1 className={clsx(
|
||||
'w-full truncate',
|
||||
headerType !== 'photo-detail' && 'pr-1 sm:pr-2',
|
||||
)}>
|
||||
{renderContentA()}
|
||||
{renderContentA}
|
||||
</h1>}
|
||||
</div>
|
||||
{/* Content B: Filter Set Meta or Photo Pagination */}
|
||||
@ -149,19 +141,15 @@ export default function PhotoHeader({
|
||||
? <>
|
||||
{entityDescription}
|
||||
{includeShareButton &&
|
||||
<ShareButton
|
||||
photos={photos}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
simulation={simulation}
|
||||
recipe={recipe}
|
||||
focal={focal}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
className="translate-y-[1.5px]"
|
||||
prefetch
|
||||
dim
|
||||
/>}
|
||||
<ShareButton {...{
|
||||
photos,
|
||||
...categories,
|
||||
count,
|
||||
dateRange,
|
||||
className: 'translate-y-[1.5px]',
|
||||
prefetch: true,
|
||||
dim: true,
|
||||
}} />}
|
||||
</>
|
||||
: <ResponsiveText shortText={paginationLabel}>
|
||||
{entityVerb} {paginationLabel}
|
||||
@ -176,8 +164,8 @@ export default function PhotoHeader({
|
||||
'justify-end',
|
||||
)}>
|
||||
{selectedPhoto
|
||||
? renderPrevNext()
|
||||
: renderDateRange()}
|
||||
? renderPrevNext
|
||||
: renderDateRange}
|
||||
</div>
|
||||
</DivDebugBaselineGrid>,
|
||||
]}
|
||||
|
||||
@ -65,8 +65,9 @@ export default function PhotoLarge({
|
||||
showZoomControls: showZoomControlsProp = true,
|
||||
shouldZoomOnFKeydown = true,
|
||||
shouldShare = true,
|
||||
shouldShareTag,
|
||||
shouldShareCamera,
|
||||
shouldShareLens,
|
||||
shouldShareTag,
|
||||
shouldShareSimulation,
|
||||
shouldShareRecipe,
|
||||
shouldShareFocalLength,
|
||||
@ -89,8 +90,9 @@ export default function PhotoLarge({
|
||||
showZoomControls?: boolean
|
||||
shouldZoomOnFKeydown?: boolean
|
||||
shouldShare?: boolean
|
||||
shouldShareTag?: boolean
|
||||
shouldShareCamera?: boolean
|
||||
shouldShareLens?: boolean
|
||||
shouldShareTag?: boolean
|
||||
shouldShareSimulation?: boolean
|
||||
shouldShareRecipe?: boolean
|
||||
shouldShareFocalLength?: boolean
|
||||
@ -280,21 +282,20 @@ export default function PhotoLarge({
|
||||
showTagsContent
|
||||
) &&
|
||||
<div>
|
||||
{showCameraContent &&
|
||||
<PhotoCamera
|
||||
camera={camera}
|
||||
contrast="medium"
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>}
|
||||
{showLensContent &&
|
||||
<>
|
||||
<br />
|
||||
<PhotoLens
|
||||
lens={lens}
|
||||
{(showCameraContent || showLensContent) &&
|
||||
<div className="flex flex-col">
|
||||
<PhotoCamera
|
||||
camera={camera}
|
||||
contrast="medium"
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>
|
||||
</>}
|
||||
{showLensContent &&
|
||||
<PhotoLens
|
||||
lens={lens}
|
||||
contrast="medium"
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>}
|
||||
</div>}
|
||||
{showRecipeContent && recipeTitle &&
|
||||
<PhotoRecipe
|
||||
recipe={recipeTitle}
|
||||
@ -426,6 +427,7 @@ export default function PhotoLarge({
|
||||
photo={photo}
|
||||
tag={shouldShareTag ? primaryTag : undefined}
|
||||
camera={shouldShareCamera ? camera : undefined}
|
||||
lens={shouldShareLens ? lens : undefined}
|
||||
simulation={shouldShareSimulation
|
||||
? photo.filmSimulation
|
||||
: undefined}
|
||||
|
||||
@ -17,16 +17,12 @@ import Spinner from '@/components/Spinner';
|
||||
|
||||
export default function PhotoMedium({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
selected,
|
||||
priority,
|
||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||
className,
|
||||
onVisible,
|
||||
...categories
|
||||
}: {
|
||||
photo: Photo
|
||||
selected?: boolean
|
||||
@ -42,7 +38,7 @@ export default function PhotoMedium({
|
||||
return (
|
||||
<LinkWithStatus
|
||||
ref={ref}
|
||||
href={pathForPhoto({ photo, tag, camera, simulation, focal, recipe })}
|
||||
href={pathForPhoto({ photo, ...categories })}
|
||||
className={clsx(
|
||||
'active:brightness-75',
|
||||
selected && 'brightness-50',
|
||||
|
||||
@ -14,14 +14,11 @@ import useVisible from '@/utility/useVisible';
|
||||
|
||||
export default function PhotoSmall({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
selected,
|
||||
className,
|
||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||
onVisible,
|
||||
...categories
|
||||
}: {
|
||||
photo: Photo
|
||||
selected?: boolean
|
||||
@ -36,7 +33,7 @@ export default function PhotoSmall({
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
|
||||
href={pathForPhoto({ photo, ...categories })}
|
||||
className={clsx(
|
||||
className,
|
||||
'active:brightness-75',
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
PREFIX_CAMERA,
|
||||
PREFIX_FILM_SIMULATION,
|
||||
PREFIX_FOCAL_LENGTH,
|
||||
PREFIX_LENS,
|
||||
PREFIX_RECIPE,
|
||||
PREFIX_TAG,
|
||||
pathForPhoto,
|
||||
@ -105,6 +106,9 @@ export const revalidateRecipesKey = () =>
|
||||
export const revalidateCamerasKey = () =>
|
||||
revalidateTag(KEY_CAMERAS);
|
||||
|
||||
export const revalidateLensesKey = () =>
|
||||
revalidateTag(KEY_LENSES);
|
||||
|
||||
export const revalidateFilmSimulationsKey = () =>
|
||||
revalidateTag(KEY_FILM_SIMULATIONS);
|
||||
|
||||
@ -115,6 +119,7 @@ export const revalidateAllKeys = () => {
|
||||
revalidatePhotosKey();
|
||||
revalidateTagsKey();
|
||||
revalidateCamerasKey();
|
||||
revalidateLensesKey();
|
||||
revalidateFilmSimulationsKey();
|
||||
revalidateRecipesKey();
|
||||
revalidateFocalLengthsKey();
|
||||
@ -134,6 +139,7 @@ export const revalidatePhoto = (photoId: string) => {
|
||||
revalidateTag(photoId);
|
||||
revalidateTagsKey();
|
||||
revalidateCamerasKey();
|
||||
revalidateLensesKey();
|
||||
revalidateFilmSimulationsKey();
|
||||
revalidateRecipesKey();
|
||||
revalidateFocalLengthsKey();
|
||||
@ -144,6 +150,7 @@ export const revalidatePhoto = (photoId: string) => {
|
||||
revalidatePath(PATH_FEED, 'layout');
|
||||
revalidatePath(PREFIX_TAG, 'layout');
|
||||
revalidatePath(PREFIX_CAMERA, 'layout');
|
||||
revalidatePath(PREFIX_LENS, 'layout');
|
||||
revalidatePath(PREFIX_FILM_SIMULATION, 'layout');
|
||||
revalidatePath(PREFIX_RECIPE, 'layout');
|
||||
revalidatePath(PREFIX_FOCAL_LENGTH, 'layout');
|
||||
|
||||
@ -2,6 +2,7 @@ import { PRIORITY_ORDER_ENABLED } from '@/app/config';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { PhotoSetCategory } from '../set';
|
||||
import { Camera } from '@/camera';
|
||||
import { Lens } from '@/lens';
|
||||
|
||||
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||
@ -23,8 +24,9 @@ export type GetPhotosOptions = {
|
||||
takenAfterInclusive?: Date
|
||||
updatedBefore?: Date
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
} & Omit<PhotoSetCategory, 'camera'> & {
|
||||
} & Omit<PhotoSetCategory, 'camera' | 'lens'> & {
|
||||
camera?: Partial<Camera>
|
||||
lens?: Partial<Lens>
|
||||
};
|
||||
|
||||
export const areOptionsSensitive = (options: GetPhotosOptions) =>
|
||||
@ -83,10 +85,6 @@ export const getWheresFromOptions = (
|
||||
wheres.push(`aspect_ratio <= $${valuesIndex++}`);
|
||||
wheresValues.push(maximumAspectRatio);
|
||||
}
|
||||
if (tag) {
|
||||
wheres.push(`$${valuesIndex++}=ANY(tags)`);
|
||||
wheresValues.push(tag);
|
||||
}
|
||||
if (camera?.make) {
|
||||
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(camera.make, true));
|
||||
@ -95,12 +93,18 @@ export const getWheresFromOptions = (
|
||||
wheres.push(`${parameterizeForDb('model')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(camera.model, true));
|
||||
}
|
||||
if (lens) {
|
||||
if (lens?.make) {
|
||||
wheres.push(`${parameterizeForDb('lens_make')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(lens.make, true));
|
||||
}
|
||||
if (lens?.model) {
|
||||
wheres.push(`${parameterizeForDb('lens_model')}=$${valuesIndex++}`);
|
||||
wheresValues.push(parameterize(lens.model, true));
|
||||
}
|
||||
if (tag) {
|
||||
wheres.push(`$${valuesIndex++}=ANY(tags)`);
|
||||
wheresValues.push(tag);
|
||||
}
|
||||
if (simulation) {
|
||||
wheres.push(`film_simulation=$${valuesIndex++}`);
|
||||
wheresValues.push(simulation);
|
||||
|
||||
@ -334,6 +334,24 @@ export const getUniqueCameras = async () =>
|
||||
})))
|
||||
, 'getUniqueCameras');
|
||||
|
||||
export const getUniqueLenses = async () =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
SELECT DISTINCT lens_make||' '||lens_model as lens,
|
||||
lens_make, lens_model, COUNT(*)
|
||||
FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
AND trim(lens_make) <> ''
|
||||
AND trim(lens_model) <> ''
|
||||
GROUP BY lens_make, lens_model
|
||||
ORDER BY lens ASC
|
||||
`.then(({ rows }): Lenses => rows
|
||||
.map(({ lens_make: make, lens_model: model, count }) => ({
|
||||
lensKey: createLensKey({ make, model }),
|
||||
lens: { make, model },
|
||||
count: parseInt(count, 10),
|
||||
})))
|
||||
, 'getUniqueLenses');
|
||||
|
||||
export const getUniqueRecipes = async () =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
SELECT DISTINCT recipe_title, COUNT(*)
|
||||
@ -405,24 +423,6 @@ export const getUniqueFilmSimulations = async () =>
|
||||
})))
|
||||
, 'getUniqueFilmSimulations');
|
||||
|
||||
export const getUniqueLenses = async () =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
SELECT DISTINCT lens_make||' '||lens_model as lens,
|
||||
lens_make, lens_model, COUNT(*)
|
||||
FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
AND trim(lens_make) <> ''
|
||||
AND trim(lens_model) <> ''
|
||||
GROUP BY lens_make, lens_model
|
||||
ORDER BY lens ASC
|
||||
`.then(({ rows }): Lenses => rows
|
||||
.map(({ lens_make: make, lens_model: model, count }) => ({
|
||||
lensKey: createLensKey({ make, model }),
|
||||
lens: { make, model },
|
||||
count: parseInt(count, 10),
|
||||
})))
|
||||
, 'getUniqueLenses');
|
||||
|
||||
export const getUniqueFocalLengths = async () =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
SELECT DISTINCT focal_length, COUNT(*)
|
||||
|
||||
@ -31,12 +31,12 @@ export const getHiddenDefaultCategories = (keys: CategoryKeys): CategoryKeys =>
|
||||
DEFAULT_CATEGORY_KEYS.filter(key => !keys.includes(key));
|
||||
|
||||
export interface PhotoSetCategory {
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
lens?: Lens
|
||||
tag?: string
|
||||
recipe?: string
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
lens?: Lens // Unimplemented as a set
|
||||
}
|
||||
|
||||
export interface PhotoSetCategories {
|
||||
|
||||
@ -7,6 +7,7 @@ import FilmSimulationShareModal from '@/simulation/FilmSimulationShareModal';
|
||||
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
||||
import LensShareModal from '@/lens/LensShareModal';
|
||||
|
||||
export default function ShareModals() {
|
||||
const { shareModalProps = {} } = useAppState();
|
||||
@ -16,8 +17,9 @@ export default function ShareModals() {
|
||||
photos,
|
||||
count,
|
||||
dateRange,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
simulation,
|
||||
recipe,
|
||||
focal,
|
||||
@ -38,6 +40,8 @@ export default function ShareModals() {
|
||||
return <TagShareModal {...{ tag, ...attributes }} />;
|
||||
} else if (camera) {
|
||||
return <CameraShareModal {...{ camera, ...attributes }} />;
|
||||
} else if (lens) {
|
||||
return <LensShareModal {...{ lens, ...attributes }} />;
|
||||
} else if (simulation) {
|
||||
return <FilmSimulationShareModal {...{ simulation, ...attributes }} />;
|
||||
} else if (recipe) {
|
||||
|
||||
@ -4,7 +4,9 @@ import {
|
||||
absolutePathForCameraImage,
|
||||
absolutePathForFilmSimulationImage,
|
||||
absolutePathForFocalLengthImage,
|
||||
absolutePathForLensImage,
|
||||
absolutePathForPhotoImage,
|
||||
absolutePathForRecipeImage,
|
||||
absolutePathForTagImage,
|
||||
} from '@/app/paths';
|
||||
|
||||
@ -15,24 +17,26 @@ export type ShareModalProps = Omit<PhotoSetAttributes, 'photos'> & {
|
||||
|
||||
export const getSharePathFromShareModalProps = ({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
recipe,
|
||||
simulation,
|
||||
focal,
|
||||
}: ShareModalProps) => {
|
||||
if (photo) {
|
||||
return absolutePathForPhotoImage(photo);
|
||||
}
|
||||
if (tag) {
|
||||
return absolutePathForTagImage(tag);
|
||||
}
|
||||
if (camera) {
|
||||
} else if (camera) {
|
||||
return absolutePathForCameraImage(camera);
|
||||
}
|
||||
if (simulation) {
|
||||
} else if (lens) {
|
||||
return absolutePathForLensImage(lens);
|
||||
} else if (tag) {
|
||||
return absolutePathForTagImage(tag);
|
||||
} else if (recipe) {
|
||||
return absolutePathForRecipeImage(recipe);
|
||||
} else if (simulation) {
|
||||
return absolutePathForFilmSimulationImage(simulation);
|
||||
}
|
||||
if (focal) {
|
||||
} else if (focal) {
|
||||
return absolutePathForFocalLengthImage(focal);
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user