Offer explicit sync controls

This commit is contained in:
Sam Becker 2026-02-21 14:03:44 -06:00
parent 7b115da8d4
commit d2cca5ec9e
19 changed files with 193 additions and 57 deletions

View File

@ -26,7 +26,6 @@ import MoreMenu, { MoreMenuSection } from '@/components/more/MoreMenu';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md'; import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/components/icons/IconGrSync'; import IconGrSync from '@/components/icons/IconGrSync';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
@ -40,6 +39,7 @@ import IconUpload from '@/components/icons/IconUpload';
import { uploadPhotoFromClient } from '@/photo/storage'; import { uploadPhotoFromClient } from '@/photo/storage';
import ImageInput from '@/components/ImageInput'; import ImageInput from '@/components/ImageInput';
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
import IconWarning from '@/components/icons/IconWarning';
export default function AdminPhotoMenu({ export default function AdminPhotoMenu({
photo, photo,
@ -75,7 +75,7 @@ export default function AdminPhotoMenu({
: undefined; : undefined;
const sectionMain = useMemo(() => { const sectionMain = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{ const items: MoreMenuSection['items'] = [{
label: appText.admin.edit, label: appText.admin.edit,
icon: <IconEdit icon: <IconEdit
size={14} size={14}
@ -144,9 +144,25 @@ export default function AdminPhotoMenu({
icon: <IconGrSync icon: <IconGrSync
className="translate-x-[-1px] translate-y-[0.5px]" className="translate-x-[-1px] translate-y-[0.5px]"
/>, />,
action: () => syncPhotoAction(photo.id) items: [{
.then(() => revalidatePhoto?.(photo.id)), label: appText.admin.syncAutomatic,
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync }, icon: <IconGrSync
className="translate-x-[-1px] translate-y-[0.5px]"
/>,
action: () => syncPhotoAction(photo.id)
.then(() => revalidatePhoto?.(photo.id)),
}, {
label: appText.admin.syncOverwrite,
icon: <IconWarning className="translate-x-[-1.5px]" />,
className: 'text-warning *:hover:text-warning *:active:text-warning',
color: 'yellow',
action: () => {
if(window.confirm(appText.admin.syncOverwriteConfirm)) {
syncPhotoAction(photo.id, { syncMode: 'only-missing' })
.then(() => revalidatePhoto?.(photo.id));
}
},
}],
}); });
items.push({ items.push({
label: appText.admin.reupload, label: appText.admin.reupload,
@ -184,7 +200,7 @@ export default function AdminPhotoMenu({
icon: <IconTrash icon: <IconTrash
className="translate-x-[-1px]" className="translate-x-[-1px]"
/>, />,
className: 'text-error *:hover:text-error', className: 'text-error *:hover:text-error *:active:text-error',
color: 'red', color: 'red',
action: () => { action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo, appText))) { if (confirm(deleteConfirmationTextForPhoto(photo, appText))) {

View File

@ -29,6 +29,7 @@ export default function AdminPhotosTable({
hasAiTextGeneration, hasAiTextGeneration,
dateType = 'createdAt', dateType = 'createdAt',
canEdit = true, canEdit = true,
canSync,
canDelete = true, canDelete = true,
timezone, timezone,
shouldScrollIntoViewOnExternalSync, shouldScrollIntoViewOnExternalSync,
@ -42,6 +43,7 @@ export default function AdminPhotosTable({
hasAiTextGeneration: boolean hasAiTextGeneration: boolean
dateType?: 'createdAt' | 'updatedAt' dateType?: 'createdAt' | 'updatedAt'
canEdit?: boolean canEdit?: boolean
canSync?: boolean
canDelete?: boolean canDelete?: boolean
timezone?: Timezone timezone?: Timezone
shouldScrollIntoViewOnExternalSync?: boolean shouldScrollIntoViewOnExternalSync?: boolean
@ -127,19 +129,20 @@ export default function AdminPhotosTable({
)}> )}>
{canEdit && {canEdit &&
<EditButton path={pathForAdminPhotoEdit(photo)} />} <EditButton path={pathForAdminPhotoEdit(photo)} />}
<PhotoSyncButton {canSync &&
photo={photo} <PhotoSyncButton
onSyncComplete={invalidateSwr} photo={photo}
isSyncingExternal={photoIdsSyncing.includes(photo.id)} onSyncComplete={invalidateSwr}
hasAiTextGeneration={hasAiTextGeneration} isSyncingExternal={photoIdsSyncing.includes(photo.id)}
disabled={photoIdsSyncing.length > 0} hasAiTextGeneration={hasAiTextGeneration}
className={opacityForPhotoId(photo.id)} disabled={photoIdsSyncing.length > 0}
shouldConfirm className={opacityForPhotoId(photo.id)}
shouldToast shouldConfirm
shouldScrollIntoViewOnExternalSync={ shouldToast
shouldScrollIntoViewOnExternalSync} shouldScrollIntoViewOnExternalSync={
updateMode={updateMode} shouldScrollIntoViewOnExternalSync}
/> updateMode={updateMode}
/>}
{debugColorData && {debugColorData &&
<SyncColorButton photoId={photo.id} />} <SyncColorButton photoId={photo.id} />}
<AdminPhotoMenu <AdminPhotoMenu

View File

@ -151,6 +151,7 @@ export default function AdminPhotosUpdateClient({
photoIdsSyncing={photoIdsSyncing} photoIdsSyncing={photoIdsSyncing}
hasAiTextGeneration={hasAiTextGeneration} hasAiTextGeneration={hasAiTextGeneration}
canEdit={false} canEdit={false}
canSync={true}
canDelete={false} canDelete={false}
dateType="updatedAt" dateType="updatedAt"
shouldScrollIntoViewOnExternalSync shouldScrollIntoViewOnExternalSync

View File

@ -0,0 +1,9 @@
import { IconBaseProps } from 'react-icons/lib';
import { MdWarningAmber } from 'react-icons/md';
export default function IconWarning(props: IconBaseProps) {
return <MdWarningAmber
{...props}
size={props.size ?? 17}
/>;
}

View File

@ -10,10 +10,36 @@ import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi'; import { FiMoreHorizontal } from 'react-icons/fi';
import MoreMenuItem from './MoreMenuItem'; import MoreMenuItem from './MoreMenuItem';
import { clearGlobalFocus } from '@/utility/dom'; import { clearGlobalFocus } from '@/utility/dom';
import { FaChevronRight } from 'react-icons/fa6';
const surfaceStyles = (className?: string) => clsx(
'z-10',
'min-w-[8rem]',
'component-surface',
'py-1',
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
className,
);
export type MoreMenuSection = { export type MoreMenuSection = {
label?: string label?: string
items: ComponentProps<typeof MoreMenuItem>[] items: (
// Either a menu item
ComponentProps<typeof MoreMenuItem> |
// or a submenu
{
label: string
labelComplex?: ReactNode
icon?: ReactNode
items: ComponentProps<typeof MoreMenuItem>[]
}
)[]
} }
export default function MoreMenu({ export default function MoreMenu({
@ -89,20 +115,7 @@ export default function MoreMenu({
onCloseAutoFocus={e => e.preventDefault()} onCloseAutoFocus={e => e.preventDefault()}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={clsx( className={surfaceStyles(className)}
'z-10',
'min-w-[8rem]',
'component-surface',
'py-1',
'not-dark:shadow-lg not-dark:shadow-gray-900/10',
'data-[side=top]:dark:shadow-[0_0px_40px_rgba(0,0,0,0.6)]',
'data-[side=bottom]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=right]:dark:shadow-[0_10px_40px_rgba(0,0,0,0.6)]',
'data-[side=top]:animate-fade-in-from-bottom',
'data-[side=bottom]:animate-fade-in-from-top',
'data-[side=right]:animate-fade-in-from-top',
className,
)}
> >
{header && <div className={clsx( {header && <div className={clsx(
'px-3 pt-3 pb-2 text-dim uppercase', 'px-3 pt-3 pb-2 text-dim uppercase',
@ -126,13 +139,56 @@ export default function MoreMenu({
{label} {label}
</div>} </div>}
{items.map(item => {items.map(item =>
<div key={item.label} className="px-1"> 'items' in item
<MoreMenuItem ? <DropdownMenu.DropdownMenuSub key={item.label}>
{...item} <DropdownMenu.SubTrigger asChild>
dismissMenu={dismissMenu} <div className="mx-1 focus:outline-none">
/> <div className={clsx(
</div>, 'link outline-none focus:outline-none',
)} 'inline-flex w-full items-center h-8.5',
'rounded-sm p-2.5',
'items-center gap-1.5',
'text-sm text-main hover:text-main',
'hover:bg-gray-100/90 active:bg-gray-200/75',
// eslint-disable-next-line max-len
'dark:hover:bg-gray-800/60 dark:active:bg-gray-900/80',
'select-none',
'cursor-pointer',
'whitespace-nowrap',
)}>
{item.icon && <div className="w-4.5">
{item.icon}
</div>}
<span className="grow min-w-0 text-left">
{item.labelComplex ?? item.label}
</span>
<FaChevronRight
size={10}
className="text-dim"
/>
</div>
</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className={surfaceStyles()}
>
{item.items.map(item =>
<div key={item.label} className="px-1">
<MoreMenuItem
{...item}
dismissMenu={dismissMenu}
/>
</div>)}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.DropdownMenuSub>
: <div key={item.label} className="px-1">
<MoreMenuItem
{...item}
dismissMenu={dismissMenu}
/>
</div>)}
</div>, </div>,
)} )}
</div> </div>

View File

@ -31,7 +31,7 @@ export default function MoreMenuItem({
labelComplex?: ReactNode labelComplex?: ReactNode
annotation?: ReactNode annotation?: ReactNode
icon?: ReactNode icon?: ReactNode
color?: 'grey' | 'red' color?: 'grey' | 'red' | 'yellow'
href?: string href?: string
hrefDownloadName?: string hrefDownloadName?: string
className?: string className?: string
@ -53,6 +53,10 @@ export default function MoreMenuItem({
'hover:bg-red-100/50 active:bg-red-100/75', 'hover:bg-red-100/50 active:bg-red-100/75',
'dark:hover:bg-red-950/55 dark:active:bg-red-950/80', 'dark:hover:bg-red-950/55 dark:active:bg-red-950/80',
); );
case 'yellow': return clsx(
'hover:bg-amber-100/50 active:bg-amber-100/75',
'dark:hover:bg-amber-950/55 dark:active:bg-amber-950/80',
);
} }
}; };

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'সর্বজনীন করুন', public: 'সর্বজনীন করুন',
download: 'ডাউনলোড', download: 'ডাউনলোড',
sync: 'সিঙ্ক', sync: 'সিঙ্ক',
syncAutomatic: 'স্বয়ংক্রিয়',
syncOverwrite: 'ওভাররাইট করুন',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'আপনি কি নিশ্চিত যে আপনি সমস্ত ফটো ফিল্ড ওভাররাইট করতে চান? কাস্টমাইজড ডেটা হারিয়ে যেতে পারে।',
reupload: 'পুনরায় আপলোড করুন', reupload: 'পুনরায় আপলোড করুন',
delete: 'ডিলিট', delete: 'ডিলিট',
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?', deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Make Public', public: 'Make Public',
download: 'Download', download: 'Download',
sync: 'Sync', sync: 'Sync',
syncAutomatic: 'Automatic',
syncOverwrite: 'Overwrite',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Are you sure you want to overwrite all photo fields? Customised data may be lost.',
reupload: 'Reupload', reupload: 'Reupload',
delete: 'Delete', delete: 'Delete',
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"', deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',

View File

@ -134,6 +134,10 @@ export const TEXT = {
public: 'Make Public', public: 'Make Public',
download: 'Download', download: 'Download',
sync: 'Sync', sync: 'Sync',
syncAutomatic: 'Automatic',
syncOverwrite: 'Overwrite',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Are you sure you want to overwrite all photo fields? Customized data may be lost.',
reupload: 'Reupload', reupload: 'Reupload',
delete: 'Delete', delete: 'Delete',
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"', deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'सार्वजनिक बनाएं', public: 'सार्वजनिक बनाएं',
download: 'डाउनलोड करें', download: 'डाउनलोड करें',
sync: 'सिंक करें', sync: 'सिंक करें',
syncAutomatic: 'स्वचालित',
syncOverwrite: 'अधिलेखित करें',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'क्या आप सुनिश्चित हैं कि आप सभी फोटो फ़ील्ड को अधिलेखित करना चाहते हैं? अनुकूलित डेटा खो सकता है।',
reupload: 'पुनः अपलोड करें', reupload: 'पुनः अपलोड करें',
delete: 'हटाएं', delete: 'हटाएं',
// eslint-disable-next-line max-len // eslint-disable-next-line max-len

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Buat Publik', public: 'Buat Publik',
download: 'Unduh', download: 'Unduh',
sync: 'Sinkronkan', sync: 'Sinkronkan',
syncAutomatic: 'Otomatis',
syncOverwrite: 'Timpa',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Apakah Anda yakin ingin menimpa semua bidang foto? Data yang disesuaikan mungkin hilang.',
reupload: 'Unggah ulang', reupload: 'Unggah ulang',
delete: 'Hapus', delete: 'Hapus',
deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?', deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?',

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Tornar Público', public: 'Tornar Público',
download: 'Baixar', download: 'Baixar',
sync: 'Sincronizar', sync: 'Sincronizar',
syncAutomatic: 'Automático',
syncOverwrite: 'Sobrescrever',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Tem certeza de que deseja sobrescrever todos os campos da foto? Dados personalizados podem ser perdidos.',
reupload: 'Enviar novamente', reupload: 'Enviar novamente',
delete: 'Excluir', delete: 'Excluir',
deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?', deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?',

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Tornar Público', public: 'Tornar Público',
download: 'Descarregar', download: 'Descarregar',
sync: 'Sincronizar', sync: 'Sincronizar',
syncAutomatic: 'Automático',
syncOverwrite: 'Sobrescrever',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Tens certeza de que queres sobrescrever todos os campos da fotografia? Dados personalizados podem ser perdidos.',
reupload: 'Carregar novamente', reupload: 'Carregar novamente',
delete: 'Excluir', delete: 'Excluir',
deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?', deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?',

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Herkese Açık Yap', public: 'Herkese Açık Yap',
download: 'İndir', download: 'İndir',
sync: 'Senkronize Et', sync: 'Senkronize Et',
syncAutomatic: 'Otomatik',
syncOverwrite: 'Üzerine Yaz',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Tüm fotoğraf alanlarının üzerine yazmak istediğinize emin misiniz? Özelleştirilmiş veriler kaybolabilir.',
reupload: 'Yeniden Yükle', reupload: 'Yeniden Yükle',
delete: 'Sil', delete: 'Sil',
// eslint-disable-next-line max-len // eslint-disable-next-line max-len

View File

@ -135,6 +135,10 @@ export const TEXT: I18N = {
public: 'Làm công khai', public: 'Làm công khai',
download: 'Tải xuống', download: 'Tải xuống',
sync: 'Đồng bộ', sync: 'Đồng bộ',
syncAutomatic: 'Tự động',
syncOverwrite: 'Ghi đè',
// eslint-disable-next-line max-len
syncOverwriteConfirm: 'Bạn có chắc chắn muốn ghi đè tất cả các trường ảnh? Dữ liệu tùy chỉnh có thể bị mất.',
reupload: 'Tải lên lại', reupload: 'Tải lên lại',
delete: 'Xóa', delete: 'Xóa',
deleteConfirm: 'Bạn có chắc chắn muốn xóa "{{photoTitle}}?"', deleteConfirm: 'Bạn có chắc chắn muốn xóa "{{photoTitle}}?"',

View File

@ -135,6 +135,9 @@ export const TEXT: I18N = {
public: '设为公开', public: '设为公开',
download: '下载', download: '下载',
sync: '同步', sync: '同步',
syncAutomatic: '自动',
syncOverwrite: '覆盖',
syncOverwriteConfirm: '确定要覆盖所有照片字段吗?自定义数据可能会丢失。',
reupload: '重新上传', reupload: '重新上传',
delete: '删除', delete: '删除',
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?', deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',

View File

@ -22,7 +22,6 @@ import {
getPhotoOptionsCountForPath, getPhotoOptionsCountForPath,
} from '@/db'; } from '@/db';
import { import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData, PhotoFormData,
convertFormDataToPhotoDbInsert, convertFormDataToPhotoDbInsert,
convertPhotoToFormData, convertPhotoToFormData,
@ -583,9 +582,11 @@ export const getExifDataAction = async (
export const syncPhotoAction = async ( export const syncPhotoAction = async (
photoId: string, { photoId: string, {
isBatch, isBatch,
syncMode = 'auto',
updateMode, updateMode,
}: { }: {
isBatch?: boolean, isBatch?: boolean,
syncMode?: 'auto' | 'only-missing' | 'overwrite',
updateMode?: boolean, updateMode?: boolean,
} = {}, } = {},
) => ) =>
@ -602,7 +603,7 @@ export const syncPhotoAction = async (
includeInitialPhotoFields: false, includeInitialPhotoFields: false,
generateBlurData: BLUR_ENABLED, generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_CONTENT_GENERATION_ENABLED, generateResizedImage: AI_CONTENT_GENERATION_ENABLED,
// If in update mode, only update color fields if necessary // In update mode, only update color fields if necessary
updateColorFields: !( updateColorFields: !(
updateMode && updateMode &&
photo.colorData !== undefined && photo.colorData !== undefined &&
@ -643,10 +644,22 @@ export const syncPhotoAction = async (
const formDataFromPhoto = convertPhotoToFormData(photo); const formDataFromPhoto = convertPhotoToFormData(photo);
// Don't overwrite manually configured meta with null data Object.entries(formDataFromExif).forEach(([field, value]) => {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => { const existingValue =
if (!formDataFromExif[field] && formDataFromPhoto[field]) { formDataFromPhoto[field as keyof PhotoFormData];
delete formDataFromExif[field]; switch (syncMode) {
case 'auto':
// Remove all fields already present in formDataFromPhoto
if (existingValue !== undefined) {
delete formDataFromExif[field as keyof PhotoFormData];
}
break;
case 'only-missing':
// Avoid overwriting fields with null data
if (existingValue !== undefined && !value) {
delete formDataFromExif[field as keyof PhotoFormData];
}
break;
} }
}); });

View File

@ -69,7 +69,6 @@ export type FormMeta = {
tagOptionsLimit?: number tagOptionsLimit?: number
tagOptionsLimitValidationMessage?: string tagOptionsLimitValidationMessage?: string
tagOptionsShouldParameterize?: boolean tagOptionsShouldParameterize?: boolean
shouldNotOverwriteWithNullDataOnSync?: boolean
isJson?: boolean isJson?: boolean
staticValue?: string staticValue?: string
}; };
@ -89,7 +88,6 @@ const FORM_METADATA = (
label: 'title', label: 'title',
capitalize: true, capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_SHORT, validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
shouldNotOverwriteWithNullDataOnSync: true,
}, },
caption: { caption: {
section: 'text', section: 'text',
@ -155,7 +153,6 @@ const FORM_METADATA = (
noteShort: 'Fujifilm / Nikon / analog scans', noteShort: 'Fujifilm / Nikon / analog scans',
tagOptions: filmOptions, tagOptions: filmOptions,
tagOptionsLimit: 1, tagOptionsLimit: 1,
shouldNotOverwriteWithNullDataOnSync: true,
}, },
recipeTitle: { recipeTitle: {
section: 'exif', section: 'exif',
@ -187,7 +184,6 @@ const FORM_METADATA = (
spellCheck: false, spellCheck: false,
capitalize: false, capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM, shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
isJson: true, isJson: true,
validate: value => { validate: value => {
let validationMessage = undefined; let validationMessage = undefined;
@ -299,11 +295,6 @@ export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA())
.filter(([_, meta]) => meta.isJson) .filter(([_, meta]) => meta.isJson)
.map(([key]) => key as keyof PhotoFormData); .map(([key]) => key as keyof PhotoFormData);
export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC =
Object.entries(FORM_METADATA())
.filter(([_, meta]) => meta.shouldNotOverwriteWithNullDataOnSync)
.map(([key]) => key as keyof PhotoFormData);
export const FORM_METADATA_ENTRIES = ( export const FORM_METADATA_ENTRIES = (
...args: Parameters<typeof FORM_METADATA> ...args: Parameters<typeof FORM_METADATA>
) => ) =>

View File

@ -142,6 +142,10 @@ html {
@apply @apply
text-red-500 dark:text-red-400 text-red-500 dark:text-red-400
} }
@utility text-warning {
@apply
text-amber-700 dark:text-amber-500
}
/* Rules */ /* Rules */
@utility border-main { @utility border-main {
@apply @apply