'use client'; import { ComponentProps, useCallback, useEffect, useMemo, useState, } from 'react'; import { FIELDS_WITH_JSON, FORM_METADATA_ENTRIES, FormFields, FormMeta, PhotoFormData, convertFormKeysToLabels, formHasTextContent, getChangedFormFields, getFormErrors, isFormValid, } from '.'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import { createPhotoAction, updatePhotoAction } from '../actions'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { clsx } from 'clsx/lite'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/app/paths'; import { toastSuccess, toastWarning } from '@/toast'; import { getDimensionsFromSize } from '@/utility/size'; import ImageWithFallback from '@/components/image/ImageWithFallback'; import { Tags, convertTagsForForm } from '@/tag'; import { AiContent } from '../ai/useAiImageQueries'; import AiButton from '../ai/AiButton'; import Spinner from '@/components/Spinner'; import usePreventNavigation from '@/utility/usePreventNavigation'; import { useAppState } from '@/app/AppState'; import UpdateBlurDataButton from '../UpdateBlurDataButton'; import { getNextImageUrlForManipulation } from '@/platforms/next-image'; import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config'; import ErrorNote from '@/components/ErrorNote'; import { convertRecipesForForm, Recipes } from '@/recipe'; import deepEqual from 'fast-deep-equal/es6/react'; import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox'; import { convertFilmsForForm, Films } from '@/film'; import { isMakeFujifilm } from '@/platforms/fujifilm'; import PhotoFilmIcon from '@/film/PhotoFilmIcon'; import FieldsetFavs from './FieldsetFavs'; import FieldsetPrivate from './FieldsetPrivate'; import { useAppText } from '@/i18n/state/client'; import IconAddUpload from '@/components/icons/IconAddUpload'; import FieldsetExclude from './FieldsetExclude'; const THUMBNAIL_SIZE = 300; export default function PhotoForm({ type = 'create', initialPhotoForm, updatedExifData, updatedBlurData, uniqueTags, uniqueRecipes, uniqueFilms, aiContent, shouldStripGpsData, onTitleChange, onTextContentChange, onFormStatusChange, }: { type?: 'create' | 'edit' initialPhotoForm: Partial updatedExifData?: Partial updatedBlurData?: string uniqueTags?: Tags uniqueRecipes?: Recipes uniqueFilms?: Films aiContent?: AiContent shouldStripGpsData?: boolean onTitleChange?: (updatedTitle: string) => void onTextContentChange?: (hasContent: boolean) => void, onFormStatusChange?: (pending: boolean) => void }) { const [formData, setFormData] = useState>(initialPhotoForm); const [formErrors, setFormErrors] = useState(getFormErrors(initialPhotoForm)); const [formActionErrorMessage, setFormActionErrorMessage] = useState(''); const { invalidateSwr, shouldDebugImageFallbacks } = useAppState(); const appText = useAppText(); const changedFormKeys = useMemo(() => getChangedFormFields(initialPhotoForm, formData), [initialPhotoForm, formData]); const formHasChanged = changedFormKeys.length > 0; const onlyChangedFieldIsBlurData = changedFormKeys.length === 1 && changedFormKeys[0] === 'blurData'; usePreventNavigation(formHasChanged && !onlyChangedFieldIsBlurData); const canFormBeSubmitted = (type === 'create' || formHasChanged) && isFormValid(formData) && !aiContent?.isLoading; // Update form when EXIF data // is refreshed by parent useEffect(() => { if (Object.keys(updatedExifData ?? {}).length > 0) { const changedKeys: (keyof PhotoFormData)[] = []; setFormData(currentForm => { (Object.entries(updatedExifData ?? {}) as [keyof PhotoFormData, string][]) .forEach(([key, value]) => { let a = currentForm[key]; let b = value; if (FIELDS_WITH_JSON.includes(key)) { a = a ? JSON.parse(a) : undefined; b = b ? JSON.parse(b) : undefined; } if (!deepEqual(a, b)) { changedKeys.push(key as keyof PhotoFormData); } }); return { ...currentForm, ...updatedExifData, }; }); if (changedKeys.length > 0) { const fields = convertFormKeysToLabels(changedKeys); toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000); } else { toastWarning('No new EXIF data found'); } } }, [updatedExifData]); const { width, height, } = getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio); const url = formData.url ?? ''; useEffect(() => { if (updatedBlurData) { setFormData(data => updatedBlurData ? { ...data, blurData: updatedBlurData } : data); } else if (!BLUR_ENABLED) { setFormData(data => ({ ...data, blurData: '' })); } }, [updatedBlurData]); useEffect(() => setFormData(data => aiContent?.title ? { ...data, title: aiContent?.title } : data), [aiContent?.title]); useEffect(() => setFormData(data => aiContent?.caption ? { ...data, caption: aiContent?.caption } : data), [aiContent?.caption]); useEffect(() => setFormData(data => aiContent?.tags ? { ...data, tags: aiContent?.tags } : data), [aiContent?.tags]); useEffect(() => setFormData(data => aiContent?.semanticDescription ? { ...data, semanticDescription: aiContent?.semanticDescription } : data), [aiContent?.semanticDescription]); useEffect(() => { onTextContentChange?.(formHasTextContent(formData)); }, [onTextContentChange, formData]); useEffect(() => { if (formData.hidden === 'true') { setFormData(data => ({ ...data, excludeFromFeeds: 'false', favorite: 'false', })); } }, [formData.hidden]); const isFieldGeneratingAi = (key: keyof PhotoFormData) => { switch (key) { case 'title': return aiContent?.isLoadingTitle; case 'caption': return aiContent?.isLoadingCaption; case 'tags': return aiContent?.isLoadingTags; case 'semanticDescription': return aiContent?.isLoadingSemantic; default: return false; } }; const accessoryForField = (key: keyof PhotoFormData) => { if (aiContent) { switch (key) { case 'title': return ; case 'caption': return ; case 'tags': return ; case 'semanticDescription': return ; case 'blurData': return shouldDebugImageFallbacks && type === 'edit' && formData.url ? setFormData(data => ({ ...data, blurData }))} /> : null; } } }; const isFieldHidden = ( key: FormFields, hideIfEmpty?: boolean, shouldHide?: FormMeta['shouldHide'], ) => { if ( key === 'blurData' && type === 'create' && !BLUR_ENABLED && !shouldDebugImageFallbacks ) { return true; } else { return ( (hideIfEmpty && !formData[key]) || shouldHide?.(formData, changedFormKeys) ); } }; const isFieldReadOnly = (key: FormFields) => { return formData.hidden === 'true' && ( key === 'excludeFromFeeds' || key === 'favorite' ); }; const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => { setFormData(data => ({ ...data, applyRecipeTitleGlobally: didFindMatchingPhotos ? 'true' : 'false', })); }, []); return (
Analyzing image
{formActionErrorMessage && {formActionErrorMessage}}
(type === 'create' ? createPhotoAction : updatePhotoAction )(data) .catch(e => { if (e.message !== 'NEXT_REDIRECT') { setFormActionErrorMessage(e.message); } })} onSubmit={() => { setFormActionErrorMessage(''); (document.activeElement as HTMLElement)?.blur?.(); invalidateSwr?.(); }} > {/* Fields */}
{FORM_METADATA_ENTRIES( convertTagsForForm(uniqueTags, appText), convertRecipesForForm(uniqueRecipes), convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)), aiContent !== undefined, shouldStripGpsData, ) .map(([key, { label, note, noteShort, required, selectOptions, selectOptionsDefaultLabel, tagOptions, tagOptionsLimit, tagOptionsLimitValidationMessage, readOnly, hideModificationStatus, validate, validateStringMaxLength, spellCheck, capitalize, hideIfEmpty, shouldHide, loadingMessage, type, staticValue, }]) => { if (!isFieldHidden(key, hideIfEmpty, shouldHide)) { const fieldProps: ComponentProps = { id: key, label: label + ( key === 'blurData' && shouldDebugImageFallbacks ? ` (${(formData[key] ?? '').length} chars.)` : '' ), note, noteShort, error: formErrors[key], value: staticValue ?? formData[key] ?? '', isModified: ( !hideModificationStatus && changedFormKeys.includes(key) ), onChange: value => { const formUpdated = { ...formData, [key]: value }; setFormData(formUpdated); if (validate) { setFormErrors({ ...formErrors, [key]: validate(value), }); } else if (validateStringMaxLength !== undefined) { setFormErrors({ ...formErrors, [key]: value.length > validateStringMaxLength ? `${validateStringMaxLength} characters or less` : undefined, }); } if (key === 'title') { onTitleChange?.(value.trim()); } }, selectOptions, selectOptionsDefaultLabel: selectOptionsDefaultLabel, tagOptions, tagOptionsLimit, tagOptionsLimitValidationMessage, required, readOnly: readOnly || isFieldReadOnly(key), spellCheck, capitalize, placeholder: loadingMessage && !formData[key] ? loadingMessage : undefined, loading: ( (loadingMessage && !formData[key] ? true : false) || isFieldGeneratingAi(key) ), type, accessory: accessoryForField(key), }; switch (key) { case 'film': return } {...fieldProps} />; case 'applyRecipeTitleGlobally': return ; case 'favorite': return ; case 'excludeFromFeeds': return ; case 'hidden': return ; default: return ; } } })}
{/* Actions */}
Cancel } disabled={!canFormBeSubmitted} onFormStatusChange={onFormStatusChange} primary > {type === 'create' ? 'Add' : 'Update'}
); };