Refactor recipe schema and pages

This commit is contained in:
Sam Becker 2025-03-03 19:43:08 -06:00
parent 5d6e00559f
commit 1d20cb58b2
27 changed files with 423 additions and 255 deletions

View File

@ -1,28 +0,0 @@
import SiteGrid from '@/components/SiteGrid';
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
export default async function AdminRecipePage({
params,
}: {
params: Promise<{ photoId: string }>
}) {
const { photoId } = await params;
const photo = await getPhoto(photoId);
const photosHidden = await getPhotos({ hidden: 'only' });
const { filmSimulation } = photo!;
const { fujifilmRecipe } = photosHidden[0];
return (
<SiteGrid
contentMain={photo && fujifilmRecipe && filmSimulation
? <PhotoRecipeOverlay
photos={[photo]}
recipe={fujifilmRecipe}
/>
: <div>
Can&apos;t find photo/recipe
</div>}
/>
);
}

View File

@ -1,33 +0,0 @@
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
export default async function AdminRecipePage() {
const [
photos,
photo1,
photo2,
photo3,
photo4,
photosHidden,
] = await Promise.all([
getPhotos({ tag: 'favs' }),
getPhoto('4zT6dgPr'),
getPhoto('9MopluBn'),
getPhoto('ifv8zq45'),
getPhoto('2BO2YoW6'),
getPhotos({ hidden: 'only', limit: 1 }),
]);
const { fujifilmRecipe } = photosHidden[0];
return (
<PhotoRecipeOverlay
photos={[
...photos,
photo1!,
photo2!,
photo3!,
photo4!,
]}
recipe={fujifilmRecipe!}
/>
);
}

View File

