Refine core photo/category behavior

This commit is contained in:
Sam Becker 2025-03-09 15:03:03 -05:00
parent 6768f15212
commit e1ee7ff7da
15 changed files with 123 additions and 109 deletions

View File

@ -1,7 +1,7 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import FilmSimulationImageResponse from
'@/image-response/FilmSimulationImageResponse';
@ -39,7 +39,7 @@ export async function GET(
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, simulation }),
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, simulation }),
getIBMPlexMonoMedium(),
getImageResponseCacheControlHeaders(),
]);

View File

@ -1,7 +1,7 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import { getIBMPlexMonoMedium } from '@/app/font';
import { ImageResponse } from 'next/og';
@ -41,7 +41,7 @@ export async function GET(
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, focal }),
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, focal }),
getIBMPlexMonoMedium(),
getImageResponseCacheControlHeaders(),
]);

View File

@ -62,7 +62,7 @@ export async function generateMetadata({
};
}
export default async function PhotoTagPage({
export default async function PhotoRecipePage({
params,
}: PhotoRecipeProps) {
const { photoId, recipe: recipeFromParams } = await params;

View File

@ -1,7 +1,7 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import { getIBMPlexMonoMedium } from '@/app/font';
import { ImageResponse } from 'next/og';
@ -37,7 +37,7 @@ export async function GET(
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, recipe }),
getPhotosCached({ recipe, limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY }),
getIBMPlexMonoMedium(),
getImageResponseCacheControlHeaders(),
]);

View File

@ -2,7 +2,7 @@ import { getPhotosCached } from '@/photo/cache';
import { Camera, CameraProps, getCameraFromParams } from '@/camera';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import CameraImageResponse from '@/image-response/CameraImageResponse';
import { getIBMPlexMonoMedium } from '@/app/font';
@ -39,7 +39,7 @@ export async function GET(
headers,
] = await Promise.all([
getPhotosCached({
limit: MAX_PHOTOS_TO_SHOW_PER_TAG,
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
camera: camera,
}),
getIBMPlexMonoMedium(),

View File

@ -1,7 +1,7 @@
import { getPhotosCached } from '@/photo/cache';
import {
IMAGE_OG_DIMENSION_SMALL,
MAX_PHOTOS_TO_SHOW_PER_TAG,
MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
} from '@/image-response';
import TagImageResponse from '@/image-response/TagImageResponse';
import { getIBMPlexMonoMedium } from '@/app/font';
@ -37,7 +37,7 @@ export async function GET(
{ fontFamily, fonts },
headers,
] = await Promise.all([
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_TAG, tag }),
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, tag }),
getIBMPlexMonoMedium(),
getImageResponseCacheControlHeaders(),
]);

View File

