Add core recipe page components
This commit is contained in:
parent
0565eb93a5
commit
2b93dd750f
@ -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,
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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',
|
||||
|
||||
87
app/recipe/[recipe]/page.tsx
Normal file
87
app/recipe/[recipe]/page.tsx
Normal 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 }} />
|
||||
);
|
||||
}
|
||||
@ -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`;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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 {...{
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}} />}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -113,6 +113,7 @@ export interface PhotoSetCategory {
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
recipe?: string
|
||||
lens?: Lens // Unimplemented as a set
|
||||
}
|
||||
|
||||
|
||||
48
src/recipe/PhotoRecipe.tsx
Normal file
48
src/recipe/PhotoRecipe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 =
|
||||
150
src/recipe/PhotoRecipeOGTile.tsx
Normal file
150
src/recipe/PhotoRecipeOGTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
54
src/recipe/RecipeHeader.tsx
Normal file
54
src/recipe/RecipeHeader.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/recipe/RecipeModal.tsx
Normal file
21
src/recipe/RecipeModal.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
33
src/recipe/RecipeOverview.tsx
Normal file
33
src/recipe/RecipeOverview.tsx
Normal 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
66
src/recipe/index.ts
Normal 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),
|
||||
});
|
||||
@ -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}
|
||||
|
||||
@ -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>>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user