Vercel/src/photo/form/PhotoForm.tsx
2026-02-21 23:23:01 -06:00

711 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable react-hooks/set-state-in-effect */
'use client';
import {
ComponentProps,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
FIELDS_TO_NOT_TOAST,
FIELDS_WITH_JSON,
FORM_METADATA_ENTRIES_BY_SECTION,
FORM_SECTIONS,
FormFields,
FormMeta,
PhotoFormData,
convertFormKeysToLabels,
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 {
PARAM_REDIRECT,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_UPLOADS,
} from '@/app/path';
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 { 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 { useAppText } from '@/i18n/state/client';
import IconAddUpload from '@/components/icons/IconAddUpload';
import { didVisibilityChange } from '../visibility';
import FieldsetVisibility from '../visibility/FieldsetVisibility';
import PhotoColors from '../color/PhotoColors';
import { generateColorDataFromString } from '../color/client';
import { capitalize } from '@/utility/string';
import AnchorSections from '@/components/AnchorSections';
import useIsVisible from '@/utility/useIsVisible';
import useHash from '@/utility/useHash';
import { getOptimizedPhotoUrlForManipulation } from '../storage';
import {
getFileNamePartsFromStorageUrl,
StorageListResponse,
} from '@/platforms/storage';
import SmallDisclosure from '@/components/SmallDisclosure';
import { TbPhoto } from 'react-icons/tb';
import { Albums } from '@/album';
import FieldsetAlbum from '@/album/FieldsetAlbum';
import Form from 'next/form';
import { useRouter, useSearchParams } from 'next/navigation';
const THUMBNAIL_SIZE = 300;
export default function PhotoForm({
type = 'create',
initialPhotoForm,
photoStorageUrls = [],
updatedExifData,
updatedBlurData,
photoAlbumTitles = [],
albums,
uniqueTags,
uniqueRecipes,
uniqueFilms,
aiContent,
shouldStripGpsData,
onTitleChange,
onFormDataChange,
onFormStatusChange,
}: {
type?: 'create' | 'edit'
initialPhotoForm: Partial<PhotoFormData>
photoStorageUrls?: StorageListResponse
updatedExifData?: Partial<PhotoFormData>
updatedBlurData?: string
photoAlbumTitles?: string[]
albums: Albums
uniqueTags: Tags
uniqueRecipes: Recipes
uniqueFilms: Films
aiContent?: AiContent
shouldStripGpsData?: boolean
onTitleChange?: (updatedTitle: string) => void
onFormDataChange?: (formData: Partial<PhotoFormData>) => void,
onFormStatusChange?: (pending: boolean) => void
}) {
const router = useRouter();
const redirectParam = useSearchParams().get(PARAM_REDIRECT);
const [formData, setFormData] =
useState<Partial<PhotoFormData>>(initialPhotoForm);
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
const [formActionErrorMessage, setFormActionErrorMessage] = useState('');
const [detectedFilm, setDetectedFilm] =
useState(initialPhotoForm.film);
const [albumTitles, setAlbumTitles] = useState(photoAlbumTitles
.sort((a, b) => a.localeCompare(b))
.join(','));
const areAlbumTitlesModified = albumTitles !== photoAlbumTitles
.sort((a, b) => a.localeCompare(b))
.join(',');
const { hash } = useHash();
const { invalidateSwr, shouldDebugImageFallbacks } = useAppState();
const appText = useAppText();
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]);
const formHasChanged = changedFormKeys.length > 0 || areAlbumTitlesModified;
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)) {
try {
a = a ? JSON.parse(a) : undefined;
b = b ? JSON.parse(b) : undefined;
} catch (error) {
console.log(`Error parsing JSON: ${key}`, error);
}
}
if (!deepEqual(a, b)) {
changedKeys.push(key as keyof PhotoFormData);
}
});
return {
...currentForm,
...updatedExifData,
};
});
if (updatedExifData?.film) {
setDetectedFilm(updatedExifData.film);
}
const keysToToast = changedKeys.filter(key =>
!FIELDS_TO_NOT_TOAST.includes(key));
if (keysToToast.length > 0) {
const fields = convertFormKeysToLabels(keysToToast);
toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000);
} else {
toastWarning('No new EXIF data found');
}
}
}, [updatedExifData]);
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(() => {
onFormDataChange?.(formData);
}, [onFormDataChange, formData]);
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 <AiButton
tabIndex={-1}
aiContent={aiContent}
requestFields={['title']}
shouldConfirm={Boolean(formData.title)}
className="h-full"
/>;
case 'caption':
return <AiButton
tabIndex={-1}
aiContent={aiContent}
requestFields={['caption']}
shouldConfirm={Boolean(formData.caption)}
className="h-full"
/>;
case 'tags':
return <AiButton
tabIndex={-1}
aiContent={aiContent}
requestFields={['tags']}
shouldConfirm={Boolean(formData.tags)}
className="h-full"
/>;
case 'semanticDescription':
return <AiButton
tabIndex={-1}
aiContent={aiContent}
requestFields={['semantic']}
shouldConfirm={Boolean(formData.semanticDescription)}
/>;
case 'blurData':
return shouldDebugImageFallbacks && type === 'edit' && formData.url
? <UpdateBlurDataButton
photoUrl={getOptimizedPhotoUrlForManipulation(
formData.url,
IS_PREVIEW,
)}
onUpdatedBlurData={blurData =>
setFormData(data => ({ ...data, blurData }))}
/>
: null;
}
}
};
const footerForField = (key: keyof PhotoFormData) => {
switch (key) {
case 'url':
return type === 'edit' && photoStorageUrls.length === 0
? <span className="text-error">
No storage found for photo
</span>
: photoStorageUrls.length > 1
? <SmallDisclosure label="Optimized file set">
<div className="space-y-1">
{photoStorageUrls.map(({ url, size }) => {
const {
fileName,
fileModifier,
} = getFileNamePartsFromStorageUrl(url);
return <div
key={url}
className="flex items-center gap-2"
>
<TbPhoto className="translate-y-[1px] text-medium" />
<Link
href={url}
target="_blank"
>
{fileName}
</Link>
<span className="text-dim">
{size}
{/* Show dimensions for original file when available */}
{!fileModifier && formData.width && formData.height &&
` @ ${formData.width}×${formData.height}`}
</span>
</div>;
})}
</div>
</SmallDisclosure>
: 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 onMatchResults = useCallback((didFindMatchingPhotos: boolean) => {
setFormData(data => ({
...data,
applyRecipeTitleGlobally: didFindMatchingPhotos
? 'true'
: 'false',
}));
}, [setFormData]);
const formContent = useMemo(() =>
FORM_METADATA_ENTRIES_BY_SECTION(
convertTagsForForm(uniqueTags, appText),
convertRecipesForForm(uniqueRecipes),
convertFilmsForForm(
uniqueFilms,
isMakeFujifilm(formData.make),
detectedFilm,
formData.make,
),
aiContent !== undefined,
shouldStripGpsData,
), [
uniqueTags,
appText,
uniqueRecipes,
uniqueFilms,
formData.make,
detectedFilm,
aiContent,
shouldStripGpsData,
]);
const ref = useRef<HTMLImageElement>(null);
const isThumbnailVisible = useIsVisible({ ref, initiallyVisible: true });
const thumbnailDimensions =
getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio);
const thumbnail = (includeRef?: boolean, className?: string) =>
<ImageWithFallback
ref={includeRef ? ref : undefined}
alt="Upload"
src={url}
className={clsx(
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700',
className,
)}
blurDataURL={formData.blurData}
blurCompatibilityLevel="none"
width={thumbnailDimensions.width}
height={thumbnailDimensions.height}
priority
/>;
return (
<div className="space-y-4 max-w-[38rem] relative">
<div className="flex gap-2">
<div className="relative">
{thumbnail(true)}
<div className={clsx(
'max-md:hidden',
'fixed top-8',
// Orient around responsive form fields
'left-[77%] min-[850px]:left-[41rem] lg:left-[42rem]',
'mr-4',
// Prevent image blocking form button interaction
'pointer-events-none',
)}>
{thumbnail(false, clsx(
'opacity-0 -translate-y-4',
!isThumbnailVisible &&
'opacity-100 translate-y-0 transition-all duration-300',
))}
</div>
<div className={clsx(
'absolute top-2 left-2 transition-opacity duration-500',
aiContent?.isLoading ? 'opacity-100' : 'opacity-0',
)}>
<div className={clsx(
'leading-none text-xs font-medium uppercase tracking-wide',
'px-1.5 py-1 rounded-[4px]',
'inline-flex items-center gap-2',
'bg-white/70 dark:bg-black/60 backdrop-blur-md',
'border border-gray-900/10 dark:border-gray-700/70',
'select-none',
)}>
<Spinner
color="text"
size={9}
className={clsx(
'text-extra-dim',
'translate-x-[1px] translate-y-[0.5px]',
)}
/>
Analyzing image
</div>
</div>
</div>
</div>
{formActionErrorMessage &&
<ErrorNote>{formActionErrorMessage}</ErrorNote>}
<div className={clsx(
'flex gap-4',
'sticky top-0 z-10 bg-main',
'border-b border-gray-200 dark:border-gray-700',
'uppercase tracking-wide text-sm',
'*:py-2',
)}>
<span className="flex gap-4 max-sm:hidden">
<span>Photo Details</span>
<span className="text-extra-extra-dim">/</span>
</span>
{FORM_SECTIONS.map(section => (
<a
key={section}
href={`#${section}`}
className={clsx(
'cursor-pointer hover:text-main',
'active:border-b-2',
'active:border-b-gray-200 dark:active:border-b-gray-700',
section === hash
? 'font-bold border-b-2 border-b-black dark:border-b-white'
: 'text-dim',
)}
>
{capitalize(section)}
</a>
))}
</div>
<Form
action={data => (type === 'create'
? createPhotoAction
: updatePhotoAction
)(data)
.then(() => {
router.push(redirectParam ?? PATH_ADMIN_PHOTOS);
})
.catch(e => {
if (e.message !== 'NEXT_REDIRECT') {
setFormActionErrorMessage(e.message);
}
})}
onSubmit={() => {
setFormActionErrorMessage('');
(document.activeElement as HTMLElement)?.blur?.();
invalidateSwr?.();
}}
>
{/* Fields */}
<AnchorSections
className="mt-6 space-y-5 *:space-y-5"
classNameSection="scroll-mt-12"
sections={formContent
.map(({ section, fields }) => ({
id: section,
content: <>
{fields.map(([key, {
label,
note,
noteShort,
required,
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
tagOptionsLimit,
tagOptionsLimitValidationMessage,
tagOptionsShouldParameterize,
readOnly,
hideModificationStatus,
validate,
validateStringMaxLength,
spellCheck,
capitalize,
hideIfEmpty,
shouldHide,
loadingMessage,
type,
staticValue,
}]) => {
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
// eslint-disable-next-line max-len
const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
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,
tagOptionsShouldParameterize,
required,
readOnly,
spellCheck,
capitalize,
placeholder: loadingMessage && !formData[key]
? loadingMessage
: undefined,
loading: (
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)
),
type,
accessory: accessoryForField(key),
footer: footerForField(key),
};
switch (key) {
case 'film':
return <FieldsetWithStatus
key={key}
{...fieldProps}
tagOptionsDefaultIcon={<span
className="w-4 overflow-hidden"
>
<PhotoFilmIcon />
</span>}
/>;
case 'applyRecipeTitleGlobally':
return <ApplyRecipeTitleGloballyCheckbox
key={key}
{...fieldProps}
photoId={initialPhotoForm.id}
recipeTitle={formData.recipeTitle}
hasRecipeTitleChanged={
changedFormKeys.includes('recipeTitle')}
recipeData={formData.recipeData}
film={formData.film}
onMatchResults={onMatchResults}
/>;
case 'colorData':
return <FieldsetWithStatus
key={key}
{...fieldProps}
noteComplex={<PhotoColors
classNameDot="size-[13px]!"
// eslint-disable-next-line max-len
colorData={generateColorDataFromString(formData.colorData)}
/>}
/>;
case 'tags':
return <FieldsetWithStatus
key={key}
{...fieldProps}
className="relative z-2"
/>;
case 'albums':
return <FieldsetAlbum
key={key}
{...fieldProps}
albumOptions={albums}
value={albumTitles}
onChange={value => setAlbumTitles(value)}
isModified={areAlbumTitlesModified}
className={clsx(
fieldProps.className,
'relative z-1',
)}
/>;
case 'visibility':
return <FieldsetVisibility
key={key}
{...fieldProps}
formData={formData}
setFormData={setFormData}
isModified={didVisibilityChange(
initialPhotoForm,
formData,
)}
/>;
case 'favorite':
return <FieldsetFavs
key={key}
{...fieldProps}
/>;
default:
return <FieldsetWithStatus
key={key}
{...fieldProps}
/>;
}
}
})}
</>,
}))}
/>
{/* Actions */}
<div className={clsx(
'flex gap-3 sticky bottom-0',
'pb-4 md:pb-8 mt-16',
'relative z-10',
)}>
<Link
className="button"
href={type === 'edit' ? PATH_ADMIN_PHOTOS : PATH_ADMIN_UPLOADS}
>
Cancel
</Link>
<SubmitButtonWithStatus
icon={type === 'create' && <IconAddUpload />}
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
hideText="never"
primary
>
{type === 'create' ? 'Add' : 'Update'}
</SubmitButtonWithStatus>
<div className={clsx(
'absolute -top-16 -left-2 right-0 bottom-0 -z-10',
'pointer-events-none',
'bg-linear-to-t',
'from-white/90 from-60%',
'dark:from-black/90 dark:from-50%',
)} />
</div>
</Form>
</div>
);
};