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 { 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))) {

View File

@ -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

View File

@ -151,6 +151,7 @@ export default function AdminPhotosUpdateClient({
photoIdsSyncing={photoIdsSyncing}
hasAiTextGeneration={hasAiTextGeneration}
canEdit={false}
canSync={true}
canDelete={false}
dateType="updatedAt"
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 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({
@ -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>

View File

@ -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',
);
}
};

View File

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

View File

@ -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}}?"',

View File

@ -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}}?"',

View File

@ -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

View File

@ -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}}"?',

View File

@ -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}}"?',

View File

@ -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}}"?',

View File

@ -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

View File

@ -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}}?"',

View File

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

View File

@ -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;
}
});

View File

@ -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>
) =>

View File

@ -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