Create core lens pages

This commit is contained in:
Sam Becker 2025-03-16 11:56:21 -05:00
parent bb2c8dddc6
commit ee265f1f33
27 changed files with 655 additions and 101 deletions

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

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

View 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 }} />
);
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 />&ndash; {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>,
]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(*)

View File

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

View File

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

View File

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