diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index d4504fac..5ce5a001 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -26,7 +26,6 @@ import MoreMenu, { MoreMenuSection } from '@/components/more/MoreMenu'; import { useAppState } from '@/app/AppState'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { MdOutlineFileDownload } from 'react-icons/md'; -import MoreMenuItem from '@/components/more/MoreMenuItem'; import IconGrSync from '@/components/icons/IconGrSync'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import IconFavs from '@/components/icons/IconFavs'; @@ -40,6 +39,7 @@ import IconUpload from '@/components/icons/IconUpload'; import { uploadPhotoFromClient } from '@/photo/storage'; import ImageInput from '@/components/ImageInput'; import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; +import IconWarning from '@/components/icons/IconWarning'; export default function AdminPhotoMenu({ photo, @@ -75,7 +75,7 @@ export default function AdminPhotoMenu({ : undefined; const sectionMain = useMemo(() => { - const items: ComponentProps[] = [{ + const items: MoreMenuSection['items'] = [{ label: appText.admin.edit, icon: , - action: () => syncPhotoAction(photo.id) - .then(() => revalidatePhoto?.(photo.id)), - ...showKeyCommands && { keyCommand: KEY_COMMANDS.sync }, + items: [{ + label: appText.admin.syncAutomatic, + icon: , + action: () => syncPhotoAction(photo.id) + .then(() => revalidatePhoto?.(photo.id)), + }, { + label: appText.admin.syncOverwrite, + icon: , + 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({ label: appText.admin.reupload, @@ -184,7 +200,7 @@ export default function AdminPhotoMenu({ icon: , - className: 'text-error *:hover:text-error', + className: 'text-error *:hover:text-error *:active:text-error', color: 'red', action: () => { if (confirm(deleteConfirmationTextForPhoto(photo, appText))) { diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index f3963d22..46638b6c 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -29,6 +29,7 @@ export default function AdminPhotosTable({ hasAiTextGeneration, dateType = 'createdAt', canEdit = true, + canSync, canDelete = true, timezone, shouldScrollIntoViewOnExternalSync, @@ -42,6 +43,7 @@ export default function AdminPhotosTable({ hasAiTextGeneration: boolean dateType?: 'createdAt' | 'updatedAt' canEdit?: boolean + canSync?: boolean canDelete?: boolean timezone?: Timezone shouldScrollIntoViewOnExternalSync?: boolean @@ -127,19 +129,20 @@ export default function AdminPhotosTable({ )}> {canEdit && } - 0} - className={opacityForPhotoId(photo.id)} - shouldConfirm - shouldToast - shouldScrollIntoViewOnExternalSync={ - shouldScrollIntoViewOnExternalSync} - updateMode={updateMode} - /> + {canSync && + 0} + className={opacityForPhotoId(photo.id)} + shouldConfirm + shouldToast + shouldScrollIntoViewOnExternalSync={ + shouldScrollIntoViewOnExternalSync} + updateMode={updateMode} + />} {debugColorData && } ; +} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index ad848b2d..b1dd6f79 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -10,10 +10,36 @@ import { clsx } from 'clsx/lite'; import { FiMoreHorizontal } from 'react-icons/fi'; import MoreMenuItem from './MoreMenuItem'; 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 = { label?: string - items: ComponentProps[] + items: ( + // Either a menu item + ComponentProps | + // or a submenu + { + label: string + labelComplex?: ReactNode + icon?: ReactNode + items: ComponentProps[] + } + )[] } export default function MoreMenu({ @@ -89,20 +115,7 @@ export default function MoreMenu({ onCloseAutoFocus={e => e.preventDefault()} align={align} sideOffset={sideOffset} - className={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, - )} + className={surfaceStyles(className)} > {header &&
} {items.map(item => -
- -
, - )} + 'items' in item + ? + +
+
+ {item.icon &&
+ {item.icon} +
} + + {item.labelComplex ?? item.label} + + +
+
+
+ + + {item.items.map(item => +
+ +
)} +
+
+
+ :
+ +
)}
, )} diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index dc2adec9..cf58d9b6 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -31,7 +31,7 @@ export default function MoreMenuItem({ labelComplex?: ReactNode annotation?: ReactNode icon?: ReactNode - color?: 'grey' | 'red' + color?: 'grey' | 'red' | 'yellow' href?: string hrefDownloadName?: string className?: string @@ -53,6 +53,10 @@ export default function MoreMenuItem({ 'hover:bg-red-100/50 active:bg-red-100/75', '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', + ); } }; diff --git a/src/i18n/locales/bd-bn.ts b/src/i18n/locales/bd-bn.ts index 9ef4d7f2..db4853af 100644 --- a/src/i18n/locales/bd-bn.ts +++ b/src/i18n/locales/bd-bn.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'সর্বজনীন করুন', download: 'ডাউনলোড', sync: 'সিঙ্ক', + syncAutomatic: 'স্বয়ংক্রিয়', + syncOverwrite: 'ওভাররাইট করুন', + // eslint-disable-next-line max-len + syncOverwriteConfirm: 'আপনি কি নিশ্চিত যে আপনি সমস্ত ফটো ফিল্ড ওভাররাইট করতে চান? কাস্টমাইজড ডেটা হারিয়ে যেতে পারে।', reupload: 'পুনরায় আপলোড করুন', delete: 'ডিলিট', deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?', diff --git a/src/i18n/locales/en-gb.ts b/src/i18n/locales/en-gb.ts index 5d8f7ba3..32a92411 100644 --- a/src/i18n/locales/en-gb.ts +++ b/src/i18n/locales/en-gb.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Make Public', download: 'Download', 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', delete: 'Delete', deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"', diff --git a/src/i18n/locales/en-us.ts b/src/i18n/locales/en-us.ts index 40cfb8cf..03dc024b 100644 --- a/src/i18n/locales/en-us.ts +++ b/src/i18n/locales/en-us.ts @@ -134,6 +134,10 @@ export const TEXT = { public: 'Make Public', download: 'Download', 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', delete: 'Delete', deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"', diff --git a/src/i18n/locales/hi-in.ts b/src/i18n/locales/hi-in.ts index 4bba6f32..a24999af 100644 --- a/src/i18n/locales/hi-in.ts +++ b/src/i18n/locales/hi-in.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'सार्वजनिक बनाएं', download: 'डाउनलोड करें', sync: 'सिंक करें', + syncAutomatic: 'स्वचालित', + syncOverwrite: 'अधिलेखित करें', + // eslint-disable-next-line max-len + syncOverwriteConfirm: 'क्या आप सुनिश्चित हैं कि आप सभी फोटो फ़ील्ड को अधिलेखित करना चाहते हैं? अनुकूलित डेटा खो सकता है।', reupload: 'पुनः अपलोड करें', delete: 'हटाएं', // eslint-disable-next-line max-len diff --git a/src/i18n/locales/id-id.ts b/src/i18n/locales/id-id.ts index 3bedc94a..d969297c 100644 --- a/src/i18n/locales/id-id.ts +++ b/src/i18n/locales/id-id.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Buat Publik', download: 'Unduh', 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', delete: 'Hapus', deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?', diff --git a/src/i18n/locales/pt-br.ts b/src/i18n/locales/pt-br.ts index 3a8b8ad2..0588a0db 100644 --- a/src/i18n/locales/pt-br.ts +++ b/src/i18n/locales/pt-br.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Tornar Público', download: 'Baixar', 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', delete: 'Excluir', deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?', diff --git a/src/i18n/locales/pt-pt.ts b/src/i18n/locales/pt-pt.ts index 2ce1b485..0c305b2e 100644 --- a/src/i18n/locales/pt-pt.ts +++ b/src/i18n/locales/pt-pt.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Tornar Público', download: 'Descarregar', 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', delete: 'Excluir', deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?', diff --git a/src/i18n/locales/tr-tr.ts b/src/i18n/locales/tr-tr.ts index 4d939f11..f605bdab 100644 --- a/src/i18n/locales/tr-tr.ts +++ b/src/i18n/locales/tr-tr.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Herkese Açık Yap', download: 'İndir', 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', delete: 'Sil', // eslint-disable-next-line max-len diff --git a/src/i18n/locales/vi-vn.ts b/src/i18n/locales/vi-vn.ts index 08d4b178..04bb7d0b 100644 --- a/src/i18n/locales/vi-vn.ts +++ b/src/i18n/locales/vi-vn.ts @@ -135,6 +135,10 @@ export const TEXT: I18N = { public: 'Làm công khai', download: 'Tải xuống', 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', delete: 'Xóa', deleteConfirm: 'Bạn có chắc chắn muốn xóa "{{photoTitle}}?"', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index d0296eb3..b35782fe 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -135,6 +135,9 @@ export const TEXT: I18N = { public: '设为公开', download: '下载', sync: '同步', + syncAutomatic: '自动', + syncOverwrite: '覆盖', + syncOverwriteConfirm: '确定要覆盖所有照片字段吗?自定义数据可能会丢失。', reupload: '重新上传', delete: '删除', deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?', diff --git a/src/photo/actions.ts b/src/photo/actions.ts index d38d31ff..38beed2e 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -22,7 +22,6 @@ import { getPhotoOptionsCountForPath, } from '@/db'; import { - FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC, PhotoFormData, convertFormDataToPhotoDbInsert, convertPhotoToFormData, @@ -583,9 +582,11 @@ export const getExifDataAction = async ( export const syncPhotoAction = async ( photoId: string, { isBatch, + syncMode = 'auto', updateMode, }: { isBatch?: boolean, + syncMode?: 'auto' | 'only-missing' | 'overwrite', updateMode?: boolean, } = {}, ) => @@ -602,7 +603,7 @@ export const syncPhotoAction = async ( includeInitialPhotoFields: false, generateBlurData: BLUR_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: !( updateMode && photo.colorData !== undefined && @@ -643,10 +644,22 @@ export const syncPhotoAction = async ( const formDataFromPhoto = convertPhotoToFormData(photo); - // Don't overwrite manually configured meta with null data - FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => { - if (!formDataFromExif[field] && formDataFromPhoto[field]) { - delete formDataFromExif[field]; + Object.entries(formDataFromExif).forEach(([field, value]) => { + const existingValue = + formDataFromPhoto[field as keyof PhotoFormData]; + 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; } }); diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index be85b270..4cfb744a 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -69,7 +69,6 @@ export type FormMeta = { tagOptionsLimit?: number tagOptionsLimitValidationMessage?: string tagOptionsShouldParameterize?: boolean - shouldNotOverwriteWithNullDataOnSync?: boolean isJson?: boolean staticValue?: string }; @@ -89,7 +88,6 @@ const FORM_METADATA = ( label: 'title', capitalize: true, validateStringMaxLength: STRING_MAX_LENGTH_SHORT, - shouldNotOverwriteWithNullDataOnSync: true, }, caption: { section: 'text', @@ -155,7 +153,6 @@ const FORM_METADATA = ( noteShort: 'Fujifilm / Nikon / analog scans', tagOptions: filmOptions, tagOptionsLimit: 1, - shouldNotOverwriteWithNullDataOnSync: true, }, recipeTitle: { section: 'exif', @@ -187,7 +184,6 @@ const FORM_METADATA = ( spellCheck: false, capitalize: false, shouldHide: ({ make }) => make !== MAKE_FUJIFILM, - shouldNotOverwriteWithNullDataOnSync: true, isJson: true, validate: value => { let validationMessage = undefined; @@ -299,11 +295,6 @@ export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA()) .filter(([_, meta]) => meta.isJson) .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 = ( ...args: Parameters ) => diff --git a/tailwind.css b/tailwind.css index 20830ca6..32d664dd 100644 --- a/tailwind.css +++ b/tailwind.css @@ -142,6 +142,10 @@ html { @apply text-red-500 dark:text-red-400 } +@utility text-warning { + @apply + text-amber-700 dark:text-amber-500 +} /* Rules */ @utility border-main { @apply