711 lines
23 KiB
TypeScript
711 lines
23 KiB
TypeScript
/* 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>
|
||
);
|
||
};
|