@ -7,6 +7,8 @@ import { getPhotosFilmSimulationDataCached } from '@/simulation/data';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config'; import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import { cache } from 'react'; import { cache } from 'react';
import { PATH_ROOT } from '@/app/paths';
import { redirect } from 'next/navigation';
const getPhotosFilmSimulationDataCachedCached = const getPhotosFilmSimulationDataCachedCached =
cache(getPhotosFilmSimulationDataCached); cache(getPhotosFilmSimulationDataCached);
@ -38,6 +40,8 @@ export async function generateMetadata({
limit: INFINITE_SCROLL_GRID_INITIAL, limit: INFINITE_SCROLL_GRID_INITIAL,
}); });
if (photos.length === 0) { return {}; }
const { const {
url, url,
title, title,
@ -75,6 +79,8 @@ export default async function FilmSimulationPage({
limit: INFINITE_SCROLL_GRID_INITIAL, limit: INFINITE_SCROLL_GRID_INITIAL,
}); });
if (photos.length === 0) { redirect(PATH_ROOT); }
return ( return (
<FilmSimulationOverview {...{ <FilmSimulationOverview {...{
simulation, simulation,

View File

@ -0,0 +1,90 @@
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 { getPhotosNearIdCached } from '@/photo/cache';
import { cache } from 'react';
import { getPhotosMeta } from '@/photo/db/query';
const getPhotosNearIdCachedCached = cache((
photoId: string,
recipe: string,
) =>
getPhotosNearIdCached(
photoId,
{ recipe, limit: RELATED_GRID_PHOTOS_TO_SHOW + 2 },
));
interface PhotoRecipeProps {
params: Promise<{ photoId: string, recipe: string }>
}
export async function generateMetadata({
params,
}: PhotoRecipeProps): Promise<Metadata> {
const { photoId, recipe: recipeFromParams } = await params;
const recipe = decodeURIComponent(recipeFromParams);
const { photo } = await getPhotosNearIdCachedCached(photoId, recipe);
if (!photo) { return {}; }
const title = titleForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto({ photo, recipe });
return {
title,
description,
openGraph: {
title,
images,
description,
url,
},
twitter: {
title,
description,
images,
card: 'summary_large_image',
},
};
}
export default async function PhotoTagPage({
params,
}: PhotoRecipeProps) {
const { photoId, recipe: recipeFromParams } = await params;
const recipe = decodeURIComponent(recipeFromParams);
const { photo, photos, photosGrid, indexNumber } =
await getPhotosNearIdCachedCached(photoId, recipe);
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } = await getPhotosMeta({ recipe });
return (
<PhotoDetailPage {...{
photo,
photos,
photosGrid,
recipe,
indexNumber,
count,
dateRange,
}} />
);
}

View File

@ -0,0 +1,57 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
} from '@/image-response';
import { getIBMPlexMonoMedium } from '@/app/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { getUniqueRecipes } from '@/photo/db/query';
import {
STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES,
IS_PRODUCTION,
} from '@/app/config';
import RecipeImageResponse from '@/image-response/RecipeImageResponse';
export let generateStaticParams:
(() => Promise<{ recipe: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORY_OG_IMAGES && IS_PRODUCTION) {
generateStaticParams = async () => {
const recipes = await getUniqueRecipes();
return recipes
.slice(0, GENERATE_STATIC_PARAMS_LIMIT)
.map(({ recipe }) => ({ recipe }));
};
}
export async function GET(
_: Request,
context: { params: Promise<{ recipe: string }> },
) {
const { recipe } = await context.params;
const [
photos,
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, recipe }),
getIBMPlexMonoMedium(),
getImageResponseCacheControlHeaders(),
]);
const { width, height } = IMAGE_OG_DIMENSION_SMALL;
return new ImageResponse(
<RecipeImageResponse {...{
recipe,
photos,
width,
height,
fontFamily,
}}/>,
{ width, height, fonts, headers },
);
}

View File

@ -1,27 +1,24 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo'; import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueTags } from '@/photo/db/query'; import { getUniqueRecipes } from '@/photo/db/query';
import { IS_PRODUCTION } from '@/app/config'; import { IS_PRODUCTION } from '@/app/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config'; import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config';
import { PATH_ROOT } from '@/app/paths'; import { PATH_ROOT } from '@/app/paths';
import { getPhotosTagDataCached } from '@/tag/data';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { cache } from 'react'; import { cache } from 'react';
import { convertRecipeToTag, generateMetaForRecipe } from '@/recipe'; import { generateMetaForRecipe } from '@/recipe';
import RecipeOverview from '@/recipe/RecipeOverview'; import RecipeOverview from '@/recipe/RecipeOverview';
import { getPhotosRecipeDataCached } from '@/recipe/data';
const getPhotosTagDataCachedCached = cache((tag: string) => const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached);
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
export let generateStaticParams: export let generateStaticParams:
(() => Promise<{ recipe: string }[]>) | undefined = undefined; (() => Promise<{ recipe: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) { if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => { generateStaticParams = async () => {
const tags = await getUniqueTags(); const recipes = await getUniqueRecipes();
return tags return recipes.map(({ recipe }) => ({ recipe }));
.filter(({ tag }) => tag.startsWith('recipe'))
.map(({ tag }) => ({ recipe: tag.replace(/^recipe-?/i, '')}));
}; };
} }
@ -39,7 +36,10 @@ export async function generateMetadata({
const [ const [
photos, photos,
{ count, dateRange }, { count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe)); ] = await getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { return {}; } if (photos.length === 0) { return {}; }
@ -77,7 +77,10 @@ export default async function RecipePage({
const [ const [
photos, photos,
{ count, dateRange }, { count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe)); ] = await getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { redirect(PATH_ROOT); } if (photos.length === 0) { redirect(PATH_ROOT); }

View File

@ -35,10 +35,6 @@ const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`; const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`; const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
// Search params
export const SEARCH_PARAM_SHOW = 'show';
export const SEARCH_PARAM_SHOW_RECIPE = 'recipe';
// Admin paths // Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`; export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
@ -114,9 +110,8 @@ export const pathForPhoto = ({
simulation, simulation,
focal, focal,
recipe, recipe,
showRecipe, }: PhotoPathParams) =>
}: PhotoPathParams) => { typeof photo !== 'string' && photo.hidden
const path = typeof photo !== 'string' && photo.hidden
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}` ? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
: tag : tag
? `${pathForTag(tag)}/${getPhotoId(photo)}` ? `${pathForTag(tag)}/${getPhotoId(photo)}`
@ -129,10 +124,6 @@ export const pathForPhoto = ({
: recipe : recipe
? `${pathForRecipe(recipe)}/${getPhotoId(photo)}` ? `${pathForRecipe(recipe)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`; : `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
return showRecipe
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
: path;
};
export const pathForTag = (tag: string) => export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`; `${PREFIX_TAG}/${tag}`;

View File

@ -0,0 +1,51 @@
import type { Photo } from '../photo';
import ImageCaption from './components/ImageCaption';
import ImagePhotoGrid from './components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer';
import type { NextImageSize } from '@/platforms/next-image';
import { formatTag } from '@/tag';
import { TbChecklist } from 'react-icons/tb';
export default function RecipeImageResponse({
recipe,
photos,
width,
height,
fontFamily,
}: {
recipe: string,
photos: Photo[]
width: NextImageSize
height: number
fontFamily: string
}) {
return (
<ImageContainer {...{
width,
height,
...photos.length === 0 && { background: 'black' },
}}>
<ImagePhotoGrid
{...{
photos,
width,
height,
}}
/>
<ImageCaption {...{
width,
height,
fontFamily,
icon: <TbChecklist
size={height * .087}
style={{
transform: `translateY(${height * .006}px)`,
marginRight: height * .02,
}}
/>,
}}>
{formatTag(recipe).toLocaleUpperCase()}
</ImageCaption>
</ImageContainer>
);
}

View File

@ -11,6 +11,7 @@ import HiddenHeader from '@/tag/HiddenHeader';
import FocalLengthHeader from '@/focal/FocalLengthHeader'; import FocalLengthHeader from '@/focal/FocalLengthHeader';
import PhotoHeader from './PhotoHeader'; import PhotoHeader from './PhotoHeader';
import { JSX } from 'react'; import { JSX } from 'react';
import RecipeHeader from '@/recipe/RecipeHeader';
export default function PhotoDetailPage({ export default function PhotoDetailPage({
photo, photo,
@ -19,6 +20,7 @@ export default function PhotoDetailPage({
tag, tag,
camera, camera,
simulation, simulation,
recipe,
focal, focal,
indexNumber, indexNumber,
count, count,
@ -72,6 +74,14 @@ export default function PhotoDetailPage({
count={count} count={count}
dateRange={dateRange} dateRange={dateRange}
/>; />;
} else if (recipe) {
customHeader = <RecipeHeader
recipe={recipe}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
/>;
} else if (focal) { } else if (focal) {
customHeader = <FocalLengthHeader customHeader = <FocalLengthHeader
focal={focal} focal={focal}
@ -90,6 +100,7 @@ export default function PhotoDetailPage({
contentMain={customHeader ?? <PhotoHeader contentMain={customHeader ?? <PhotoHeader
selectedPhoto={photo} selectedPhoto={photo}
photos={photos} photos={photos}
recipe={recipe}
/>} />}
/> />
<AnimateItems <AnimateItems
@ -106,10 +117,12 @@ export default function PhotoDetailPage({
showTitleAsH1 showTitleAsH1
showCamera={!camera} showCamera={!camera}
showSimulation={!simulation} showSimulation={!simulation}
showRecipe={!recipe}
shouldShare={shouldShare} shouldShare={shouldShare}
shouldShareTag={tag !== undefined} shouldShareTag={tag !== undefined}
shouldShareCamera={camera !== undefined} shouldShareCamera={camera !== undefined}
shouldShareSimulation={simulation !== undefined} shouldShareSimulation={simulation !== undefined}
shouldShareRecipe={recipe !== undefined}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu} includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
/>, />,
]} ]}

View File

@ -21,8 +21,6 @@ import {
safelyParseFormattedHtml, safelyParseFormattedHtml,
} from '@/utility/html'; } from '@/utility/html';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { convertTagToRecipe, isTagRecipe } from '@/recipe';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function PhotoGridSidebar({ export default function PhotoGridSidebar({
tags, tags,
@ -53,17 +51,6 @@ export default function PhotoGridSidebar({
className="text-icon translate-y-[1px]" className="text-icon translate-y-[1px]"
/>} />}
items={tagsIncludingHidden.map(({ tag, count }) => { items={tagsIncludingHidden.map(({ tag, count }) => {
if (isTagRecipe(tag)) {
return <PhotoRecipe
key={tag}
recipe={convertTagToRecipe(tag)}
countOnHover={count}
type="icon-last"
prefetch={false}
contrast="low"
badged
/>;
} else {
switch (tag) { switch (tag) {
case TAG_FAVS: case TAG_FAVS:
return <FavsTag return <FavsTag
@ -94,7 +81,6 @@ export default function PhotoGridSidebar({
badged badged
/>; />;
} }
}
})} })}
/> />
: null; : null;

View File

@ -22,6 +22,7 @@ export default function PhotoHeader({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
photos, photos,
selectedPhoto, selectedPhoto,
entity, entity,
@ -68,6 +69,7 @@ export default function PhotoHeader({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
}} />; }} />;
const renderDateRange = () => const renderDateRange = () =>

View File

@ -30,7 +30,7 @@ import {
} from '@/app/config'; } from '@/app/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
import { RevalidatePhoto } from './InfinitePhotoScroll'; import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useRef } from 'react'; import { useMemo, useRef } from 'react';
import useVisible from '@/utility/useVisible'; import useVisible from '@/utility/useVisible';
import PhotoDate from './PhotoDate'; import PhotoDate from './PhotoDate';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
@ -42,7 +42,8 @@ import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5'; import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import useRecipeState from '../recipe/useRecipeState'; import useRecipeState from '../recipe/useRecipeState';
import PhotoRecipeGrid from '@/recipe/PhotoRecipeGrid'; import PhotoRecipeOverlay from '@/recipe/PhotoRecipeGrid';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function PhotoLarge({ export default function PhotoLarge({
photo, photo,
@ -56,12 +57,14 @@ export default function PhotoLarge({
showTitleAsH1, showTitleAsH1,
showCamera = true, showCamera = true,
showSimulation = true, showSimulation = true,
showRecipe = true,
showZoomControls: showZoomControlsProp = true, showZoomControls: showZoomControlsProp = true,
shouldZoomOnFKeydown = true, shouldZoomOnFKeydown = true,
shouldShare = true, shouldShare = true,
shouldShareTag, shouldShareTag,
shouldShareCamera, shouldShareCamera,
shouldShareSimulation, shouldShareSimulation,
shouldShareRecipe,
shouldShareFocalLength, shouldShareFocalLength,
includeFavoriteInAdminMenu, includeFavoriteInAdminMenu,
onVisible, onVisible,
@ -77,12 +80,14 @@ export default function PhotoLarge({
showTitleAsH1?: boolean showTitleAsH1?: boolean
showCamera?: boolean showCamera?: boolean
showSimulation?: boolean showSimulation?: boolean
showRecipe?: boolean
showZoomControls?: boolean showZoomControls?: boolean
shouldZoomOnFKeydown?: boolean shouldZoomOnFKeydown?: boolean
shouldShare?: boolean shouldShare?: boolean
shouldShareTag?: boolean shouldShareTag?: boolean
shouldShareCamera?: boolean shouldShareCamera?: boolean
shouldShareSimulation?: boolean shouldShareSimulation?: boolean
shouldShareRecipe?: boolean
shouldShareFocalLength?: boolean shouldShareFocalLength?: boolean
includeFavoriteInAdminMenu?: boolean includeFavoriteInAdminMenu?: boolean
onVisible?: () => void onVisible?: () => void
@ -101,21 +106,26 @@ export default function PhotoLarge({
const showZoomControls = showZoomControlsProp && areZoomControlsShown; const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const refRecipe = useRef<HTMLDivElement>(null); const refRecipe = useRef<HTMLDivElement>(null);
const refRecipeTrigger = useRef<HTMLButtonElement>(null); const refRecipeTitle = useRef<HTMLButtonElement>(null);
const refRecipeButton = useRef<HTMLButtonElement>(null);
const refTriggers = useMemo(() => [refRecipeTitle, refRecipeButton], []);
const { const {
shouldShowRecipe, shouldShowRecipe,
toggleRecipe, toggleRecipe,
hideRecipe, hideRecipe,
} = useRecipeState({ } = useRecipeState({
ref: refRecipe, ref: refRecipe,
refTrigger: refRecipeTrigger, refTriggers,
}); });
const tags = sortTags(photo.tags, primaryTag); const tags = sortTags(photo.tags, primaryTag);
const camera = cameraFromPhoto(photo); const camera = cameraFromPhoto(photo);
const { recipeTitle: recipe } = photo;
const showCameraContent = showCamera && shouldShowCameraDataForPhoto(photo); const showCameraContent = showCamera && shouldShowCameraDataForPhoto(photo);
const showRecipeContent = showRecipe && recipe;
const showTagsContent = tags.length > 0; const showTagsContent = tags.length > 0;
const showExifContent = shouldShowExifDataForPhoto(photo); const showExifContent = shouldShowExifDataForPhoto(photo);
@ -132,6 +142,7 @@ export default function PhotoLarge({
const hasMetaContent = const hasMetaContent =
showCameraContent || showCameraContent ||
showTagsContent || showTagsContent ||
showRecipeContent ||
showExifContent; showExifContent;
const hasNonDateContent = const hasNonDateContent =
@ -185,11 +196,11 @@ export default function PhotoLarge({
)}> )}>
<AnimatePresence> <AnimatePresence>
{(shouldShowRecipe || shouldDebugRecipeOverlays) && {(shouldShowRecipe || shouldDebugRecipeOverlays) &&
photo.fujifilmRecipe && photo.recipeData &&
photo.filmSimulation && photo.filmSimulation &&
<PhotoRecipeGrid <PhotoRecipeOverlay
ref={refRecipe} ref={refRecipe}
recipe={photo.fujifilmRecipe} recipe={photo.recipeData}
simulation={photo.filmSimulation} simulation={photo.filmSimulation}
iso={photo.isoFormatted} iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted} exposure={photo.exposureCompensationFormatted}
@ -250,7 +261,7 @@ export default function PhotoLarge({
)}> )}>
{photo.caption} {photo.caption}
</div>} </div>}
{(showCameraContent || showTagsContent) && {(showCameraContent || showRecipeContent || showTagsContent) &&
<div> <div>
{showCameraContent && {showCameraContent &&
<PhotoCamera <PhotoCamera
@ -258,6 +269,15 @@ export default function PhotoLarge({
contrast="medium" contrast="medium"
prefetch={prefetchRelatedLinks} prefetch={prefetchRelatedLinks}
/>} />}
{showRecipeContent &&
<PhotoRecipe
refButton={refRecipeTitle}
recipe={recipe}
contrast="medium"
isOpen={shouldShowRecipe}
recipeOnClick={toggleRecipe}
prefetch={prefetchRelatedLinks}
/>}
{showTagsContent && {showTagsContent &&
<PhotoTags <PhotoTags
tags={tags} tags={tags}
@ -270,7 +290,7 @@ export default function PhotoLarge({
{/* EXIF Data */} {/* EXIF Data */}
<div className={clsx( <div className={clsx(
'space-y-baseline', 'space-y-baseline',
!hasTitleContent && 'md:-mt-baseline', !hasTitleContent && !hasMetaContent && 'md:-mt-baseline',
)}> )}>
{showExifContent && {showExifContent &&
<> <>
@ -310,7 +330,7 @@ export default function PhotoLarge({
</ul> </ul>
{( {(
(showSimulation && photo.filmSimulation) || (showSimulation && photo.filmSimulation) ||
(SHOW_RECIPES && photo.fujifilmRecipe) (SHOW_RECIPES && showRecipe && photo.recipeData)
) && ) &&
<div className="flex items-center gap-2 *:w-auto"> <div className="flex items-center gap-2 *:w-auto">
{showSimulation && photo.filmSimulation && {showSimulation && photo.filmSimulation &&
@ -318,9 +338,9 @@ export default function PhotoLarge({
simulation={photo.filmSimulation} simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks} prefetch={prefetchRelatedLinks}
/>} />}
{SHOW_RECIPES && photo.fujifilmRecipe && {SHOW_RECIPES && photo.recipeData &&
<button <button
ref={refRecipeTrigger} ref={refRecipeButton}
title="Fujifilm Recipe" title="Fujifilm Recipe"
onClick={toggleRecipe} onClick={toggleRecipe}
className={clsx( className={clsx(
@ -377,6 +397,9 @@ export default function PhotoLarge({
simulation={shouldShareSimulation simulation={shouldShareSimulation
? photo.filmSimulation ? photo.filmSimulation
: undefined} : undefined}
recipe={shouldShareRecipe
? recipe
: undefined}
focal={shouldShareFocalLength focal={shouldShareFocalLength
? photo.focalLength ? photo.focalLength
: undefined} : undefined}

View File

@ -28,6 +28,7 @@ export default function PhotoPrevNext({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
}: { }: {
photo?: Photo photo?: Photo
photos?: Photo[] photos?: Photo[]
@ -58,6 +59,7 @@ export default function PhotoPrevNext({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
}), }),
{ scroll: false }, { scroll: false },
); );
@ -74,6 +76,7 @@ export default function PhotoPrevNext({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
}), }),
{ scroll: false }, { scroll: false },
); );
@ -94,6 +97,7 @@ export default function PhotoPrevNext({
camera, camera,
simulation, simulation,
focal, focal,
recipe,
]); ]);
return ( return (
@ -114,6 +118,7 @@ export default function PhotoPrevNext({
camera={camera} camera={camera}
simulation={simulation} simulation={simulation}
focal={focal} focal={focal}
recipe={recipe}
scroll={false} scroll={false}
prefetch prefetch
> >
@ -133,6 +138,7 @@ export default function PhotoPrevNext({
camera={camera} camera={camera}
simulation={simulation} simulation={simulation}
focal={focal} focal={focal}
recipe={recipe}
scroll={false} scroll={false}
prefetch prefetch
> >

View File

@ -45,6 +45,7 @@ export const getWheresFromOptions = (
camera, camera,
lens, lens,
simulation, simulation,
recipe,
focal, focal,
} = options; } = options;
@ -104,6 +105,10 @@ export const getWheresFromOptions = (
wheres.push(`film_simulation=$${valuesIndex++}`); wheres.push(`film_simulation=$${valuesIndex++}`);
wheresValues.push(simulation); wheresValues.push(simulation);
} }
if (recipe) {
wheres.push(`recipe_title=$${valuesIndex++}`);
wheresValues.push(recipe);
}
if (focal) { if (focal) {
wheres.push(`focal_length=$${valuesIndex++}`); wheres.push(`focal_length=$${valuesIndex++}`);
wheresValues.push(focal); wheresValues.push(focal);

View File

@ -23,11 +23,32 @@ export const MIGRATIONS: Migration[] = [{
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255) ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
`, `,
}, { }, {
label: '03: Fujifilm Recipe', label: '03: Fujifilm Recipe: Data',
fields: ['fujifilm_recipe'], fields: ['recipe_data'],
run: () => sql`
DO $$
BEGIN
IF EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_name='photos'
AND column_name='fujifilm_recipe'
)
THEN
ALTER TABLE photos
RENAME COLUMN fujifilm_recipe TO recipe_data;
ELSE
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS recipe_data JSONB;
END IF;
END $$;
`,
}, {
label: '04: Fujifilm Recipe: Title',
fields: ['recipe_title'],
run: () => sql` run: () => sql`
ALTER TABLE photos ALTER TABLE photos
ADD COLUMN IF NOT EXISTS fujifilm_recipe JSONB ADD COLUMN IF NOT EXISTS recipe_title VARCHAR(255)
`, `,
}]; }];

View File

@ -26,6 +26,7 @@ import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration'; import { migrationForError } from './migration';
import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated'; import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Recipes } from '@/recipe';
const createPhotosTable = () => const createPhotosTable = () =>
sql` sql`
@ -53,7 +54,8 @@ const createPhotosTable = () =>
latitude DOUBLE PRECISION, latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION, longitude DOUBLE PRECISION,
film_simulation VARCHAR(255), film_simulation VARCHAR(255),
fujifilm_recipe JSONB, recipe_title VARCHAR(255),
recipe_data JSONB,
priority_order REAL, priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL, taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL, taken_at_naive VARCHAR(255) NOT NULL,
@ -76,6 +78,7 @@ const safelyQueryPhotos = async <T>(
result = await callback(); result = await callback();
} catch (e: any) { } catch (e: any) {
const migration = migrationForError(e); const migration = migrationForError(e);
console.log('Query error', e);
if (migration) { if (migration) {
console.log(`Running Migration ${migration.label} ...`); console.log(`Running Migration ${migration.label} ...`);
await migration.run(); await migration.run();
@ -140,7 +143,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
latitude, latitude,
longitude, longitude,
film_simulation, film_simulation,
fujifilm_recipe, recipe_title,
recipe_data,
priority_order, priority_order,
hidden, hidden,
taken_at, taken_at,
@ -170,7 +174,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
${photo.latitude}, ${photo.latitude},
${photo.longitude}, ${photo.longitude},
${photo.filmSimulation}, ${photo.filmSimulation},
${JSON.stringify(photo.fujifilmRecipe)}, ${photo.recipeTitle},
${JSON.stringify(photo.recipeData)},
${photo.priorityOrder}, ${photo.priorityOrder},
${photo.hidden}, ${photo.hidden},
${photo.takenAt}, ${photo.takenAt},
@ -203,7 +208,8 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
latitude=${photo.latitude}, latitude=${photo.latitude},
longitude=${photo.longitude}, longitude=${photo.longitude},
film_simulation=${photo.filmSimulation}, film_simulation=${photo.filmSimulation},
fujifilm_recipe=${JSON.stringify(photo.fujifilmRecipe)}, recipe_title=${photo.recipeTitle},
recipe_data=${JSON.stringify(photo.recipeData)},
priority_order=${photo.priorityOrder || null}, priority_order=${photo.priorityOrder || null},
hidden=${photo.hidden}, hidden=${photo.hidden},
taken_at=${photo.takenAt}, taken_at=${photo.takenAt},
@ -325,6 +331,20 @@ export const getUniqueFilmSimulations = async () =>
}))) })))
, 'getUniqueFilmSimulations'); , 'getUniqueFilmSimulations');
export const getUniqueRecipes = async () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT recipe_title, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE AND recipe_title IS NOT NULL
GROUP BY recipe_title
ORDER BY recipe_title ASC
`.then(({ rows }): Recipes => rows
.map(({ recipe_title, count }) => ({
recipe: recipe_title,
count: parseInt(count, 10),
})))
, 'getUniqueRecipes');
export const getUniqueFocalLengths = async () => export const getUniqueFocalLengths = async () =>
safelyQueryPhotos(() => sql` safelyQueryPhotos(() => sql`
SELECT DISTINCT focal_length, COUNT(*) SELECT DISTINCT focal_length, COUNT(*)

View File

@ -14,7 +14,7 @@ import {
getOffsetFromExif, getOffsetFromExif,
} from '@/utility/exif'; } from '@/utility/exif';
import { roundToNumber } from '@/utility/number'; import { roundToNumber } from '@/utility/number';
import { convertStringToArray } from '@/utility/string'; import { convertStringToArray, parameterize } from '@/utility/string';
import { generateNanoid } from '@/utility/nanoid'; import { generateNanoid } from '@/utility/nanoid';
import { import {
FILM_SIMULATION_FORM_INPUT_OPTIONS, FILM_SIMULATION_FORM_INPUT_OPTIONS,
@ -111,9 +111,14 @@ const FORM_METADATA = (
shouldHide: ({ make }) => make !== MAKE_FUJIFILM, shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true, shouldNotOverwriteWithNullDataOnSync: true,
}, },
fujifilmRecipe: { recipeTitle: {
label: 'recipe title',
spellCheck: false,
capitalize: false,
},
recipeData: {
type: 'textarea', type: 'textarea',
label: 'fujifilm recipe', label: 'recipe data',
spellCheck: false, spellCheck: false,
capitalize: false, capitalize: false,
validate: value => { validate: value => {
@ -207,7 +212,7 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
return value?.toISOString ? value.toISOString() : value; return value?.toISOString ? value.toISOString() : value;
case 'hidden': case 'hidden':
return value ? 'true' : 'false'; return value ? 'true' : 'false';
case 'fujifilmRecipe': case 'recipeData':
return JSON.stringify(value); return JSON.stringify(value);
default: default:
return value !== undefined && value !== null return value !== undefined && value !== null
@ -228,7 +233,7 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
export const convertExifToFormData = ( export const convertExifToFormData = (
data: ExifData, data: ExifData,
filmSimulation?: FilmSimulation, filmSimulation?: FilmSimulation,
fujifilmRecipe?: FujifilmRecipe, recipeData?: FujifilmRecipe,
): Omit< ): Omit<
Record<keyof PhotoExif, string | undefined>, Record<keyof PhotoExif, string | undefined>,
'takenAt' | 'takenAtNaive' 'takenAt' | 'takenAtNaive'
@ -252,7 +257,7 @@ export const convertExifToFormData = (
longitude: longitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined, !GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
filmSimulation, filmSimulation,
fujifilmRecipe: JSON.stringify(fujifilmRecipe), recipeData: JSON.stringify(recipeData),
...data.tags?.DateTimeOriginal && { ...data.tags?.DateTimeOriginal && {
takenAt: convertTimestampWithOffsetToPostgresString( takenAt: convertTimestampWithOffsetToPostgresString(
data.tags.DateTimeOriginal, data.tags.DateTimeOriginal,
@ -299,11 +304,14 @@ export const convertFormDataToPhotoDbInsert = (
return { return {
...(photoForm as PhotoFormData & { ...(photoForm as PhotoFormData & {
filmSimulation?: FilmSimulation filmSimulation?: FilmSimulation
fujifilmRecipe?: FujifilmRecipe recipeData?: FujifilmRecipe
}), }),
...!photoForm.id && { id: generateNanoid() }, ...!photoForm.id && { id: generateNanoid() },
// Delete array field when empty // Delete array field when empty
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
...photoForm.recipeTitle && {
recipeTitle: parameterize(photoForm.recipeTitle),
},
// Convert form strings to numbers // Convert form strings to numbers
aspectRatio: photoForm.aspectRatio aspectRatio: photoForm.aspectRatio
? roundToNumber(parseFloat(photoForm.aspectRatio), 6) ? roundToNumber(parseFloat(photoForm.aspectRatio), 6)

View File

@ -65,7 +65,7 @@ export interface PhotoExif {
latitude?: number latitude?: number
longitude?: number longitude?: number
filmSimulation?: FilmSimulation filmSimulation?: FilmSimulation
fujifilmRecipe?: string recipeData?: string
takenAt?: string takenAt?: string
takenAtNaive?: string takenAtNaive?: string
} }
@ -80,6 +80,7 @@ export interface PhotoDbInsert extends PhotoExif {
caption?: string caption?: string
semanticDescription?: string semanticDescription?: string
tags?: string[] tags?: string[]
recipeTitle?: string
locationName?: string locationName?: string
priorityOrder?: number priorityOrder?: number
hidden?: boolean hidden?: boolean
@ -97,7 +98,7 @@ export interface PhotoDb extends
} }
// Parsed db response // Parsed db response
export interface Photo extends Omit<PhotoDb, 'fujifilmRecipe'> { export interface Photo extends Omit<PhotoDb, 'recipeData'> {
focalLengthFormatted?: string focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string fNumberFormatted?: string
@ -105,7 +106,7 @@ export interface Photo extends Omit<PhotoDb, 'fujifilmRecipe'> {
exposureTimeFormatted?: string exposureTimeFormatted?: string
exposureCompensationFormatted?: string exposureCompensationFormatted?: string
takenAtNaiveFormatted: string takenAtNaiveFormatted: string
fujifilmRecipe?: FujifilmRecipe recipeData?: FujifilmRecipe
} }
export interface PhotoSetCategory { export interface PhotoSetCategory {
@ -142,8 +143,8 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
formatExposureTime(photoDb.exposureTime), formatExposureTime(photoDb.exposureTime),
exposureCompensationFormatted: exposureCompensationFormatted:
formatExposureCompensation(photoDb.exposureCompensation), formatExposureCompensation(photoDb.exposureCompensation),
fujifilmRecipe: photoDb.fujifilmRecipe recipeData: photoDb.recipeData
? JSON.parse(photoDb.fujifilmRecipe) ? JSON.parse(photoDb.recipeData)
: undefined, : undefined,
takenAtNaiveFormatted: takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive), formatDateFromPostgresString(photoDb.takenAtNaive),
@ -165,7 +166,7 @@ export const convertPhotoToPhotoDbInsert = (
): PhotoDbInsert => ({ ): PhotoDbInsert => ({
...photo, ...photo,
takenAt: photo.takenAt.toISOString(), takenAt: photo.takenAt.toISOString(),
fujifilmRecipe: JSON.stringify(photo.fujifilmRecipe), recipeData: JSON.stringify(photo.recipeData),
}); });
export const photoStatsAsString = (photo: Photo) => [ export const photoStatsAsString = (photo: Photo) => [

View File

@ -4,7 +4,8 @@ import EntityLink, {
} from '@/components/primitives/EntityLink'; } from '@/components/primitives/EntityLink';
import { TbChecklist } from 'react-icons/tb'; import { TbChecklist } from 'react-icons/tb';
import { formatRecipe } from '.'; import { formatRecipe } from '.';
import clsx from 'clsx'; import clsx from 'clsx/lite';
import { RefObject } from 'react';
export default function PhotoRecipe({ export default function PhotoRecipe({
recipe, recipe,
@ -14,9 +15,13 @@ export default function PhotoRecipe({
prefetch, prefetch,
countOnHover, countOnHover,
className, className,
refButton,
isOpen,
recipeOnClick, recipeOnClick,
}: { }: {
recipe: string recipe: string
refButton?: RefObject<HTMLButtonElement | null>
isOpen?: boolean
recipeOnClick?: () => void recipeOnClick?: () => void
countOnHover?: number countOnHover?: number
} & EntityLinkExternalProps) { } & EntityLinkExternalProps) {
@ -41,6 +46,7 @@ export default function PhotoRecipe({
/> />
{recipeOnClick && {recipeOnClick &&
<button <button
ref={refButton}
onClick={recipeOnClick} onClick={recipeOnClick}
className={clsx( className={clsx(
'self-start', 'self-start',
@ -48,7 +54,7 @@ export default function PhotoRecipe({
'text-[10px] text-medium tracking-wider', 'text-[10px] text-medium tracking-wider',
)} )}
> >
RECIPE {isOpen ? 'CLOSE' : 'RECIPE'}
</button>} </button>}
</div> </div>
); );

View File

@ -10,7 +10,7 @@ import { RecipeProps } from '.';
const addSign = (value = 0) => value < 0 ? value : `+${value}`; const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipeGrid({ export default function PhotoRecipeOverlay({
ref, ref,
recipe: { recipe: {
dynamicRange, dynamicRange,

View File

@ -1,52 +0,0 @@
'use client';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import clsx from 'clsx/lite';
import ImageLarge from '@/components/image/ImageLarge';
import PhotoRecipeGrid from './PhotoRecipeGrid';
import { Photo } from '../photo';
import { useEffect, useState } from 'react';
export default function PhotoRecipeOverlay({
photos,
recipe,
className,
}: {
photos: Photo[]
recipe: FujifilmRecipe
className?: string
}) {
const [photoIndex, setPhotoIndex] = useState(0);
const photo = photos[photoIndex];
useEffect(() => {
const interval = setInterval(() => {
setPhotoIndex((photoIndex + 1) % photos.length);
}, 500);
return () => clearInterval(interval);
}, [photoIndex, photos]);
return (
<div className={clsx(
'relative w-full aspect-[3/2]',
className,
)}>
<ImageLarge
src={photo.url}
alt="Image Background"
aspectRatio={3 / 2}
/>
<div className={clsx(
'absolute inset-0 w-full h-full',
'flex items-center justify-center',
)}>
<PhotoRecipeGrid {...{
recipe,
simulation: photo.filmSimulation ?? 'provia',
exposure: photo.exposureCompensationFormatted ?? '+0ev',
iso: photo.isoFormatted ?? 'ISO 0',
}} />
</div>
</div>
);
}

View File

@ -1,10 +1,10 @@
'use client'; 'use client';
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { descriptionForTaggedPhotos } from '../tag';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe'; import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { descriptionForRecipePhotos } from '.';
export default function RecipeHeader({ export default function RecipeHeader({
recipe, recipe,
photos, photos,
@ -22,27 +22,27 @@ export default function RecipeHeader({
}) { }) {
const { setRecipeModalProps } = useAppState(); const { setRecipeModalProps } = useAppState();
const photo = photos.find(({ filmSimulation, fujifilmRecipe }) => const photo = photos.find(({ filmSimulation, recipeData }) =>
fujifilmRecipe && filmSimulation); recipeData && filmSimulation);
return ( return (
<PhotoHeader <PhotoHeader
tag={recipe} recipe={recipe}
entity={<PhotoRecipe entity={<PhotoRecipe
recipe={recipe} recipe={recipe}
contrast="high" contrast="high"
recipeOnClick={() => ( recipeOnClick={() => (
photo?.fujifilmRecipe && photo?.recipeData &&
photo?.filmSimulation photo?.filmSimulation
) ? setRecipeModalProps?.({ ) ? setRecipeModalProps?.({
simulation: photo.filmSimulation, simulation: photo.filmSimulation,
recipe: photo.fujifilmRecipe, recipe: photo.recipeData,
iso: photo.isoFormatted, iso: photo.isoFormatted,
exposure: photo.exposureTimeFormatted, exposure: photo.exposureTimeFormatted,
}) })
: undefined} : undefined}
/>} />}
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} entityDescription={descriptionForRecipePhotos(photos, undefined, count)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -2,7 +2,7 @@
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import PhotoRecipeGrid from './PhotoRecipeGrid'; import PhotoRecipeOverlay from './PhotoRecipeGrid';
export default function ShareModals() { export default function ShareModals() {
const { const {
@ -12,10 +12,11 @@ export default function ShareModals() {
if (recipeModalProps) { if (recipeModalProps) {
return <Modal return <Modal
className="bg-transparent!"
onClose={() => setRecipeModalProps?.(undefined)} onClose={() => setRecipeModalProps?.(undefined)}
container={false} container={false}
> >
<PhotoRecipeGrid {...{ <PhotoRecipeOverlay {...{
...recipeModalProps, ...recipeModalProps,
onClose: () => setRecipeModalProps?.(undefined), onClose: () => setRecipeModalProps?.(undefined),
}}/> }}/>

16
src/recipe/data.ts Normal file
View File

@ -0,0 +1,16 @@
import {
getPhotosCached,
getPhotosMetaCached,
} from '@/photo/cache';
export const getPhotosRecipeDataCached = ({
recipe,
limit,
}: {
recipe: string,
limit?: number,
}) =>
Promise.all([
getPhotosCached({ recipe, limit }),
getPhotosMetaCached({ recipe }),
]);

View File

@ -1,13 +1,16 @@
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths'; import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo'; import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo';
import { PhotoDateRange } from '@/photo'; import { PhotoDateRange } from '@/photo';
import { Tags } from '../tag';
import { parameterize } from '@/utility/string';
import { capitalizeWords } from '@/utility/string'; import { capitalizeWords } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
const KEY_RECIPE = 'recipe'; export type RecipeWithCount = {
recipe: string
count: number
}
export type Recipes = RecipeWithCount[]
export interface RecipeProps { export interface RecipeProps {
recipe: FujifilmRecipe recipe: FujifilmRecipe
@ -16,19 +19,6 @@ export interface RecipeProps {
exposure?: string exposure?: string
} }
export const isTagRecipe = (tag: string) =>
(new RegExp(`^${KEY_RECIPE}-?`).test(tag));
export const convertTagsToRecipes = (tags: Tags) =>
tags.filter(({ tag }) => isTagRecipe(tag))
.map(({ tag }) => convertTagToRecipe(tag));
export const convertRecipeToTag = (recipe: string) =>
`${KEY_RECIPE}-${parameterize(recipe)}`;
export const convertTagToRecipe = (tag: string) =>
tag.replace(new RegExp(`^${KEY_RECIPE}-?`), '');
export const formatRecipe = (recipe?: string) => export const formatRecipe = (recipe?: string) =>
capitalizeWords(recipe?.replaceAll('-', ' ')); capitalizeWords(recipe?.replaceAll('-', ' '));

View File

@ -1,20 +1,18 @@
import { import {
getPathComponents, getPathComponents,
pathForPhoto, pathForPhoto,
SEARCH_PARAM_SHOW_RECIPE,
} from '@/app/paths'; } from '@/app/paths';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { SEARCH_PARAM_SHOW } from '@/app/paths';
import { RefObject, useCallback, useEffect, useState } from 'react'; import { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom'; import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside'; import useClickInsideOutside from '@/utility/useClickInsideOutside';
export default function useRecipeState({ export default function useRecipeState({
ref, ref,
refTrigger, refTriggers = [],
}: { }: {
ref?: RefObject<HTMLElement | null>, ref?: RefObject<HTMLElement | null>,
refTrigger?: RefObject<HTMLElement | null>, refTriggers?: RefObject<HTMLElement | null>[],
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@ -23,13 +21,7 @@ export default function useRecipeState({
...pathComponents ...pathComponents
} = getPathComponents(pathname); } = getPathComponents(pathname);
const searchParamShow = typeof document !== 'undefined' const [shouldShowRecipe, setShouldShowRecipe] = useState(false);
? (new URLSearchParams(document.location.search)).get(SEARCH_PARAM_SHOW)
: undefined;
const showRecipeInitially = searchParamShow === SEARCH_PARAM_SHOW_RECIPE;
const [shouldShowRecipe, setShouldShowRecipe] = useState(showRecipeInitially);
const setVisibility = useCallback((shouldShow: boolean) => { const setVisibility = useCallback((shouldShow: boolean) => {
if (shouldShow) { if (shouldShow) {
@ -69,7 +61,7 @@ export default function useRecipeState({
[setVisibility, shouldShowRecipe]); [setVisibility, shouldShowRecipe]);
useClickInsideOutside({ useClickInsideOutside({
htmlElements: [ref, refTrigger], htmlElements: [ref, ...refTriggers],
onClickOutside: hideRecipe, onClickOutside: hideRecipe,
}); });

View File

@ -3,8 +3,6 @@ import { isTagFavs } from '.';
import FavsTag from './FavsTag'; import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink'; import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { convertTagToRecipe, isTagRecipe } from '@/recipe';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function PhotoTags({ export default function PhotoTags({
tags, tags,
@ -17,12 +15,7 @@ export default function PhotoTags({
<div className="flex flex-col"> <div className="flex flex-col">
{tags.map(tag => {tags.map(tag =>
<Fragment key={tag}> <Fragment key={tag}>
{isTagRecipe(tag) {isTagFavs(tag)
? <PhotoRecipe {...{
recipe: convertTagToRecipe(tag),
recipeOnClick: () => console.log('clicked'),
}} />
: isTagFavs(tag)
? <FavsTag {...{ contrast, prefetch }} /> ? <FavsTag {...{ contrast, prefetch }} />
: <PhotoTag {...{ tag, contrast, prefetch }} />} : <PhotoTag {...{ tag, contrast, prefetch }} />}
</Fragment>)} </Fragment>)}