Create recipe auto-chooser in photo form

This commit is contained in:
Sam Becker 2025-03-05 22:00:42 -08:00
parent 482c9119c9
commit b114bca43e
9 changed files with 107 additions and 25 deletions

View File

@ -1,11 +1,16 @@
import { redirect } from 'next/navigation';
import { getPhotoNoStore, getUniqueTagsCached } from '@/photo/cache';
import {
getPhotoNoStore,
getUniqueRecipesCached,
getUniqueTagsCached,
} from '@/photo/cache';
import { PATH_ADMIN } from '@/app/paths';
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
import {
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
IS_PREVIEW,
SHOW_RECIPES,
} from '@/app/config';
import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server';
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
@ -23,6 +28,10 @@ export default async function PhotoEditPage({
const uniqueTags = await getUniqueTagsCached();
const uniqueRecipes = SHOW_RECIPES
? await getUniqueRecipesCached()
: [];
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
// Only generate image thumbnails when AI generation is enabled
@ -42,6 +51,7 @@ export default async function PhotoEditPage({
<PhotoEditPageClient {...{
photo,
uniqueTags,
uniqueRecipes,
hasAiTextGeneration,
imageThumbnailBase64,
blurData,

View File

@ -19,6 +19,8 @@ export default function FieldSetWithStatus({
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
tagOptionsLimit,
tagOptionsLimitValidationMessage,
placeholder,
loading,
required,
@ -40,6 +42,8 @@ export default function FieldSetWithStatus({
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number
tagOptionsLimitValidationMessage?: string
placeholder?: string
loading?: boolean
required?: boolean
@ -133,6 +137,8 @@ export default function FieldSetWithStatus({
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading}
placeholder={placeholder}
limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage}
/>
: type === 'textarea'
? <textarea

View File

@ -18,6 +18,8 @@ export default function TagInput({
className,
readOnly,
placeholder,
limit,
limitValidationMessage,
}: {
id?: string
name: string
@ -27,6 +29,8 @@ export default function TagInput({
className?: string
readOnly?: boolean
placeholder?: string
limit?: number
limitValidationMessage?: string
}) {
const containerRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -44,14 +48,19 @@ export default function TagInput({
convertStringToArray(value) ?? []
, [value]);
const hasReachedLimit = useMemo(() =>
limit !== undefined && selectedOptions.length >= limit
, [limit, selectedOptions]);
const inputTextFormatted = parameterize(inputText);
const isInputTextUnique =
inputTextFormatted &&
!optionValues.includes(inputTextFormatted) &&
!selectedOptions.includes(inputTextFormatted);
const optionsFiltered = useMemo<AnnotatedTag[]>(() =>
(isInputTextUnique
const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit
? [{ value: limitValidationMessage ?? `Tag limit reached (${limit})` }]
: (isInputTextUnique
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
: []
).concat(options
@ -61,7 +70,15 @@ export default function TagInput({
!inputTextFormatted ||
value.includes(inputTextFormatted)
)))
, [inputTextFormatted, isInputTextUnique, options, selectedOptions]);
, [
hasReachedLimit,
inputTextFormatted,
isInputTextUnique,
limit,
limitValidationMessage,
options,
selectedOptions,
]);
const hideMenu = useCallback((shouldBlurInput?: boolean) => {
setShouldShowMenu(false);
@ -88,8 +105,14 @@ export default function TagInput({
}
setSelectedOptionIndex(undefined);
setInputText('');
if (limit !== undefined && limit - 1 >= selectedOptions.length) {
hideMenu(true);
} else {
inputRef.current?.focus();
}, [onChange, selectedOptions]);
}
}, [limit, selectedOptions, onChange, hideMenu]);
const removeOption = useCallback((option: string) => {
onChange?.(selectedOptions.filter(o =>
@ -103,7 +126,6 @@ export default function TagInput({
if (inputText) {
if (inputText.includes(',')) {
addOptions(inputText.split(','));
setInputText('');
} else {
setShouldShowMenu(true);
}
@ -136,11 +158,15 @@ export default function TagInput({
case 'Enter':
// Only trap focus if there are options to select
// otherwise allow form to submit
if (shouldShowMenu && optionsFiltered.length > 0) {
if (
shouldShowMenu &&
optionsFiltered.length > 0
) {
e.stopImmediatePropagation();
e.preventDefault();
if (!hasReachedLimit) {
addOptions([optionsFiltered[selectedOptionIndex ?? 0].value]);
setInputText('');
}
}
break;
case 'ArrowDown':
@ -197,6 +223,8 @@ export default function TagInput({
optionsFiltered,
addOptions,
shouldShowMenu,
hasReachedLimit,
limit,
]);
return (
@ -205,6 +233,7 @@ export default function TagInput({
className="flex flex-col w-full group"
onFocus={() => setShouldShowMenu(true)}
onBlur={e => {
setInputText('');
if (!e.currentTarget.contains(e.relatedTarget)) {
hideMenu();
}
@ -258,7 +287,7 @@ export default function TagInput({
ref={inputRef}
type="text"
className={clsx(
'grow min-w-0! p-0! -my-2 text-xl',
'grow min-w-0! p-0! -my-2',
'outline-hidden border-none',
'placeholder:text-dim placeholder:text-[14px]',
'placeholder:translate-x-[2px]',
@ -307,11 +336,12 @@ export default function TagInput({
}
tabIndex={0}
className={clsx(
'text-base',
'group flex items-center gap-1',
'cursor-pointer select-none',
'px-1.5 py-1 rounded-xs',
'text-base select-none',
hasReachedLimit ? 'cursor-not-allowed' : 'cursor-pointer',
'hover:bg-gray-100 dark:hover:bg-gray-800',
!hasReachedLimit &&
'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800',
index === 0 && selectedOptionIndex === undefined &&
@ -319,8 +349,9 @@ export default function TagInput({
'outline-hidden',
)}
onClick={() => {
if (!hasReachedLimit) {
addOptions([value]);
setInputText('');
}
}}
onFocus={() => setSelectedOptionIndex(index)}
>

View File

@ -10,16 +10,19 @@ import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent';
import ExifSyncButton from '@/admin/ExifSyncButton';
import { useState } from 'react';
import { Recipes } from '@/recipe';
export default function PhotoEditPageClient({
photo,
uniqueTags,
uniqueRecipes,
hasAiTextGeneration,
imageThumbnailBase64,
blurData,
}: {
photo: Photo
uniqueTags: Tags
uniqueRecipes: Recipes
hasAiTextGeneration: boolean
imageThumbnailBase64: string
blurData: string
@ -67,6 +70,7 @@ export default function PhotoEditPageClient({
updatedExifData={updatedExifData}
updatedBlurData={blurData}
uniqueTags={uniqueTags}
uniqueRecipes={uniqueRecipes}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}
onTextContentChange={setHasTextContent}

View File

@ -120,8 +120,7 @@ export default function PhotoGridSidebar({
size={16}
className="translate-x-[-1px]"
/>}
items={recipes
.sort(sortRecipesWithCount)
items={sortRecipesWithCount(recipes)
.map(({ recipe, count }) =>
<PhotoRecipe
key={recipe}

View File

@ -16,6 +16,7 @@ import {
getPhotosMeta,
getUniqueFocalLengths,
getUniqueLenses,
getUniqueRecipes,
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
@ -38,10 +39,11 @@ import { createLensKey } from '@/lens';
const KEY_PHOTOS = 'photos';
const KEY_PHOTO = 'photo';
// Field keys
const KEY_TAGS = 'tags';
const KEY_CAMERAS = 'cameras';
const KEY_LENSES = 'lenses';
const KEY_TAGS = 'tags';
const KEY_FILM_SIMULATIONS = 'film-simulations';
const KEY_RECIPES = 'recipes';
const KEY_FOCAL_LENGTHS = 'focal-lengths';
// Type keys
const KEY_COUNT = 'count';
@ -214,6 +216,12 @@ export const getUniqueFilmSimulationsCached =
[KEY_PHOTOS, KEY_FILM_SIMULATIONS],
);
export const getUniqueRecipesCached =
unstable_cache(
getUniqueRecipes,
[KEY_PHOTOS, KEY_RECIPES],
);
export const getUniqueFocalLengthsCached =
unstable_cache(
getUniqueFocalLengths,

View File

@ -30,6 +30,7 @@ import { getNextImageUrlForManipulation } from '@/platforms/next-image';
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
import { PhotoDbInsert } from '..';
import ErrorNote from '@/components/ErrorNote';
import { convertRecipesForForm, Recipes } from '@/recipe';
const THUMBNAIL_SIZE = 300;
@ -39,6 +40,7 @@ export default function PhotoForm({
updatedExifData,
updatedBlurData,
uniqueTags,
uniqueRecipes,
aiContent,
shouldStripGpsData,
onTitleChange,
@ -50,6 +52,7 @@ export default function PhotoForm({
updatedExifData?: Partial<PhotoFormData>
updatedBlurData?: string
uniqueTags?: Tags
uniqueRecipes?: Recipes
aiContent?: AiContent
shouldStripGpsData?: boolean
onTitleChange?: (updatedTitle: string) => void
@ -293,6 +296,7 @@ export default function PhotoForm({
<div className="space-y-6">
{FORM_METADATA_ENTRIES(
convertTagsForForm(uniqueTags),
convertRecipesForForm(uniqueRecipes),
aiContent !== undefined,
)
.map(([key, {
@ -302,6 +306,8 @@ export default function PhotoForm({
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
tagOptionsLimit,
tagOptionsLimitValidationMessage,
readOnly,
validate,
validateStringMaxLength,
@ -345,6 +351,9 @@ export default function PhotoForm({
selectOptions={selectOptions}
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
tagOptions={tagOptions}
tagOptionsLimit={tagOptionsLimit}
// eslint-disable-next-line max-len
tagOptionsLimitValidationMessage={tagOptionsLimitValidationMessage}
required={required}
readOnly={readOnly}
spellCheck={spellCheck}

View File

@ -60,6 +60,8 @@ type FormMeta = {
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number
tagOptionsLimitValidationMessage?: string
shouldNotOverwriteWithNullDataOnSync?: boolean
};
@ -68,6 +70,7 @@ const STRING_MAX_LENGTH_LONG = 1000;
const FORM_METADATA = (
tagOptions?: AnnotatedTag[],
recipeOptions?: AnnotatedTag[],
aiTextGeneration?: boolean,
): Record<keyof PhotoFormData, FormMeta> => ({
title: {
@ -113,6 +116,9 @@ const FORM_METADATA = (
},
recipeTitle: {
label: 'recipe title',
tagOptions: recipeOptions,
tagOptionsLimit: 1,
tagOptionsLimitValidationMessage: 'Photos can only have one recipe',
spellCheck: false,
capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,

View File

@ -1,7 +1,11 @@
import { absolutePathForRecipe, absolutePathForRecipeImage } from '@/app/paths';
import { descriptionForPhotoSet, Photo, photoQuantityText } from '@/photo';
import { PhotoDateRange } from '@/photo';
import { capitalizeWords } from '@/utility/string';
import {
capitalizeWords,
formatCount,
formatCountDescriptive,
} from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
@ -64,8 +68,13 @@ export const generateMetaForRecipe = (
export const photoHasRecipe = (photo?: Photo) =>
photo?.filmSimulation && photo?.recipeData;
export const sortRecipesWithCount = (
a: RecipeWithCount,
b: RecipeWithCount,
) =>
a.recipe.localeCompare(b.recipe);
export const sortRecipesWithCount = (recipes: Recipes = []) =>
recipes.sort((a, b) => a.recipe.localeCompare(b.recipe));
export const convertRecipesForForm = (recipes: Recipes = []) =>
sortRecipesWithCount(recipes)
.map(({ recipe, count }) => ({
value: recipe,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
}));