diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 8351b47b..8d07482d 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -12,6 +12,7 @@ import { getUniqueTags, deletePhotoRecipeGlobally, renamePhotoRecipeGlobally, + getPhotosNeedingRecipeTitleCount, } from '@/photo/db/query'; import { GetPhotosOptions, areOptionsSensitive } from './db'; import { @@ -305,6 +306,13 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) => } }); +export const getPhotosNeedingRecipeTitleCountAction = async ( + recipeData: string, +) => + runAuthenticatedAdminServerAction(async () => + await getPhotosNeedingRecipeTitleCount(recipeData), + ); + export const deletePhotoRecipeGloballyAction = async (formData: FormData) => runAuthenticatedAdminServerAction(async () => { const recipe = formData.get('recipe') as string; diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 3e742f4d..7c99191f 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -359,6 +359,14 @@ export const getRecipeTitleForData = async (data: string | object) => .then(({ rows }) => rows[0]?.recipe_title as string | undefined) , 'getRecipeTitleForData'); +export const getPhotosNeedingRecipeTitleCount = async (data: string) => + safelyQueryPhotos(() => sql` + SELECT COUNT(*) + FROM photos + WHERE recipe_title IS NULL AND recipe_data = ${data} + `.then(({ rows }) => parseInt(rows[0].count, 10)) + , 'getPhotosNeedingRecipeTitleCount'); + export const updateAllMatchingRecipeTitles = ( title: string, data: string, diff --git a/src/photo/form/ApplyRecipesGloballyCheckbox.tsx b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx new file mode 100644 index 00000000..1bd911ab --- /dev/null +++ b/src/photo/form/ApplyRecipesGloballyCheckbox.tsx @@ -0,0 +1,34 @@ +import FieldSetWithStatus from '@/components/FieldSetWithStatus'; +import { ComponentProps, useEffect, useState } from 'react'; +import { getPhotosNeedingRecipeTitleCountAction } from '../actions'; + +export default function ApplyRecipeTitleGloballyCheckbox({ + recipeTitle, + hasRecipeTitleChanged, + recipeData, + ...props +}: ComponentProps & { + recipeTitle?: string + hasRecipeTitleChanged?: boolean + recipeData?: string +}) { + const [matchingPhotosCount, setMatchingPhotosCount] = useState(); + + useEffect(() => { + if (recipeTitle && hasRecipeTitleChanged && recipeData) { + setMatchingPhotosCount(undefined); + getPhotosNeedingRecipeTitleCountAction(recipeData) + .then(setMatchingPhotosCount); + } else { + setMatchingPhotosCount(0); + } + }, [recipeTitle, hasRecipeTitleChanged, recipeData]); + + return ( + + ); +} diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 86703a7d..0084861a 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { ComponentProps, useEffect, useMemo, useState } from 'react'; import { FIELDS_WITH_JSON, FORM_METADATA_ENTRIES, + FormFields, + FormMeta, PhotoFormData, convertFormKeysToLabels, formHasTextContent, @@ -29,10 +31,10 @@ import { useAppState } from '@/state/AppState'; import UpdateBlurDataButton from '../UpdateBlurDataButton'; 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'; import deepEqual from 'fast-deep-equal/es6/react'; +import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox'; const THUMBNAIL_SIZE = 300; @@ -223,9 +225,9 @@ export default function PhotoForm({ }; const shouldHideField = ( - key: keyof PhotoDbInsert | 'favorite', + key: FormFields, hideIfEmpty?: boolean, - shouldHide?: (formData: Partial) => boolean, + shouldHide?: FormMeta['shouldHide'], ) => { if ( key === 'blurData' && @@ -237,7 +239,7 @@ export default function PhotoForm({ } else { return ( (hideIfEmpty && !formData[key]) || - shouldHide?.(formData) + shouldHide?.(formData, changedFormKeys) ); } }; @@ -326,25 +328,27 @@ export default function PhotoForm({ shouldHide, loadingMessage, type, - }]) => - !shouldHideField(key, hideIfEmpty, shouldHide) && - { + if (!shouldHideField(key, hideIfEmpty, shouldHide)) { + const fieldProps: ComponentProps = { + id: key, + label: label + ( key === 'blurData' && shouldDebugImageFallbacks ? ` (${(formData[key] ?? '').length} chars.)` : '' - )} - note={note} - error={formErrors[key]} - value={formData[key] ?? ''} - isModified={changedFormKeys.includes(key)} - onChange={value => { + ), + note, + error: formErrors[key], + value: formData[key] ?? '', + isModified: changedFormKeys.includes(key), + onChange: value => { const formUpdated = { ...formData, [key]: value }; setFormData(formUpdated); if (validate) { - setFormErrors({ ...formErrors, [key]: validate(value) }); + setFormErrors({ + ...formErrors, [key]: + validate(value), + }); } else if (validateStringMaxLength !== undefined) { setFormErrors({ ...formErrors, @@ -356,26 +360,41 @@ export default function PhotoForm({ if (key === 'title') { onTitleChange?.(value.trim()); } - }} - selectOptions={selectOptions} - selectOptionsDefaultLabel={selectOptionsDefaultLabel} - tagOptions={tagOptions} - tagOptionsLimit={tagOptionsLimit} - // eslint-disable-next-line max-len - tagOptionsLimitValidationMessage={tagOptionsLimitValidationMessage} - required={required} - readOnly={readOnly} - spellCheck={spellCheck} - capitalize={capitalize} - placeholder={loadingMessage && !formData[key] + }, + selectOptions, + selectOptionsDefaultLabel: selectOptionsDefaultLabel, + tagOptions, + tagOptionsLimit, + tagOptionsLimitValidationMessage, + required, + readOnly, + spellCheck, + capitalize, + placeholder: loadingMessage && !formData[key] ? loadingMessage - : undefined} - loading={ + : undefined, + loading: ( (loadingMessage && !formData[key] ? true : false) || - isFieldGeneratingAi(key)} - type={type} - accessory={accessoryForField(key)} - />)} + isFieldGeneratingAi(key) + ), + type, + accessory: accessoryForField(key), + }; + return key === 'applyRecipeTitleGlobally' + ? + : ; + } + })} +export type FormFields = keyof PhotoDbInsert | VirtualFields; + +export type PhotoFormData = Record export type FieldSetType = 'text' | @@ -42,7 +46,7 @@ export type AnnotatedTag = { annotationAria?: string, }; -type FormMeta = { +export type FormMeta = { label: string note?: string required?: boolean @@ -52,9 +56,11 @@ type FormMeta = { validateStringMaxLength?: number spellCheck?: boolean capitalize?: boolean - hide?: boolean hideIfEmpty?: boolean - shouldHide?: (formData: Partial) => boolean + shouldHide?: ( + formData: Partial, + changedFormKeys?: (keyof PhotoFormData)[], + ) => boolean loadingMessage?: string type?: FieldSetType selectOptions?: { value: string, label: string }[] @@ -96,7 +102,7 @@ const FORM_METADATA = ( label: 'semantic description (not visible)', capitalize: true, validateStringMaxLength: STRING_MAX_LENGTH_LONG, - hide: !aiTextGeneration, + shouldHide: () => !aiTextGeneration, }, id: { label: 'id', readOnly: true, hideIfEmpty: true }, blurData: { @@ -124,6 +130,18 @@ const FORM_METADATA = ( capitalize: false, shouldHide: ({ make }) => make !== MAKE_FUJIFILM, }, + applyRecipeTitleGlobally: { + label: 'apply recipe title globally', + type: 'checkbox', + excludeFromInsert: true, + shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) => + !( + make === MAKE_FUJIFILM && + recipeData && + recipeTitle && + changedFormKeys?.includes('recipeTitle') + ), + }, recipeData: { type: 'textarea', label: 'recipe data', @@ -152,7 +170,7 @@ const FORM_METADATA = ( iso: { label: 'ISO' }, exposureTime: { label: 'exposure time' }, exposureCompensation: { label: 'exposure compensation' }, - locationName: { label: 'location name', hide: true }, + locationName: { label: 'location name', shouldHide: () => true }, latitude: { label: 'latitude' }, longitude: { label: 'longitude' }, takenAt: { @@ -180,8 +198,7 @@ export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC = export const FORM_METADATA_ENTRIES = ( ...args: Parameters ) => - (Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]) - .filter(([_, meta]) => !meta.hide); + (Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]); export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) => keys.map(key => FORM_METADATA()[key].label.toUpperCase());