Offer explicit sync controls
This commit is contained in:
parent
7b115da8d4
commit
d2cca5ec9e
@ -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<typeof MoreMenuItem>[] = [{
|
||||
const items: MoreMenuSection['items'] = [{
|
||||
label: appText.admin.edit,
|
||||
icon: <IconEdit
|
||||
size={14}
|
||||
@ -144,9 +144,25 @@ export default function AdminPhotoMenu({
|
||||
icon: <IconGrSync
|
||||
className="translate-x-[-1px] translate-y-[0.5px]"
|
||||
/>,
|
||||
items: [{
|
||||
label: appText.admin.syncAutomatic,
|
||||
icon: <IconGrSync
|
||||
className="translate-x-[-1px] translate-y-[0.5px]"
|
||||
/>,
|
||||
action: () => syncPhotoAction(photo.id)
|
||||
.then(() => revalidatePhoto?.(photo.id)),
|
||||
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync },
|
||||
}, {
|
||||
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({
|
||||
label: appText.admin.reupload,
|
||||
@ -184,7 +200,7 @@ export default function AdminPhotoMenu({
|
||||
icon: <IconTrash
|
||||
className="translate-x-[-1px]"
|
||||
/>,
|
||||
className: 'text-error *:hover:text-error',
|
||||
className: 'text-error *:hover:text-error *:active:text-error',
|
||||
color: 'red',
|
||||
action: () => {
|
||||
if (confirm(deleteConfirmationTextForPhoto(photo, appText))) {
|
||||
|
||||
@ -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,6 +129,7 @@ export default function AdminPhotosTable({
|
||||
)}>
|
||||
{canEdit &&
|
||||
<EditButton path={pathForAdminPhotoEdit(photo)} />}
|
||||
{canSync &&
|
||||
<PhotoSyncButton
|
||||
photo={photo}
|
||||
onSyncComplete={invalidateSwr}
|
||||
@ -139,7 +142,7 @@ export default function AdminPhotosTable({
|
||||
shouldScrollIntoViewOnExternalSync={
|
||||
shouldScrollIntoViewOnExternalSync}
|
||||
updateMode={updateMode}
|
||||
/>
|
||||
/>}
|
||||
{debugColorData &&
|
||||
<SyncColorButton photoId={photo.id} />}
|
||||
<AdminPhotoMenu
|
||||
|
||||
@ -151,6 +151,7 @@ export default function AdminPhotosUpdateClient({
|
||||
photoIdsSyncing={photoIdsSyncing}
|
||||
hasAiTextGeneration={hasAiTextGeneration}
|
||||
canEdit={false}
|
||||
canSync={true}
|
||||
canDelete={false}
|
||||
dateType="updatedAt"
|
||||
shouldScrollIntoViewOnExternalSync
|
||||
|
||||
9
src/components/icons/IconWarning.tsx
Normal file
9
src/components/icons/IconWarning.tsx
Normal 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}
|
||||
/>;
|
||||
}
|
||||
@ -10,11 +10,37 @@ 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: (
|
||||
// Either a menu item
|
||||
ComponentProps<typeof MoreMenuItem> |
|
||||
// or a submenu
|
||||
{
|
||||
label: string
|
||||
labelComplex?: ReactNode
|
||||
icon?: ReactNode
|
||||
items: ComponentProps<typeof MoreMenuItem>[]
|
||||
}
|
||||
)[]
|
||||
}
|
||||
|
||||
export default function MoreMenu({
|
||||
sections,
|
||||
@ -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 && <div className={clsx(
|
||||
'px-3 pt-3 pb-2 text-dim uppercase',
|
||||
@ -126,13 +139,56 @@ export default function MoreMenu({
|
||||
{label}
|
||||
</div>}
|
||||
{items.map(item =>
|
||||
'items' in item
|
||||
? <DropdownMenu.DropdownMenuSub key={item.label}>
|
||||
<DropdownMenu.SubTrigger asChild>
|
||||
<div className="mx-1 focus:outline-none">
|
||||
<div className={clsx(
|
||||
'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>,
|
||||
)}
|
||||
</div>)}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.DropdownMenuSub>
|
||||
: <div key={item.label} className="px-1">
|
||||
<MoreMenuItem
|
||||
{...item}
|
||||
dismissMenu={dismissMenu}
|
||||
/>
|
||||
</div>)}
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -135,6 +135,10 @@ export const TEXT: I18N = {
|
||||
public: 'সর্বজনীন করুন',
|
||||
download: 'ডাউনলোড',
|
||||
sync: 'সিঙ্ক',
|
||||
syncAutomatic: 'স্বয়ংক্রিয়',
|
||||
syncOverwrite: 'ওভাররাইট করুন',
|
||||
// eslint-disable-next-line max-len
|
||||
syncOverwriteConfirm: 'আপনি কি নিশ্চিত যে আপনি সমস্ত ফটো ফিল্ড ওভাররাইট করতে চান? কাস্টমাইজড ডেটা হারিয়ে যেতে পারে।',
|
||||
reupload: 'পুনরায় আপলোড করুন',
|
||||
delete: 'ডিলিট',
|
||||
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
||||
|
||||
@ -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}}?"',
|
||||
|
||||
@ -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}}?"',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}}"?',
|
||||
|
||||
@ -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}}"?',
|
||||
|
||||
@ -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}}"?',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}}?"',
|
||||
|
||||
@ -135,6 +135,9 @@ export const TEXT: I18N = {
|
||||
public: '设为公开',
|
||||
download: '下载',
|
||||
sync: '同步',
|
||||
syncAutomatic: '自动',
|
||||
syncOverwrite: '覆盖',
|
||||
syncOverwriteConfirm: '确定要覆盖所有照片字段吗?自定义数据可能会丢失。',
|
||||
reupload: '重新上传',
|
||||
delete: '删除',
|
||||
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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<typeof FORM_METADATA>
|
||||
) =>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user