@ -14,11 +14,13 @@ export default function ImagePhotoGrid({
height,
imagePosition = 'center',
gap = 4,
imageStyle,
}: ({
photos: Photo[]
height: number
imagePosition?: 'center' | 'top'
gap?: number
imageStyle?: React.CSSProperties
} & (
{ width: NextImageSize, widthArbitrary?: undefined } |
{ width?: undefined, widthArbitrary: number }
@ -46,13 +48,15 @@ export default function ImagePhotoGrid({
(rows - 1) * gap / rows;
return (
<div style={{
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
gap,
}}>
}}
>
{photos.slice(0, count).map(({ id, url }) =>
<div
key={id}
@ -73,6 +77,7 @@ export default function ImagePhotoGrid({
IS_PREVIEW,
),
style: {
...imageStyle,
width: '100%',
...imagePosition === 'center' && {
height: '100%',

View File

@ -2,7 +2,7 @@ import { NextImageSize } from '@/platforms/next-image';
import { getDimensionsFromSize } from '@/utility/size';
export const MAX_PHOTOS_TO_SHOW_OG = 12;
export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6;
export const MAX_PHOTOS_TO_SHOW_PER_CATEGORY = 6;
export const MAX_PHOTOS_TO_SHOW_TEMPLATE = 16;
export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12;

View File

@ -42,8 +42,8 @@ import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence } from 'framer-motion';
import useRecipeState from '../recipe/useRecipeState';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeGrid';
import useRecipeOverlay from '../recipe/useRecipeOverlay';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
import PhotoRecipe from '@/recipe/PhotoRecipe';
export default function PhotoLarge({
@ -110,10 +110,10 @@ export default function PhotoLarge({
const refRecipeButton = useRef<HTMLButtonElement>(null);
const refTriggers = useMemo(() => [refRecipeButton], []);
const {
shouldShowRecipe,
toggleRecipe,
hideRecipe,
} = useRecipeState({
shouldShowRecipeOverlay,
toggleRecipeOverlay,
hideRecipeOverlay,
} = useRecipeOverlay({
ref: refRecipe,
refTriggers,
});
@ -193,9 +193,12 @@ export default function PhotoLarge({
<div className={clsx(
'absolute inset-0',
'flex items-center justify-center',
(shouldShowRecipeOverlay || shouldDebugRecipeOverlays)
? 'z-[1]'
: 'z-[-1]',
)}>
<AnimatePresence>
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
{(shouldShowRecipeOverlay || shouldDebugRecipeOverlays) &&
photo.recipeData &&
photo.filmSimulation &&
<PhotoRecipeOverlay
@ -204,7 +207,7 @@ export default function PhotoLarge({
simulation={photo.filmSimulation}
iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted}
onClose={hideRecipe}
onClose={hideRecipeOverlay}
/>}
</AnimatePresence>
</div>
@ -214,6 +217,17 @@ export default function PhotoLarge({
'flex items-center justify-center aspect-3/2 bg-gray-100',
);
const shouldRenderSimulation = (
SHOW_FILM_SIMULATIONS &&
showSimulation &&
photo.filmSimulation
);
const shouldRenderRecipe = (
SHOW_RECIPES &&
photo.recipeData
);
return (
<SiteGrid
containerRef={ref}
@ -325,25 +339,14 @@ export default function PhotoLarge({
<li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul>
{(
(
SHOW_FILM_SIMULATIONS &&
showSimulation &&
photo.filmSimulation
) ||
(SHOW_RECIPES && photo.recipeData)
) &&
{(shouldRenderSimulation || shouldRenderRecipe) &&
<div className="flex items-center gap-2 *:w-auto">
{(
SHOW_FILM_SIMULATIONS &&
showSimulation &&
photo.filmSimulation
) &&
{shouldRenderSimulation && photo.filmSimulation &&
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>}
{SHOW_RECIPES && photo.recipeData &&
{shouldRenderRecipe &&
<Tooltip
content="Fujifilm Recipe"
desktopOnly
@ -351,15 +354,16 @@ export default function PhotoLarge({
<button
ref={refRecipeButton}
title="Fujifilm Recipe"
onClick={toggleRecipe}
onClick={toggleRecipeOverlay}
className={clsx(
'text-medium',
'border-medium rounded-md',
'px-[4px] py-[2.5px] my-[-3px]',
'translate-y-[1.5px]',
'translate-y-[2px]',
'hover:bg-dim active:bg-main',
!shouldRenderSimulation && 'translate-x-[-2px]',
)}>
{shouldShowRecipe
{shouldShowRecipeOverlay
? <IoCloseSharp size={15} />
: <TbChecklist
className="translate-x-[0.5px]"

View File

@ -1,15 +1,23 @@
'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}`;
import { addSign, formatWhiteBalance } from '.';
export default function PhotoRecipeOGTile({
recipe: {
recipe,
simulation,
iso,
exposure,
}: {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
}) {
const {
dynamicRange,
whiteBalance,
highISONoiseReduction,
@ -24,24 +32,9 @@ export default function PhotoRecipeOGTile({
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('-', ' ');
} = recipe;
const whiteBalanceTypeFormatted = formatWhiteBalance(recipe);
const renderRow = (children: ReactNode, className?: string) =>
<div className={clsx(
@ -64,7 +57,7 @@ export default function PhotoRecipeOGTile({
label && 'p-1',
className,
)}>
<div className="truncate max-w-full tracking-wide text-lg">
<div className="flex truncate max-w-full tracking-wide text-lg">
{typeof value === 'number' ? addSign(value) : value}
</div>
{label && <div className={clsx(

View File

@ -6,13 +6,20 @@ 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}`;
import { addSign, formatWhiteBalance, RecipeProps } from '.';
export default function PhotoRecipeOverlay({
ref,
recipe: {
recipe,
simulation,
iso,
exposure,
onClose,
}: RecipeProps & {
ref?: RefObject<HTMLDivElement | null>
onClose?: () => void
}) {
const {
dynamicRange,
whiteBalance,
highISONoiseReduction,
@ -27,21 +34,9 @@ export default function PhotoRecipeOverlay({
grainEffect,
bwAdjustment,
bwMagentaGreen,
},
simulation,
iso,
exposure,
onClose,
}: RecipeProps & {
ref?: RefObject<HTMLDivElement | null>
onClose?: () => void
}) {
const whiteBalanceTypeFormatted =
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
? `${whiteBalance.colorTemperature}K`
: whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');
} = recipe;
const whiteBalanceTypeFormatted = formatWhiteBalance(recipe);
const renderRow = (children: ReactNode) =>
<div className="flex gap-2 *:w-full *:grow">{children}</div>;

View File

@ -4,7 +4,7 @@ import { Photo, PhotoDateRange } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState';
import { descriptionForRecipePhotos, photoHasRecipe } from '.';
import { descriptionForRecipePhotos, getPhotoWithRecipeFromPhotos } from '.';
export default function RecipeHeader({
recipe,
photos,
@ -22,9 +22,7 @@ export default function RecipeHeader({
}) {
const { setRecipeModalProps } = useAppState();
const photo = photoHasRecipe(selectedPhoto)
? selectedPhoto
: photos.find(photoHasRecipe);
const photo = getPhotoWithRecipeFromPhotos(photos, selectedPhoto);
return (
<PhotoHeader

View File

@ -2,7 +2,7 @@
import Modal from '@/components/Modal';
import { useAppState } from '@/state/AppState';
import PhotoRecipeOverlay from './PhotoRecipeGrid';
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
export default function ShareModals() {
const {

View File

@ -65,9 +65,17 @@ export const generateMetaForRecipe = (
images: absolutePathForRecipeImage(recipe),
});
export const photoHasRecipe = (photo?: Photo) =>
const photoHasRecipe = (photo?: Photo) =>
photo?.filmSimulation && photo?.recipeData;
export const getPhotoWithRecipeFromPhotos = (
photos: Photo[],
preferredPhoto?: Photo,
) =>
photoHasRecipe(preferredPhoto)
? preferredPhoto
: photos.find(photoHasRecipe);
export const sortRecipesWithCount = (recipes: Recipes = []) =>
recipes.sort((a, b) => a.recipe.localeCompare(b.recipe));
@ -78,3 +86,12 @@ export const convertRecipesForForm = (recipes: Recipes = []) =>
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
}));
export const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export const formatWhiteBalance = ({ whiteBalance }: FujifilmRecipe) =>
whiteBalance.type === 'kelvin' && whiteBalance.colorTemperature
? `${whiteBalance.colorTemperature}K`
: whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');

View File

@ -7,7 +7,7 @@ import { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
export default function useRecipeState({
export default function useRecipeOverlay({
ref,
refTriggers = [],
}: {
@ -21,11 +21,11 @@ export default function useRecipeState({
...pathComponents
} = getPathComponents(pathname);
const [shouldShowRecipe, setShouldShowRecipe] = useState(false);
const [shouldShowRecipeOverlay, setShouldShowRecipeOverlay] = useState(false);
const setVisibility = useCallback((shouldShow: boolean) => {
if (shouldShow) {
setShouldShowRecipe(true);
setShouldShowRecipeOverlay(true);
// Only add query param for photo details
if (photoId) {
window.history.pushState(
@ -39,7 +39,7 @@ export default function useRecipeState({
);
}
} else {
setShouldShowRecipe(false);
setShouldShowRecipeOverlay(false);
// Only remove query param for photo details
if (photoId) {
window.history.pushState(
@ -54,27 +54,29 @@ export default function useRecipeState({
}
}, [pathComponents, photoId]);
const showRecipe = useCallback(() => setVisibility(true), [setVisibility]);
const hideRecipe = useCallback(() => setVisibility(false), [setVisibility]);
const toggleRecipe = useCallback(() =>
setVisibility(!shouldShowRecipe),
[setVisibility, shouldShowRecipe]);
const showRecipeOverlay =
useCallback(() => setVisibility(true), [setVisibility]);
const hideRecipeOverlay =
useCallback(() => setVisibility(false), [setVisibility]);
const toggleRecipeOverlay = useCallback(() =>
setVisibility(!shouldShowRecipeOverlay),
[setVisibility, shouldShowRecipeOverlay]);
useClickInsideOutside({
htmlElements: [ref, ...refTriggers],
onClickOutside: hideRecipe,
onClickOutside: hideRecipeOverlay,
});
useEffect(() => {
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
if (shouldShowRecipeOverlay && !isElementEntirelyInViewport(ref?.current)) {
ref?.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldShowRecipe]);
}, [ref, shouldShowRecipeOverlay]);
return {
shouldShowRecipe,
showRecipe,
hideRecipe,
toggleRecipe,
shouldShowRecipeOverlay,
showRecipeOverlay,
hideRecipeOverlay,
toggleRecipeOverlay,
};
}