Create recipe auto-chooser in photo form
This commit is contained in:
parent
482c9119c9
commit
b114bca43e
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user