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 { Metadata } from 'next/types';
import { cache } from 'react';
import { PATH_ROOT } from '@/app/paths';
import { redirect } from 'next/navigation';
const getPhotosFilmSimulationDataCachedCached =
cache(getPhotosFilmSimulationDataCached);
@ -38,6 +40,8 @@ export async function generateMetadata({
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { return {}; }
const {
url,
title,
@ -75,6 +79,8 @@ export default async function FilmSimulationPage({
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { redirect(PATH_ROOT); }
return (
<FilmSimulationOverview {...{
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 { getUniqueTags } from '@/photo/db/query';
import { getUniqueRecipes } 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 { generateMetaForRecipe } from '@/recipe';
import RecipeOverview from '@/recipe/RecipeOverview';
import { getPhotosRecipeDataCached } from '@/recipe/data';
const getPhotosTagDataCachedCached = cache((tag: string) =>
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached);
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, '')}));
const recipes = await getUniqueRecipes();
return recipes.map(({ recipe }) => ({ recipe }));
};
}
@ -39,7 +36,10 @@ export async function generateMetadata({
const [
photos,
{ count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe));
] = await getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { return {}; }
@ -77,7 +77,10 @@ export default async function RecipePage({
const [
photos,
{ count, dateRange },
] = await getPhotosTagDataCachedCached(convertRecipeToTag(recipe));
] = await getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
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_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
// Search params
export const SEARCH_PARAM_SHOW = 'show';
export const SEARCH_PARAM_SHOW_RECIPE = 'recipe';
// Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
@ -114,9 +110,8 @@ export const pathForPhoto = ({
simulation,
focal,
recipe,
showRecipe,
}: PhotoPathParams) => {
const path = typeof photo !== 'string' && photo.hidden
}: PhotoPathParams) =>
typeof photo !== 'string' && photo.hidden
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
: tag
? `${pathForTag(tag)}/${getPhotoId(photo)}`
@ -129,10 +124,6 @@ export const pathForPhoto = ({
: recipe
? `${pathForRecipe(recipe)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
return showRecipe
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
: path;
};
export const pathForTag = (tag: string) =>
`${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 PhotoHeader from './PhotoHeader';
import { JSX } from 'react';
import RecipeHeader from '@/recipe/RecipeHeader';
export default function PhotoDetailPage({
photo,
@ -19,6 +20,7 @@ export default function PhotoDetailPage({
tag,
camera,
simulation,
recipe,
focal,
indexNumber,
count,
@ -72,6 +74,14 @@ export default function PhotoDetailPage({
count={count}
dateRange={dateRange}
/>;
} else if (recipe) {
customHeader = <RecipeHeader
recipe={recipe}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
/>;
} else if (focal) {
customHeader = <FocalLengthHeader
focal={focal}
@ -90,6 +100,7 @@ export default function PhotoDetailPage({
contentMain={customHeader ?? <PhotoHeader
selectedPhoto={photo}
photos={photos}
recipe={recipe}
/>}
/>
<AnimateItems
@ -106,10 +117,12 @@ export default function PhotoDetailPage({
showTitleAsH1
showCamera={!camera}
showSimulation={!simulation}
showRecipe={!recipe}
shouldShare={shouldShare}
shouldShareTag={tag !== undefined}
shouldShareCamera={camera !== undefined}
shouldShareSimulation={simulation !== undefined}
shouldShareRecipe={recipe !== undefined}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
/>,
]}

View File

@ -21,8 +21,6 @@ import {
safelyParseFormattedHtml,
} from '@/utility/html';
import { clsx } from 'clsx/lite';
import { convertTagToRecipe, isTagRecipe } from '@/recipe';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function PhotoGridSidebar({
tags,
@ -53,17 +51,6 @@ export default function PhotoGridSidebar({
className="text-icon translate-y-[1px]"
/>}
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) {
case TAG_FAVS:
return <FavsTag
@ -94,7 +81,6 @@ export default function PhotoGridSidebar({
badged
/>;
}
}
})}
/>
: null;

View File

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

View File

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

View File

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

View File

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

View File

@ -23,11 +23,32 @@ export const MIGRATIONS: Migration[] = [{
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
`,
}, {
label: '03: Fujifilm Recipe',
fields: ['fujifilm_recipe'],
label: '03: Fujifilm Recipe: Data',
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`
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 { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Recipes } from '@/recipe';
const createPhotosTable = () =>
sql`
@ -53,7 +54,8 @@ const createPhotosTable = () =>
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film_simulation VARCHAR(255),
fujifilm_recipe JSONB,
recipe_title VARCHAR(255),
recipe_data JSONB,
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
@ -76,6 +78,7 @@ const safelyQueryPhotos = async <T>(
result = await callback();
} catch (e: any) {
const migration = migrationForError(e);
console.log('Query error', e);
if (migration) {
console.log(`Running Migration ${migration.label} ...`);
await migration.run();
@ -140,7 +143,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
latitude,
longitude,
film_simulation,
fujifilm_recipe,
recipe_title,
recipe_data,
priority_order,
hidden,
taken_at,
@ -170,7 +174,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
${photo.latitude},
${photo.longitude},
${photo.filmSimulation},
${JSON.stringify(photo.fujifilmRecipe)},
${photo.recipeTitle},
${JSON.stringify(photo.recipeData)},
${photo.priorityOrder},
${photo.hidden},
${photo.takenAt},
@ -203,7 +208,8 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
latitude=${photo.latitude},
longitude=${photo.longitude},
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},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
@ -325,6 +331,20 @@ export const getUniqueFilmSimulations = async () =>
})))
, '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 () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT focal_length, COUNT(*)

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { RecipeProps } from '.';
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipeGrid({
export default function PhotoRecipeOverlay({
ref,
recipe: {
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';
import { Photo, PhotoDateRange } from '@/photo';
import { descriptionForTaggedPhotos } from '../tag';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState';
import { descriptionForRecipePhotos } from '.';
export default function RecipeHeader({
recipe,
photos,
@ -22,27 +22,27 @@ export default function RecipeHeader({
}) {
const { setRecipeModalProps } = useAppState();
const photo = photos.find(({ filmSimulation, fujifilmRecipe }) =>
fujifilmRecipe && filmSimulation);
const photo = photos.find(({ filmSimulation, recipeData }) =>
recipeData && filmSimulation);
return (
<PhotoHeader
tag={recipe}
recipe={recipe}
entity={<PhotoRecipe
recipe={recipe}
contrast="high"
recipeOnClick={() => (
photo?.fujifilmRecipe &&
photo?.recipeData &&
photo?.filmSimulation
) ? setRecipeModalProps?.({
simulation: photo.filmSimulation,
recipe: photo.fujifilmRecipe,
recipe: photo.recipeData,
iso: photo.isoFormatted,
exposure: photo.exposureTimeFormatted,
})
: undefined}
/>}
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
entityDescription={descriptionForRecipePhotos(photos, undefined, count)}
photos={photos}
selectedPhoto={selectedPhoto}
indexNumber={indexNumber}

View File

@ -2,7 +2,7 @@
import Modal from '@/components/Modal';
import { useAppState } from '@/state/AppState';
import PhotoRecipeGrid from './PhotoRecipeGrid';
import PhotoRecipeOverlay from './PhotoRecipeGrid';
export default function ShareModals() {
const {
@ -12,10 +12,11 @@ export default function ShareModals() {
if (recipeModalProps) {
return <Modal
className="bg-transparent!"
onClose={() => setRecipeModalProps?.(undefined)}
container={false}
>
<PhotoRecipeGrid {...{
<PhotoRecipeOverlay {...{
...recipeModalProps,
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 { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo';
import { PhotoDateRange } from '@/photo';
import { Tags } from '../tag';
import { parameterize } from '@/utility/string';
import { capitalizeWords } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
const KEY_RECIPE = 'recipe';
export type RecipeWithCount = {
recipe: string
count: number
}
export type Recipes = RecipeWithCount[]
export interface RecipeProps {
recipe: FujifilmRecipe
@ -16,19 +19,6 @@ export interface RecipeProps {
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) =>
capitalizeWords(recipe?.replaceAll('-', ' '));

View File

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

View File

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