Add core recipe page components

This commit is contained in:
Sam Becker 2025-03-02 21:41:58 -06:00
parent 0565eb93a5
commit 2b93dd750f
30 changed files with 531 additions and 30 deletions

View File

@ -1,6 +1,6 @@
import SiteGrid from '@/components/SiteGrid';
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
export default async function AdminRecipePage({
params,

View File

@ -1,5 +1,5 @@
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
export default async function AdminRecipePage() {
const [

View File

@ -22,6 +22,7 @@ import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import ShareModals from '@/share/ShareModals';
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
import { revalidatePath } from 'next/cache';
import RecipeModal from '@/recipe/RecipeModal';
import '../tailwind.css';
@ -86,6 +87,7 @@ export default function RootLayout({
)}>
<Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} />
<ShareModals />
<RecipeModal />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',

View File

@ -0,0 +1,87 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
import { getUniqueTags } from '@/photo/db/query';
import { IS_PRODUCTION } from '@/app/config';
import { STATICALLY_OPTIMIZED_PHOTO_CATEGORIES } from '@/app/config';
import { PATH_ROOT } from '@/app/paths';
import { getPhotosTagDataCached } from '@/tag/data';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { cache } from 'react';
import { convertRecipeToTag, generateMetaForRecipe } from '@/recipe';
import RecipeOverview from '@/recipe/RecipeOverview';
const getPhotosTagDataCachedCached = cache((tag: string) =>
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
export let generateStaticParams:
(() => Promise<{ recipe: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const tags = await getUniqueTags();
return tags
.filter(({ tag }) => tag.startsWith('recipe'))
.map(({ tag }) => ({ recipe: tag.replace(/^recipe-?/i, '')}));
};
}
interface RecipeProps {
params: Promise<{ recipe: string }>
}
export async function generateMetadata({
params,
}: RecipeProps): Promise<Metadata> {
const { recipe: recipeFromParams } = await params;
const recipe = decodeURIComponent(recipeFromParams);
const [
photos,
{ count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe));
if (photos.length === 0) { return {}; }
const {
url,
title,
description,
images,
} = generateMetaForRecipe(recipe, photos, count, dateRange);
return {
title,
openGraph: {
title,
description,
images,
url,
},
twitter: {
images,
description,
card: 'summary_large_image',
},
description,
};
}
export default async function RecipePage({
params,
}:RecipeProps) {
const { recipe: recipeFromParams } = await params;
const recipe = decodeURIComponent(recipeFromParams);
const [
photos,
{ count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe));
if (photos.length === 0) { redirect(PATH_ROOT); }
return (
<RecipeOverview {...{ recipe, photos, count, dateRange }} />
);
}

View File

@ -24,6 +24,7 @@ export const PREFIX_TAG = '/tag';
export const PREFIX_CAMERA = '/shot-on';
export const PREFIX_FILM_SIMULATION = '/film';
export const PREFIX_FOCAL_LENGTH = '/focal';
export const PREFIX_RECIPE = '/recipe';
// Dynamic paths
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
@ -32,6 +33,7 @@ const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
// eslint-disable-next-line max-len
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
// Search params
export const SEARCH_PARAM_SHOW = 'show';
@ -80,6 +82,7 @@ export const PATHS_TO_CACHE = [
PATH_CAMERA_DYNAMIC,
PATH_FILM_SIMULATION_DYNAMIC,
PATH_FOCAL_LENGTH_DYNAMIC,
PATH_RECIPE_DYNAMIC,
...PATHS_ADMIN,
];
@ -110,6 +113,7 @@ export const pathForPhoto = ({
camera,
simulation,
focal,
recipe,
showRecipe,
}: PhotoPathParams) => {
const path = typeof photo !== 'string' && photo.hidden
@ -122,6 +126,8 @@ export const pathForPhoto = ({
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}`
: focal
? `${pathForFocalLength(focal)}/${getPhotoId(photo)}`
: recipe
? `${pathForRecipe(recipe)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
return showRecipe
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
@ -140,6 +146,9 @@ export const pathForFilmSimulation = (simulation: FilmSimulation) =>
export const pathForFocalLength = (focal: number) =>
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
export const pathForRecipe = (recipe: string) =>
`${PREFIX_RECIPE}/${recipe}`;
export const absolutePathForPhoto = (params: PhotoPathParams) =>
`${BASE_URL}${pathForPhoto(params)}`;
@ -152,6 +161,9 @@ export const absolutePathForCamera= (camera: Camera) =>
export const absolutePathForFilmSimulation = (simulation: FilmSimulation) =>
`${BASE_URL}${pathForFilmSimulation(simulation)}`;
export const absolutePathForRecipe = (recipe: string) =>
`${BASE_URL}${pathForRecipe(recipe)}`;
export const absolutePathForFocalLength = (focal: number) =>
`${BASE_URL}${pathForFocalLength(focal)}`;
@ -168,6 +180,9 @@ export const absolutePathForFilmSimulationImage =
(simulation: FilmSimulation) =>
`${absolutePathForFilmSimulation(simulation)}/image`;
export const absolutePathForRecipeImage = (recipe: string) =>
`${absolutePathForRecipe(recipe)}/image`;
export const absolutePathForFocalLengthImage =
(focal: number) =>
`${absolutePathForFocalLength(focal)}/image`;

View File

@ -14,6 +14,7 @@ export default function PhotoCamera({
contrast,
prefetch,
countOnHover,
className,
}: {
camera: Camera
hideAppleIcon?: boolean
@ -37,6 +38,7 @@ export default function PhotoCamera({
className="translate-x-[-0.5px]"
/>}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}

View File

@ -16,6 +16,7 @@ export default function Modal({
onClose,
className,
anchor = 'center',
container = true,
children,
fast,
}: {
@ -23,6 +24,7 @@ export default function Modal({
onClose?: () => void
className?: string
anchor?: 'top' | 'center'
container?: boolean
children: ReactNode
fast?: boolean
}) {
@ -80,11 +82,11 @@ export default function Modal({
ref={contentRef}
key="modalContent"
className={clsx(
'w-[calc(100vw-1.5rem)] sm:w-[min(540px,90vw)]',
'p-3 rounded-lg',
'md:p-4 md:rounded-xl',
container && 'w-[calc(100vw-1.5rem)] sm:w-[min(540px,90vw)]',
container && 'p-3 rounded-lg',
container && 'md:p-4 md:rounded-xl',
container && 'dark:border dark:border-gray-800',
'bg-white dark:bg-black',
'dark:border dark:border-gray-800',
className,
)}
>

View File

@ -12,6 +12,7 @@ export interface EntityLinkExternalProps {
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
prefetch?: boolean
className?: string
}
export default function EntityLink({
@ -65,13 +66,13 @@ export default function EntityLink({
</>;
return (
<span className="group inline-flex w-full">
<span className={clsx(
'group inline-flex max-w-full overflow-hidden',
className,
)}>
<LinkWithStatus
href={href}
className={clsx(
'inline-flex items-center gap-2',
className,
)}
className="inline-flex items-center gap-2 max-w-full"
>
{({ isLoading }) => <>
<LabeledIcon {...{

View File

@ -12,6 +12,7 @@ export default function PhotoFocalLength({
contrast,
prefetch,
countOnHover,
className,
}: {
focal: number
countOnHover?: number
@ -22,6 +23,7 @@ export default function PhotoFocalLength({
href={pathForFocalLength(focal)}
icon={<TbCone className="rotate-[270deg]" />}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}

View File

@ -16,6 +16,7 @@ export default function PhotoGrid({
camera,
simulation,
focal,
recipe,
photoPriority,
fast,
animate = true,
@ -94,6 +95,7 @@ export default function PhotoGrid({
camera,
simulation,
focal,
recipe,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1

View File

@ -15,6 +15,7 @@ export default function PhotoGridContainer({
camera,
simulation,
focal,
recipe,
animateOnFirstLoadOnly,
header,
sidebar,
@ -53,6 +54,7 @@ export default function PhotoGridContainer({
camera,
simulation,
focal,
recipe,
animateOnFirstLoadOnly,
onAnimationComplete,
canSelect,
@ -66,6 +68,7 @@ export default function PhotoGridContainer({
camera,
simulation,
focal,
recipe,
animateOnFirstLoadOnly,
canSelect,
}} />}

View File

@ -38,11 +38,11 @@ import { LuExpand } from 'react-icons/lu';
import LoaderButton from '@/components/primitives/LoaderButton';
import Tooltip from '@/components/Tooltip';
import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
import PhotoRecipe from './PhotoRecipe';
import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence } from 'framer-motion';
import useRecipeState from './useRecipeState';
import useRecipeState from '../recipe/useRecipeState';
import PhotoRecipeGrid from '@/recipe/PhotoRecipeGrid';
export default function PhotoLarge({
photo,
@ -187,7 +187,7 @@ export default function PhotoLarge({
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
photo.fujifilmRecipe &&
photo.filmSimulation &&
<PhotoRecipe
<PhotoRecipeGrid
ref={refRecipe}
recipe={photo.fujifilmRecipe}
simulation={photo.filmSimulation}

View File

@ -14,6 +14,7 @@ export default function PhotoLink({
camera,
simulation,
focal,
recipe,
scroll,
prefetch,
nextPhotoAnimation,
@ -32,7 +33,7 @@ export default function PhotoLink({
return (
photo
? <Link
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
href={pathForPhoto({ photo, tag, camera, simulation, focal, recipe })}
prefetch={prefetch}
onClick={() => {
if (nextPhotoAnimation) {

View File

@ -21,6 +21,7 @@ export default function PhotoMedium({
camera,
simulation,
focal,
recipe,
selected,
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
@ -41,7 +42,7 @@ export default function PhotoMedium({
return (
<LinkWithStatus
ref={ref}
href={pathForPhoto({ photo, tag, camera, simulation, focal })}
href={pathForPhoto({ photo, tag, camera, simulation, focal, recipe })}
className={clsx(
'active:brightness-75',
selected && 'brightness-50',
@ -65,7 +66,7 @@ export default function PhotoMedium({
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="flex object-cover w-full h-full "
className="flex object-cover w-full h-full"
imgClassName="object-cover w-full h-full"
alt={altTextForPhoto(photo)}
priority={priority}

View File

@ -113,6 +113,7 @@ export interface PhotoSetCategory {
camera?: Camera
simulation?: FilmSimulation
focal?: number
recipe?: string
lens?: Lens // Unimplemented as a set
}

View File

@ -0,0 +1,48 @@
import { pathForTag } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import { TbChecklist } from 'react-icons/tb';
import { formatRecipe } from '.';
import clsx from 'clsx';
export default function PhotoRecipe({
recipe,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
recipeOnClick,
}: {
recipe: string
recipeOnClick?: () => void
countOnHover?: number
} & EntityLinkExternalProps) {
return (
<div className="flex w-full gap-2 h-[20.5px]">
<EntityLink
title="Recipe"
label={formatRecipe(recipe)}
href={pathForTag(recipe)}
icon={<TbChecklist size={16} />}
className={className}
type={type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
<button
onClick={recipeOnClick}
className={clsx(
'px-1! py-0!',
'text-[11px] text-medium tracking-wider',
)}
>
OPEN
</button>
</div>
);
}

View File

@ -1,17 +1,16 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import clsx from 'clsx/lite';
import { ReactNode, RefObject } from 'react';
import { IoCloseCircle } from 'react-icons/io5';
import { motion } from 'framer-motion';
import { RecipeProps } from '.';
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipe({
export default function PhotoRecipeGrid({
ref,
recipe: {
dynamicRange,
@ -33,12 +32,8 @@ export default function PhotoRecipe({
iso,
exposure,
onClose,
}: {
}: RecipeProps & {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
onClose?: () => void
}) {
const whiteBalanceTypeFormatted =

View File

@ -0,0 +1,150 @@
'use client';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import clsx from 'clsx/lite';
import { ReactNode, RefObject } from 'react';
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipeOGTile({
recipe: {
dynamicRange,
whiteBalance,
highISONoiseReduction,
noiseReductionBasic,
highlight,
shadow,
color,
sharpness,
clarity,
colorChromeEffect,
colorChromeFXBlue,
grainEffect,
bwAdjustment,
bwMagentaGreen,
},
simulation,
iso,
exposure,
}: {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
onClose?: () => void
}) {
const whiteBalanceTypeFormatted =
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
? `${whiteBalance.colorTemperature}K`
: whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');
const renderRow = (children: ReactNode, className?: string) =>
<div className={clsx(
'flex gap-2 *:w-full *:grow',
className,
)}>
{children}
</div>;
const renderDataSquare = (
value: ReactNode,
label?: string,
className?: string,
) => (
<div className={clsx(
'flex flex-col items-center justify-center gap-0.5 rounded-md min-w-0',
'rounded-md border',
'border-transparent',
'bg-neutral-100/60',
label && 'p-1',
className,
)}>
<div className="truncate max-w-full tracking-wide text-lg">
{typeof value === 'number' ? addSign(value) : value}
</div>
{label && <div className={clsx(
'text-[11px] leading-none tracking-wide font-medium text-black/50',
'uppercase',
)}>
{label}
</div>}
</div>
);
return (
<div
className={clsx(
'flex z-10',
'w-[37rem] p-10 aspect-video',
'text-[13.5px] text-black',
'bg-white/50',
'backdrop-blur-xl saturate-[300%]',
)}
>
<div className="flex flex-col gap-2 w-full">
{renderRow(<>
<div className={clsx(
'flex',
'text-lg leading-none text-black truncate',
)}>
KODAK PORTRA 500
</div>
<PhotoFilmSimulation
contrast="frosted"
simulation={simulation}
className="w-auto! grow-0!"
/>
</>, 'flex items-center gap-4')}
{renderRow(<>
{renderDataSquare(`DR${dynamicRange.development}`)}
{renderDataSquare(iso)}
{renderDataSquare(exposure ?? '0ev')}
</>)}
{renderRow(<>
{renderDataSquare(
whiteBalanceTypeFormatted.toUpperCase(),
`R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`,
)}
{renderDataSquare(
highISONoiseReduction ?? noiseReductionBasic ?? 'OFF',
'ISO NR',
)}
{renderDataSquare(highlight, 'Highlight')}
{renderDataSquare(shadow, 'Shadow')}
</>)}
{renderRow(<>
{renderDataSquare(color, 'Color')}
{renderDataSquare(sharpness, 'Sharpness')}
{renderDataSquare(clarity, 'Clarity')}
</>)}
{renderRow(<>
{renderDataSquare(
colorChromeEffect?.toLocaleUpperCase() ?? 'N/A',
'Color Chrome',
)}
{renderDataSquare(
colorChromeFXBlue?.toLocaleUpperCase() ?? 'N/A',
'FX Blue',
)}
</>)}
{renderRow(<>
{renderDataSquare(
grainEffect.roughness.toLocaleUpperCase(),
grainEffect.size === 'large'
? 'Large Grain'
: grainEffect.size === 'small'
? 'Small Grain'
: 'Grain',
)}
{renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')}
{renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')}
</>)}
</div>
</div>
);
}

View File

@ -3,8 +3,8 @@
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import clsx from 'clsx/lite';
import ImageLarge from '@/components/image/ImageLarge';
import PhotoRecipe from './PhotoRecipe';
import { Photo } from '.';
import PhotoRecipeGrid from './PhotoRecipeGrid';
import { Photo } from '../photo';
import { useEffect, useState } from 'react';
export default function PhotoRecipeOverlay({
photos,
@ -40,7 +40,7 @@ export default function PhotoRecipeOverlay({
'absolute inset-0 w-full h-full',
'flex items-center justify-center',
)}>
<PhotoRecipe {...{
<PhotoRecipeGrid {...{
recipe,
simulation: photo.filmSimulation ?? 'provia',
exposure: photo.exposureCompensationFormatted ?? '+0ev',

View File

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

View File

@ -0,0 +1,21 @@
'use client';
import Modal from '@/components/Modal';
import { useAppState } from '@/state/AppState';
import PhotoRecipeOGTile from './PhotoRecipeOGTile';
export default function ShareModals() {
const {
recipeModalProps,
setRecipeModalProps,
} = useAppState();
if (recipeModalProps) {
return <Modal
onClose={() => setRecipeModalProps?.(undefined)}
container={false}
>
<PhotoRecipeOGTile {...recipeModalProps}/>
</Modal>;
}
}

View File

@ -0,0 +1,33 @@
import { Photo, PhotoDateRange } from '@/photo';
import PhotoGridContainer from '@/photo/PhotoGridContainer';
import RecipeHeader from './RecipeHeader';
export default function RecipeOverview({
recipe,
photos,
count,
dateRange,
animateOnFirstLoadOnly,
}: {
recipe: string,
photos: Photo[],
count: number,
dateRange?: PhotoDateRange,
animateOnFirstLoadOnly?: boolean,
}) {
return (
<PhotoGridContainer {...{
cacheKey: `recipe-${recipe}`,
photos,
count,
recipe,
header: <RecipeHeader {...{
recipe,
photos,
count,
dateRange,
}} />,
animateOnFirstLoadOnly,
}} />
);
}

66
src/recipe/index.ts Normal file
View File

@ -0,0 +1,66 @@
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
import { Photo, photoQuantityText } from '@/photo';
import { PhotoDateRange } from '@/photo';
import {
descriptionForTaggedPhotos,
isTagFavs,
isTagHidden,
Tags,
} from '../tag';
import { convertStringToArray, parameterize } from '@/utility/string';
import { capitalizeWords } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
const KEY_RECIPE = 'recipe';
export interface RecipeProps {
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
}
export const convertTagsToRecipes = (tags: Tags) =>
tags.filter(({ tag }) => tag.startsWith(KEY_RECIPE))
.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) =>
capitalizeWords(recipe?.replaceAll('-', ' '));
export const getValidationMessageForTags = (tags?: string) => {
const reservedTags = (convertStringToArray(tags) ?? [])
.filter(tag => isTagFavs(tag) || isTagHidden(tag))
.map(tag => tag.toLocaleUpperCase());
return reservedTags.length
? `Reserved tags: ${reservedTags.join(', ').toLocaleLowerCase()}`
: undefined;
};
export const titleForRecipe = (
recipe: string,
photos:Photo[] = [],
explicitCount?: number,
) => [
`Recipe: ${formatRecipe(recipe)}`,
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
export const generateMetaForRecipe = (
recipe: string,
photos: Photo[],
explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ({
url: absolutePathForRecipe(recipe),
title: titleForRecipe(recipe, photos, explicitCount),
description:
descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange),
images: absolutePathForRecipeImage(recipe),
});

View File

@ -15,11 +15,11 @@ export default function PhotoFilmSimulation({
contrast = 'low',
prefetch,
countOnHover,
className,
}: {
simulation: FilmSimulation
countOnHover?: number
recipe?: FujifilmRecipe
className?: string
} & EntityLinkExternalProps) {
const { small, medium, large } = labelForFilmSimulation(simulation);
@ -34,6 +34,7 @@ export default function PhotoFilmSimulation({
/>}
title={`Film Simulation: ${large}`}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}

View File

@ -10,6 +10,7 @@ import { ShareModalProps } from '@/share';
import { InsightsIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { AdminData } from '@/admin/actions';
import { RecipeProps } from '@/recipe';
export type AppStateContext = {
// CORE
@ -34,6 +35,8 @@ export type AppStateContext = {
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
shareModalProps?: ShareModalProps
setShareModalProps?: Dispatch<SetStateAction<ShareModalProps | undefined>>
recipeModalProps?: RecipeProps
setRecipeModalProps?: Dispatch<SetStateAction<RecipeProps | undefined>>
// AUTH
userEmail?: string
setUserEmail?: Dispatch<SetStateAction<string | undefined>>

View File

@ -23,6 +23,7 @@ import {
import { useRouter, usePathname } from 'next/navigation';
import { isPathAdmin, PATH_SIGN_IN } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { RecipeProps } from '@/recipe';
export default function AppStateProvider({
children,
@ -53,6 +54,8 @@ export default function AppStateProvider({
useState(false);
const [shareModalProps, setShareModalProps] =
useState<ShareModalProps>();
const [recipeModalProps, setRecipeModalProps] =
useState<RecipeProps>();
// AUTH
const [userEmail, setUserEmail] =
useState<string>();
@ -170,6 +173,8 @@ export default function AppStateProvider({
setIsCommandKOpen,
shareModalProps,
setShareModalProps,
recipeModalProps,
setRecipeModalProps,
// AUTH
userEmail,
setUserEmail,

View File

@ -12,6 +12,7 @@ export default function FavsTag({
contrast,
prefetch,
countOnHover,
className,
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
@ -36,6 +37,7 @@ export default function FavsTag({
)}
/>}
type={type}
className={className}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}

View File

@ -11,6 +11,7 @@ export default function HiddenTag({
contrast,
prefetch,
countOnHover,
className,
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
@ -28,6 +29,7 @@ export default function HiddenTag({
href={pathForTag(TAG_HIDDEN)}
icon={!badged && <AiOutlineEyeInvisible size={16} />}
type={type}
className={className}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}

View File

@ -12,6 +12,7 @@ export default function PhotoTag({
contrast,
prefetch,
countOnHover,
className,
}: {
tag: string
countOnHover?: number
@ -25,6 +26,7 @@ export default function PhotoTag({
className="translate-y-[0.5px]"
/>}
type={type}
className={className}
badged={badged}
contrast={contrast}
prefetch={prefetch}