Merge pull request #201 from sambecker/recipe-pages

Recipe Pages
This commit is contained in:
Sam Becker 2025-03-03 23:05:24 -06:00 committed by GitHub
commit 1eae6093f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1163 additions and 349 deletions

View File

@ -1,28 +0,0 @@
import SiteGrid from '@/components/SiteGrid';
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/photo/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 '@/photo/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

@ -28,6 +28,7 @@ export default async function GridPage() {
tags,
cameras,
simulations,
recipes,
] = await Promise.all([
getPhotosCached()
.catch(() => []),
@ -40,7 +41,7 @@ export default async function GridPage() {
return (
photos.length > 0
? <PhotoGridPage
{...{ photos, photosCount, tags, cameras, simulations }}
{...{ photos, photosCount, tags, cameras, simulations, recipes }}
/>
: <PhotosEmptyState />
);

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

@ -34,6 +34,7 @@ export default async function HomePage() {
tags,
cameras,
simulations,
recipes,
] = await Promise.all([
getPhotosCached()
.catch(() => []),
@ -42,14 +43,14 @@ export default async function HomePage() {
.catch(() => 0),
...(GRID_HOMEPAGE_ENABLED
? getPhotoSidebarData()
: [[], [], []]),
: [[], [], [], []]),
]);
return (
photos.length > 0
? GRID_HOMEPAGE_ENABLED
? <PhotoGridPage
{...{ photos, photosCount, tags, cameras, simulations }}
{...{ photos, photosCount, tags, cameras, simulations, recipes }}
/>
: <PhotoFeedPage
{...{ photos, photosCount }}

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

@ -0,0 +1,90 @@
import { INFINITE_SCROLL_GRID_INITIAL } from '@/photo';
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 type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { cache } from 'react';
import { generateMetaForRecipe } from '@/recipe';
import RecipeOverview from '@/recipe/RecipeOverview';
import { getPhotosRecipeDataCached } from '@/recipe/data';
const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached);
export let generateStaticParams:
(() => Promise<{ recipe: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PHOTO_CATEGORIES && IS_PRODUCTION) {
generateStaticParams = async () => {
const recipes = await getUniqueRecipes();
return recipes.map(({ recipe }) => ({ recipe }));
};
}
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 getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
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 getPhotosRecipeDataCachedCached({
recipe,
limit: INFINITE_SCROLL_GRID_INITIAL,
});
if (photos.length === 0) { redirect(PATH_ROOT); }
return (
<RecipeOverview {...{ recipe, photos, count, dateRange }} />
);
}

View File

@ -21,15 +21,15 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^0.27.2",
"@vercel/speed-insights": "^1.2.0",
"ai": "^4.1.47",
"ai": "^4.1.50",
"camelcase-keys": "^9.1.3",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"exifr": "^7.1.3",
"framer-motion": "^12.4.7",
"framer-motion": "^12.4.10",
"nanoid": "^5.1.2",
"next": "15.1.7",
"next": "15.2.1",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"pg": "^8.13.3",
@ -46,8 +46,8 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.0",
"@next/bundle-analyzer": "15.1.7",
"@next/eslint-plugin-next": "^15.1.7",
"@next/bundle-analyzer": "15.2.1",
"@next/eslint-plugin-next": "^15.2.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.9",
@ -55,7 +55,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.8",
"@types/node": "^22.13.9",
"@types/pg": "^8.11.11",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
@ -63,7 +63,7 @@
"clsx": "^2.1.1",
"cross-fetch": "^4.1.0",
"eslint": "9.21.0",
"eslint-config-next": "15.1.7",
"eslint-config-next": "15.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",

269
pnpm-lock.yaml generated
View File

@ -37,16 +37,16 @@ importers:
version: 1.34.4
'@vercel/analytics':
specifier: ^1.5.0
version: 1.5.0(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
version: 1.5.0(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
'@vercel/blob':
specifier: ^0.27.2
version: 0.27.2
'@vercel/speed-insights':
specifier: ^1.2.0
version: 1.2.0(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
version: 1.2.0(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
ai:
specifier: ^4.1.47
version: 4.1.47(react@19.0.0)(zod@3.24.2)
specifier: ^4.1.50
version: 4.1.50(react@19.0.0)(zod@3.24.2)
camelcase-keys:
specifier: ^9.1.3
version: 9.1.3
@ -63,17 +63,17 @@ importers:
specifier: ^7.1.3
version: 7.1.3
framer-motion:
specifier: ^12.4.7
version: 12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: ^12.4.10
version: 12.4.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nanoid:
specifier: ^5.1.2
version: 5.1.2
next:
specifier: 15.1.7
version: 15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.2.1
version: 15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
version: 5.0.0-beta.25(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
next-themes:
specifier: ^0.4.4
version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -115,11 +115,11 @@ importers:
specifier: ^3.3.0
version: 3.3.0
'@next/bundle-analyzer':
specifier: 15.1.7
version: 15.1.7
specifier: 15.2.1
version: 15.2.1
'@next/eslint-plugin-next':
specifier: ^15.1.7
version: 15.2.0
specifier: ^15.2.1
version: 15.2.1
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@4.0.9)
@ -142,8 +142,8 @@ importers:
specifier: ^29.5.14
version: 29.5.14
'@types/node':
specifier: ^22.13.8
version: 22.13.8
specifier: ^22.13.9
version: 22.13.9
'@types/pg':
specifier: ^8.11.11
version: 8.11.11
@ -166,14 +166,14 @@ importers:
specifier: 9.21.0
version: 9.21.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.1.7
version: 15.1.7(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
specifier: 15.2.1
version: 15.2.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@9.21.0(jiti@2.4.2))
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
version: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
jest-environment-jsdom:
specifier: ^29.7.0
version: 29.7.0
@ -185,7 +185,7 @@ importers:
version: 4.0.9
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.13.8)(typescript@5.8.2)
version: 10.9.2(@types/node@22.13.9)(typescript@5.8.2)
typescript:
specifier: 5.8.2
version: 5.8.2
@ -214,8 +214,8 @@ packages:
resolution: {integrity: sha512-jie6ZJT2ZR0uVOVCDc9R2xCX5I/Dum/wEK28lx21PJx6ZnFAN9EzD2WsPhcDWfCgGx3OAZZ0GyM3CEobXpa9LA==}
engines: {node: '>=18'}
'@ai-sdk/react@1.1.19':
resolution: {integrity: sha512-zqSOWmJxpB45ZrwZ04+Q7Uo3xeGM0tva2eUYz2T4gv9Yk6MQAfOBA6+scsKg8CyIuUy4M4/C4pCY3eWQf7sfQg==}
'@ai-sdk/react@1.1.20':
resolution: {integrity: sha512-4QOM9fR9SryaRraybckDjrhl1O6XejqELdKmrM5g9y9eLnWAfjwF+W1aN0knkSHzbbjMqN77sy9B9yL8EuJbDw==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -865,62 +865,59 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@next/bundle-analyzer@15.1.7':
resolution: {integrity: sha512-tESiAwTUEpzzxKMLDbQuPHvD+PFDjY+0O3R4T5bpjIo0cr5fvppbbllQbtksQbBEquT55Eu8JmDoOlc9YFv6Kw==}
'@next/bundle-analyzer@15.2.1':
resolution: {integrity: sha512-RSegG5zFEy+Pp771fvXu+LmNMmf6kuCSuliXAiLFogLz+QUNw9mUZ0U9fxA/2mngHRUmANsSCGv7l0RkyXjnxg==}
'@next/env@15.1.7':
resolution: {integrity: sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==}
'@next/env@15.2.1':
resolution: {integrity: sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==}
'@next/eslint-plugin-next@15.1.7':
resolution: {integrity: sha512-kRP7RjSxfTO13NE317ek3mSGzoZlI33nc/i5hs1KaWpK+egs85xg0DJ4p32QEiHnR0mVjuUfhRIun7awqfL7pQ==}
'@next/eslint-plugin-next@15.2.1':
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
'@next/eslint-plugin-next@15.2.0':
resolution: {integrity: sha512-jHFUG2OwmAuOASqq253RAEG/5BYcPHn27p1NoWZDCf4OdvdK0yRYWX92YKkL+Mk2s+GyJrmd/GATlL5b2IySpw==}
'@next/swc-darwin-arm64@15.1.7':
resolution: {integrity: sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==}
'@next/swc-darwin-arm64@15.2.1':
resolution: {integrity: sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.1.7':
resolution: {integrity: sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==}
'@next/swc-darwin-x64@15.2.1':
resolution: {integrity: sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.1.7':
resolution: {integrity: sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==}
'@next/swc-linux-arm64-gnu@15.2.1':
resolution: {integrity: sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.1.7':
resolution: {integrity: sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==}
'@next/swc-linux-arm64-musl@15.2.1':
resolution: {integrity: sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.1.7':
resolution: {integrity: sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==}
'@next/swc-linux-x64-gnu@15.2.1':
resolution: {integrity: sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.1.7':
resolution: {integrity: sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==}
'@next/swc-linux-x64-musl@15.2.1':
resolution: {integrity: sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.1.7':
resolution: {integrity: sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==}
'@next/swc-win32-arm64-msvc@15.2.1':
resolution: {integrity: sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.1.7':
resolution: {integrity: sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==}
'@next/swc-win32-x64-msvc@15.2.1':
resolution: {integrity: sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1653,8 +1650,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@22.13.8':
resolution: {integrity: sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==}
'@types/node@22.13.9':
resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==}
'@types/pg@8.11.11':
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
@ -1819,8 +1816,8 @@ packages:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ai@4.1.47:
resolution: {integrity: sha512-9UZ8Mkv1HlprCJfQ0Kq+rgKbfkrkDtJjslr1WOHBRzvC70jDJJYp8r0Qq4vlF7zs9VBkGyy8Rhm5zQJJIUjvgw==}
ai@4.1.50:
resolution: {integrity: sha512-YBNeemrJKDrxoBQd3V9aaxhKm5q5YyRcF7PZE7W0NmLuvsdva/1aQNYTAsxs47gQFdvqfYmlFy4B0E+356OlPA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -2351,8 +2348,8 @@ packages:
engines: {node: '>=6.0'}
hasBin: true
eslint-config-next@15.1.7:
resolution: {integrity: sha512-zXoMnYUIy3XHaAoOhrcYkT9UQWvXqWju2K7NNsmb5wd/7XESDwof61eUdW4QhERr3eJ9Ko/vnXqIrj8kk/drYw==}
eslint-config-next@15.2.1:
resolution: {integrity: sha512-mhsprz7l0no8X+PdDnVHF4dZKu9YBJp2Rf6ztWbXBLJ4h6gxmW//owbbGJMBVUU+PibGJDAqZhW4pt8SC8HSow==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@ -2557,8 +2554,8 @@ packages:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
framer-motion@12.4.7:
resolution: {integrity: sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==}
framer-motion@12.4.10:
resolution: {integrity: sha512-3Msuyjcr1Pb5hjkn4EJcRe1HumaveP0Gbv4DBMKTPKcV/1GSMkQXj+Uqgneys+9DPcZM18Hac9qY9iUEF5LZtg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@ -3288,11 +3285,11 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.4.5:
resolution: {integrity: sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==}
motion-dom@12.4.10:
resolution: {integrity: sha512-ISP5u6FTceoD6qKdLupIPU/LyXBrxGox+P2e3mBbm1+pLdlBbwv01YENJr7+1WZnW5ucVKzFScYsV1eXTCG4Xg==}
motion-utils@12.0.0:
resolution: {integrity: sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==}
motion-utils@12.4.10:
resolution: {integrity: sha512-NPwZd94V013SwRf++jMrk2+HEBgPkeIE2RiOzhAuuQlqxMJPkKt/LXVh6Upl+iN8oarSGD2dlY5/bqgsYXDABA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
@ -3336,8 +3333,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.1.7:
resolution: {integrity: sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==}
next@15.2.1:
resolution: {integrity: sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -4322,7 +4319,7 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.1.19(react@19.0.0)(zod@3.24.2)':
'@ai-sdk/react@1.1.20(react@19.0.0)(zod@3.24.2)':
dependencies:
'@ai-sdk/provider-utils': 2.1.10(zod@3.24.2)
'@ai-sdk/ui-utils': 1.1.16(zod@3.24.2)
@ -5197,27 +5194,27 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
slash: 3.0.0
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))':
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
jest-config: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@ -5242,7 +5239,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-mock: 29.7.0
'@jest/expect-utils@29.7.0':
@ -5260,7 +5257,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@ -5282,7 +5279,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.25
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
collect-v8-coverage: 1.0.2
exit: 0.1.2
@ -5352,7 +5349,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.13.8
'@types/node': 22.13.9
'@types/yargs': 17.0.33
chalk: 4.1.2
@ -5378,45 +5375,41 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@next/bundle-analyzer@15.1.7':
'@next/bundle-analyzer@15.2.1':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/env@15.1.7': {}
'@next/env@15.2.1': {}
'@next/eslint-plugin-next@15.1.7':
'@next/eslint-plugin-next@15.2.1':
dependencies:
fast-glob: 3.3.1
'@next/eslint-plugin-next@15.2.0':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.1.7':
'@next/swc-darwin-arm64@15.2.1':
optional: true
'@next/swc-darwin-x64@15.1.7':
'@next/swc-darwin-x64@15.2.1':
optional: true
'@next/swc-linux-arm64-gnu@15.1.7':
'@next/swc-linux-arm64-gnu@15.2.1':
optional: true
'@next/swc-linux-arm64-musl@15.1.7':
'@next/swc-linux-arm64-musl@15.2.1':
optional: true
'@next/swc-linux-x64-gnu@15.1.7':
'@next/swc-linux-x64-gnu@15.2.1':
optional: true
'@next/swc-linux-x64-musl@15.1.7':
'@next/swc-linux-x64-musl@15.2.1':
optional: true
'@next/swc-win32-arm64-msvc@15.1.7':
'@next/swc-win32-arm64-msvc@15.2.1':
optional: true
'@next/swc-win32-x64-msvc@15.1.7':
'@next/swc-win32-x64-msvc@15.2.1':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -6216,7 +6209,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 22.13.8
'@types/node': 22.13.9
'@types/istanbul-lib-coverage@2.0.6': {}
@ -6235,7 +6228,7 @@ snapshots:
'@types/jsdom@20.0.1':
dependencies:
'@types/node': 22.13.8
'@types/node': 22.13.9
'@types/tough-cookie': 4.0.5
parse5: 7.2.1
@ -6243,13 +6236,13 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/node@22.13.8':
'@types/node@22.13.9':
dependencies:
undici-types: 6.20.0
'@types/pg@8.11.11':
dependencies:
'@types/node': 22.13.8
'@types/node': 22.13.9
pg-protocol: 1.7.1
pg-types: 4.0.2
@ -6365,9 +6358,9 @@ snapshots:
dependencies:
crypto-js: 4.2.0
'@vercel/analytics@1.5.0(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
'@vercel/analytics@1.5.0(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
optionalDependencies:
next: 15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
'@vercel/blob@0.27.2':
@ -6378,9 +6371,9 @@ snapshots:
throttleit: 2.1.0
undici: 5.28.5
'@vercel/speed-insights@1.2.0(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
'@vercel/speed-insights@1.2.0(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
optionalDependencies:
next: 15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
abab@2.0.6: {}
@ -6406,11 +6399,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
ai@4.1.47(react@19.0.0)(zod@3.24.2):
ai@4.1.50(react@19.0.0)(zod@3.24.2):
dependencies:
'@ai-sdk/provider': 1.0.9
'@ai-sdk/provider-utils': 2.1.10(zod@3.24.2)
'@ai-sdk/react': 1.1.19(react@19.0.0)(zod@3.24.2)
'@ai-sdk/react': 1.1.20(react@19.0.0)(zod@3.24.2)
'@ai-sdk/ui-utils': 1.1.16(zod@3.24.2)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
@ -6737,13 +6730,13 @@ snapshots:
cookie@0.7.1: {}
create-jest@29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2)):
create-jest@29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
jest-config: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@ -7029,9 +7022,9 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
eslint-config-next@15.1.7(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2):
eslint-config-next@15.2.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2):
dependencies:
'@next/eslint-plugin-next': 15.1.7
'@next/eslint-plugin-next': 15.2.1
'@rushstack/eslint-patch': 1.10.5
'@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
'@typescript-eslint/parser': 8.24.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
@ -7327,10 +7320,10 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
framer-motion@12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
framer-motion@12.4.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 12.4.5
motion-utils: 12.0.0
motion-dom: 12.4.10
motion-utils: 12.4.10
tslib: 2.8.1
optionalDependencies:
react: 19.0.0
@ -7706,7 +7699,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
co: 4.6.0
dedent: 1.5.3
@ -7726,16 +7719,16 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2)):
jest-cli@29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
create-jest: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
jest-config: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -7745,7 +7738,7 @@ snapshots:
- supports-color
- ts-node
jest-config@29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2)):
jest-config@29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2)):
dependencies:
'@babel/core': 7.26.9
'@jest/test-sequencer': 29.7.0
@ -7770,8 +7763,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.13.8
ts-node: 10.9.2(@types/node@22.13.8)(typescript@5.8.2)
'@types/node': 22.13.9
ts-node: 10.9.2(@types/node@22.13.9)(typescript@5.8.2)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@ -7801,7 +7794,7 @@ snapshots:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/jsdom': 20.0.1
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-mock: 29.7.0
jest-util: 29.7.0
jsdom: 20.0.3
@ -7815,7 +7808,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-mock: 29.7.0
jest-util: 29.7.0
@ -7825,7 +7818,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 22.13.8
'@types/node': 22.13.9
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@ -7864,7 +7857,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-util: 29.7.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@ -7899,7 +7892,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@ -7927,7 +7920,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.2
@ -7973,7 +7966,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@ -7992,7 +7985,7 @@ snapshots:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.13.8
'@types/node': 22.13.9
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@ -8001,17 +7994,17 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 22.13.8
'@types/node': 22.13.9
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2)):
jest@29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@22.13.8)(ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2))
jest-cli: 29.7.0(@types/node@22.13.9)(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -8230,11 +8223,11 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.4.5:
motion-dom@12.4.10:
dependencies:
motion-utils: 12.0.0
motion-utils: 12.4.10
motion-utils@12.0.0: {}
motion-utils@12.4.10: {}
mrmime@2.0.1: {}
@ -8246,10 +8239,10 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.25(next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
next-auth@5.0.0-beta.25(next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
dependencies:
'@auth/core': 0.37.2
next: 15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
@ -8257,9 +8250,9 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
next@15.1.7(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.2.1(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.1.7
'@next/env': 15.2.1
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -8269,14 +8262,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(@babel/core@7.26.9)(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.1.7
'@next/swc-darwin-x64': 15.1.7
'@next/swc-linux-arm64-gnu': 15.1.7
'@next/swc-linux-arm64-musl': 15.1.7
'@next/swc-linux-x64-gnu': 15.1.7
'@next/swc-linux-x64-musl': 15.1.7
'@next/swc-win32-arm64-msvc': 15.1.7
'@next/swc-win32-x64-msvc': 15.1.7
'@next/swc-darwin-arm64': 15.2.1
'@next/swc-darwin-x64': 15.2.1
'@next/swc-linux-arm64-gnu': 15.2.1
'@next/swc-linux-arm64-musl': 15.2.1
'@next/swc-linux-x64-gnu': 15.2.1
'@next/swc-linux-x64-musl': 15.2.1
'@next/swc-win32-arm64-msvc': 15.2.1
'@next/swc-win32-x64-msvc': 15.2.1
'@opentelemetry/api': 1.9.0
sharp: 0.33.5
transitivePeerDependencies:
@ -8980,14 +8973,14 @@ snapshots:
dependencies:
sax: 1.2.4
ts-node@10.9.2(@types/node@22.13.8)(typescript@5.8.2):
ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.2):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 22.13.8
'@types/node': 22.13.9
acorn: 8.14.0
acorn-walk: 8.3.4
arg: 4.1.3

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,10 +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]`;
// Search params
export const SEARCH_PARAM_SHOW = 'show';
export const SEARCH_PARAM_SHOW_RECIPE = 'recipe';
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
// Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
@ -80,6 +78,7 @@ export const PATHS_TO_CACHE = [
PATH_CAMERA_DYNAMIC,
PATH_FILM_SIMULATION_DYNAMIC,
PATH_FOCAL_LENGTH_DYNAMIC,
PATH_RECIPE_DYNAMIC,
...PATHS_ADMIN,
];
@ -110,9 +109,9 @@ export const pathForPhoto = ({
camera,
simulation,
focal,
showRecipe,
}: PhotoPathParams) => {
const path = typeof photo !== 'string' && photo.hidden
recipe,
}: PhotoPathParams) =>
typeof photo !== 'string' && photo.hidden
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
: tag
? `${pathForTag(tag)}/${getPhotoId(photo)}`
@ -122,11 +121,9 @@ export const pathForPhoto = ({
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}`
: focal
? `${pathForFocalLength(focal)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
return showRecipe
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
: path;
};
: recipe
? `${pathForRecipe(recipe)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`;
@ -140,6 +137,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 +152,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 +171,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

@ -33,7 +33,7 @@ export default function HeaderList({
)}
>
{icon &&
<span className="w-[1rem]">
<span className="text-icon w-[1rem]">
{icon}
</span>}
{title}

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

@ -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 * .003}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

@ -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

@ -10,6 +10,7 @@ import PhotoGridContainer from './PhotoGridContainer';
import { useEffect } from 'react';
import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { Recipes } from '@/recipe';
export default function PhotoGridPage({
photos,
@ -17,12 +18,14 @@ export default function PhotoGridPage({
tags,
cameras,
simulations,
recipes,
}: {
photos: Photo[]
photosCount: number
tags: Tags
cameras: Cameras
simulations: FilmSimulations
recipes: Recipes
}) {
const { setSelectedPhotoIds } = useAppState();
@ -63,6 +66,7 @@ export default function PhotoGridPage({
tags,
cameras,
simulations,
recipes,
photosCount,
}}
/>

View File

@ -21,17 +21,22 @@ import {
safelyParseFormattedHtml,
} from '@/utility/html';
import { clsx } from 'clsx/lite';
import { Recipes, sortRecipesWithCount } from '@/recipe';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import { TbChecklist } from 'react-icons/tb';
export default function PhotoGridSidebar({
tags,
cameras,
simulations,
recipes,
photosCount,
photosDateRange,
}: {
tags: Tags
cameras: Cameras
simulations: FilmSimulations
recipes: Recipes
photosCount: number
photosDateRange?: PhotoDateRange
}) {
@ -48,7 +53,7 @@ export default function PhotoGridSidebar({
title='Tags'
icon={<FaTag
size={12}
className="text-icon translate-y-[1px]"
className="translate-y-[1px]"
/>}
items={tagsIncludingHidden.map(({ tag, count }) => {
switch (tag) {
@ -90,7 +95,7 @@ export default function PhotoGridSidebar({
title="Cameras"
icon={<IoMdCamera
size={13}
className="text-icon translate-y-[-0.25px]"
className="translate-y-[-0.25px]"
/>}
items={cameras
.sort(sortCamerasWithCount)
@ -108,6 +113,28 @@ export default function PhotoGridSidebar({
/>
: null;
const recipesContent = recipes.length > 0
? <HeaderList
title="Recipes"
icon={<TbChecklist
size={16}
className="translate-x-[-1px]"
/>}
items={recipes
.sort(sortRecipesWithCount)
.map(({ recipe, count }) =>
<PhotoRecipe
key={recipe}
recipe={recipe}
type="text-only"
countOnHover={count}
prefetch={false}
contrast="low"
badged
/>)}
/>
: null;
const filmsContent = simulations.length > 0
? <HeaderList
title="Films"
@ -161,6 +188,7 @@ export default function PhotoGridSidebar({
{SHOW_SIDEBAR_CAMERAS_FIRST
? <>{camerasContent}{tagsContent}</>
: <>{tagsContent}{camerasContent}</>}
{recipesContent}
{filmsContent}
{photoStatsContent}
</div>

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 = () =>
@ -149,6 +151,7 @@ export default function PhotoHeader({
tag={tag}
camera={camera}
simulation={simulation}
recipe={recipe}
focal={focal}
count={count}
dateRange={dateRange}

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';
@ -38,11 +38,12 @@ 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 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,25 @@ export default function PhotoLarge({
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const refRecipe = useRef<HTMLDivElement>(null);
const refRecipeTrigger = useRef<HTMLButtonElement>(null);
const refRecipeButton = useRef<HTMLButtonElement>(null);
const refTriggers = useMemo(() => [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 +141,7 @@ export default function PhotoLarge({
const hasMetaContent =
showCameraContent ||
showTagsContent ||
showRecipeContent ||
showExifContent;
const hasNonDateContent =
@ -185,11 +195,11 @@ export default function PhotoLarge({
)}>
<AnimatePresence>
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
photo.fujifilmRecipe &&
photo.recipeData &&
photo.filmSimulation &&
<PhotoRecipe
<PhotoRecipeOverlay
ref={refRecipe}
recipe={photo.fujifilmRecipe}
recipe={photo.recipeData}
simulation={photo.filmSimulation}
iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted}
@ -250,7 +260,7 @@ export default function PhotoLarge({
)}>
{photo.caption}
</div>}
{(showCameraContent || showTagsContent) &&
{(showCameraContent || showRecipeContent || showTagsContent) &&
<div>
{showCameraContent &&
<PhotoCamera
@ -258,6 +268,12 @@ export default function PhotoLarge({
contrast="medium"
prefetch={prefetchRelatedLinks}
/>}
{showRecipeContent &&
<PhotoRecipe
recipe={recipe}
contrast="medium"
prefetch={prefetchRelatedLinks}
/>}
{showTagsContent &&
<PhotoTags
tags={tags}
@ -270,7 +286,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 +326,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 +334,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 +393,9 @@ export default function PhotoLarge({
simulation={shouldShareSimulation
? photo.filmSimulation
: undefined}
recipe={shouldShareRecipe
? recipe
: undefined}
focal={shouldShareFocalLength
? photo.focalLength
: undefined}

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

@ -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 },
);
@ -71,9 +73,10 @@ export default function PhotoPrevNext({
pathForPhoto({
photo: nextPhoto,
tag,
camera,
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

@ -1,52 +0,0 @@
'use client';
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 { 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',
)}>
<PhotoRecipe {...{
recipe,
simulation: photo.filmSimulation ?? 'provia',
exposure: photo.exposureCompensationFormatted ?? '+0ev',
iso: photo.isoFormatted ?? 'ISO 0',
}} />
</div>
</div>
);
}

View File

@ -6,9 +6,10 @@ import {
import {
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueRecipes,
getUniqueTags,
} from '@/photo/db/query';
import { SHOW_FILM_SIMULATIONS } from '@/app/config';
import { SHOW_FILM_SIMULATIONS, SHOW_RECIPES } from '@/app/config';
import { sortTagsObject } from '@/tag';
export const getPhotoSidebarData = () => [
@ -17,6 +18,9 @@ export const getPhotoSidebarData = () => [
SHOW_FILM_SIMULATIONS
? getUniqueFilmSimulations().catch(() => [])
: [],
SHOW_RECIPES
? getUniqueRecipes().catch(() => [])
: [],
] as const;
export const getPhotoSidebarDataCached = () => [

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,11 +111,18 @@ const FORM_METADATA = (
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
},
fujifilmRecipe: {
type: 'textarea',
label: 'fujifilm recipe',
recipeTitle: {
label: 'recipe title',
spellCheck: false,
capitalize: false,
},
recipeData: {
type: 'textarea',
label: 'recipe data',
spellCheck: false,
capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
validate: value => {
let validationMessage = undefined;
if (value) {
@ -127,8 +134,6 @@ const FORM_METADATA = (
}
return validationMessage;
},
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
},
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
@ -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 {
@ -113,6 +114,7 @@ export interface PhotoSetCategory {
camera?: Camera
simulation?: FilmSimulation
focal?: number
recipe?: string
lens?: Lens // Unimplemented as a set
}
@ -141,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),
@ -164,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

@ -0,0 +1,62 @@
import { pathForRecipe } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import { TbChecklist } from 'react-icons/tb';
import { formatRecipe } from '.';
import clsx from 'clsx/lite';
import { RefObject } from 'react';
export default function PhotoRecipe({
recipe,
type,
badged,
contrast,
prefetch,
countOnHover,
className,
refButton,
isOpen,
recipeOnClick,
}: {
recipe: string
refButton?: RefObject<HTMLButtonElement | null>
isOpen?: boolean
recipeOnClick?: () => void
countOnHover?: number
} & EntityLinkExternalProps) {
return (
<div className="flex w-full gap-2">
<EntityLink
title="Recipe"
label={formatRecipe(recipe)}
href={pathForRecipe(recipe)}
icon={<TbChecklist
size={16}
className={clsx(
badged && 'translate-x-[-1px] translate-y-[0.5px]',
)}
/>}
className={className}
type={type}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverEntity={countOnHover}
/>
{recipeOnClick &&
<button
ref={refButton}
onClick={recipeOnClick}
className={clsx(
'self-start',
'px-1 py-0.5',
'text-[10px] text-main font-medium tracking-wider',
'translate-y-[0.5px]',
)}
>
{isOpen ? 'CLOSE' : 'RECIPE'}
</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 PhotoRecipeOverlay({
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

@ -0,0 +1,55 @@
'use client';
import { Photo, PhotoDateRange } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState';
import { descriptionForRecipePhotos, photoHasRecipe } from '.';
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 = photoHasRecipe(selectedPhoto)
? selectedPhoto
: photos.find(photoHasRecipe);
return (
<PhotoHeader
recipe={recipe}
entity={<PhotoRecipe
recipe={recipe}
contrast="high"
recipeOnClick={() => (
photo?.recipeData &&
photo?.filmSimulation
) ? setRecipeModalProps?.({
simulation: photo.filmSimulation,
recipe: photo.recipeData,
iso: photo.isoFormatted,
exposure: photo.exposureTimeFormatted,
})
: undefined}
/>}
entityDescription={descriptionForRecipePhotos(photos, undefined, count)}
photos={photos}
selectedPhoto={selectedPhoto}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
includeShareButton
/>
);
}

View File

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

View File

@ -0,0 +1,42 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
import OGTile from '@/components/OGTile';
import { descriptionForRecipePhotos, titleForRecipe } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function RecipeOGTile({
recipe,
photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count,
dateRange,
}: {
recipe: string
photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number
dateRange?: PhotoDateRange
}) {
return (
<OGTile {...{
title: titleForRecipe(recipe, photos, count),
description: descriptionForRecipePhotos(photos, true, count, dateRange),
path: pathForRecipe(recipe),
pathImageAbsolute: absolutePathForRecipeImage(recipe),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/>
);
};

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,
}} />
);
}

View File

@ -0,0 +1,23 @@
import { absolutePathForRecipe } from '@/app/paths';
import { PhotoSetAttributes } from '../photo';
import ShareModal from '@/share/ShareModal';
import { shareTextForRecipe } from '.';
import RecipeOGTile from './RecipeOGTile';
export default function RecipeShareModal({
recipe,
photos,
count,
dateRange,
}: {
recipe: string
} & PhotoSetAttributes) {
return (
<ShareModal
pathShare={absolutePathForRecipe(recipe)}
socialText={shareTextForRecipe(recipe)}
>
<RecipeOGTile {...{ recipe, photos, count, dateRange }} />
</ShareModal>
);
};

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 }),
]);

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

@ -0,0 +1,71 @@
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo';
import { PhotoDateRange } from '@/photo';
import { capitalizeWords } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
export type RecipeWithCount = {
recipe: string
count: number
}
export type Recipes = RecipeWithCount[]
export interface RecipeProps {
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
}
export const formatRecipe = (recipe?: string) =>
capitalizeWords(recipe?.replaceAll('-', ' '));
export const titleForRecipe = (
recipe: string,
photos:Photo[] = [],
explicitCount?: number,
) => [
`Recipe: ${formatRecipe(recipe)}`,
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
export const shareTextForRecipe = (recipe: string) =>
`${formatRecipe(recipe)} recipe photos`;
export const descriptionForRecipePhotos = (
photos: Photo[] = [],
dateBased?: boolean,
explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) =>
descriptionForPhotoSet(
photos,
undefined,
dateBased,
explicitCount,
explicitDateRange,
);
export const generateMetaForRecipe = (
recipe: string,
photos: Photo[],
explicitCount?: number,
explicitDateRange?: PhotoDateRange,
) => ({
url: absolutePathForRecipe(recipe),
title: titleForRecipe(recipe, photos, explicitCount),
description:
descriptionForRecipePhotos(photos, true, explicitCount, explicitDateRange),
images: absolutePathForRecipeImage(recipe),
});
export const photoHasRecipe = (photo?: Photo) =>
photo?.filmSimulation && photo?.recipeData;
export const sortRecipesWithCount = (
a: RecipeWithCount,
b: RecipeWithCount,
) =>
a.recipe.localeCompare(b.recipe);

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

@ -6,6 +6,7 @@ import CameraShareModal from '@/camera/CameraShareModal';
import FilmSimulationShareModal from '@/simulation/FilmSimulationShareModal';
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
import { useAppState } from '@/state/AppState';
import RecipeShareModal from '@/recipe/RecipeShareModal';
export default function ShareModals() {
const { shareModalProps = {} } = useAppState();
@ -18,6 +19,7 @@ export default function ShareModals() {
tag,
camera,
simulation,
recipe,
focal,
} = shareModalProps;
@ -33,6 +35,8 @@ export default function ShareModals() {
return <FilmSimulationShareModal {...{simulation, ...attributes}} />;
} else if (focal !== undefined) {
return <FocalLengthShareModal {...{focal, ...attributes}} />;
} else if (recipe) {
return <RecipeShareModal {...{recipe, ...attributes}} />;
}
}
}

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}

View File

@ -51,9 +51,9 @@ export const shareTextForTag = (tag: string) =>
export const sortTags = (
tags: string[],
tagToHide?: string,
tagToExclude?: string,
) => tags
.filter(tag => tag !== tagToHide)
.filter(tag => tag !== tagToExclude)
.sort((a, b) => isTagFavs(a) ? -1 : a.localeCompare(b));
export const sortTagsObject = (