Finalize batch sync logic

This commit is contained in:
Sam Becker 2025-04-20 17:02:06 -05:00
parent f8c0a46f2f
commit fde890ed17
7 changed files with 121 additions and 40 deletions

View File

@ -6,14 +6,15 @@ import IconGrSync from '@/components/icons/IconGrSync';
import Note from '@/components/Note'; import Note from '@/components/Note';
import AdminChildPage from '@/components/AdminChildPage'; import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { syncPhotosAction } from '@/photo/actions'; import { syncPhotosAction } from '@/photo/actions';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { LiaBroomSolid } from 'react-icons/lia'; import { LiaBroomSolid } from 'react-icons/lia';
import ProgressButton from '@/components/primitives/ProgressButton'; import ProgressButton from '@/components/primitives/ProgressButton';
import ErrorNote from '@/components/ErrorNote';
const UPDATE_BATCH_SIZE_MAX = 4; const SYNC_BATCH_SIZE_MAX = 3;
export default function AdminPhotosSyncClient({ export default function AdminPhotosSyncClient({
photos, photos,
@ -22,9 +23,14 @@ export default function AdminPhotosSyncClient({
photos: Photo[] photos: Photo[]
hasAiTextGeneration: boolean hasAiTextGeneration: boolean
}) { }) {
const updateBatchSize = Math.min(UPDATE_BATCH_SIZE_MAX, photos.length); // Use refs for non-reactive while loop state
const photoIdsToSync = useRef(photos.map(photo => photo.id));
const errorRef = useRef<Error>(undefined);
// Use state for updating progress button and error UI
const [photoIdsSyncing, setPhotoIdsSyncing] = useState<string[]>([]); const [photoIdsSyncing, setPhotoIdsSyncing] = useState<string[]>([]);
const [error, setError] = useState<Error>();
const [progress, setProgress] = useState(0);
const arePhotoIdsSyncing = photoIdsSyncing.length > 0; const arePhotoIdsSyncing = photoIdsSyncing.length > 0;
@ -41,36 +47,64 @@ export default function AdminPhotosSyncClient({
primary primary
icon={<IconGrSync className="translate-y-[1px]" />} icon={<IconGrSync className="translate-y-[1px]" />}
hideTextOnMobile={false} hideTextOnMobile={false}
progress={progress}
onClick={async () => { onClick={async () => {
if (window.confirm( if (window.confirm([
// eslint-disable-next-line max-len 'Are you sure you want to sync',
`Are you sure you want to sync the oldest ${updateBatchSize} photos? This action cannot be undone.`, photos.length === 1
)) { ? '1 outdated photo?'
const photosToSync = photos : `all ${photos.length} outdated photos?`,
.slice(0, updateBatchSize) 'Browser must remain open while syncing.',
.map(photo => photo.id); 'This action cannot be undone.',
const isFinalBatch = photosToSync.length >= photos.length; ].join(' '))) {
setPhotoIdsSyncing(photosToSync); errorRef.current = undefined;
syncPhotosAction(photosToSync) setError(undefined);
.finally(() => { while (photoIdsToSync.current.length > 0) {
if (isFinalBatch) { const photoIds = photoIdsToSync.current
router.push(PATH_ADMIN_PHOTOS); .slice(0, SYNC_BATCH_SIZE_MAX);
} else { setPhotoIdsSyncing(photoIds);
setPhotoIdsSyncing([]); await syncPhotosAction(photoIds)
.then(() => {
photoIdsToSync.current = photoIdsToSync.current.filter(
id => !photoIds.includes(id),
);
setProgress(
(photos.length - photoIdsToSync.current.length) /
photos.length,
);
router.refresh(); router.refresh();
} })
}); .catch(e => {
errorRef.current = e;
setError(e);
});
if (errorRef.current) { break; }
}
if (!errorRef.current) {
router.push(PATH_ADMIN_PHOTOS);
} else {
setProgress(0);
setPhotoIdsSyncing([]);
router.refresh();
}
} }
}} }}
isLoading={arePhotoIdsSyncing} isLoading={arePhotoIdsSyncing}
disabled={!updateBatchSize} disabled={photoIdsSyncing.length > 0}
> >
{arePhotoIdsSyncing {arePhotoIdsSyncing
? 'Syncing' ? 'Syncing ...'
: 'Sync All'} : 'Sync All'}
</ProgressButton>} </ProgressButton>}
> >
<div className="space-y-6"> <div className="space-y-6">
{error && <ErrorNote>
<span className="font-bold">
Issue syncing:
</span>
{' '}
{error.message}
</ErrorNote>}
<Note <Note
color="blue" color="blue"
icon={<LiaBroomSolid size={18}/>} icon={<LiaBroomSolid size={18}/>}
@ -81,11 +115,11 @@ export default function AdminPhotosSyncClient({
{' '} {' '}
{photos.length === 1 ? 'photo' : 'photos'} {photos.length === 1 ? 'photo' : 'photos'}
{' '} {' '}
could benefit from being synced found
</div> </div>
Sync photos to import newer EXIF fields, improve blur data, Sync to capture newer EXIF fields, improve blur data,
{' '} {' '}
and generate AI text when configured and use AI to generate missing text (if configured)
</div> </div>
</Note> </Note>
<div className="space-y-4"> <div className="space-y-4">
@ -96,6 +130,7 @@ export default function AdminPhotosSyncClient({
canEdit={false} canEdit={false}
canDelete={false} canDelete={false}
dateType="updatedAt" dateType="updatedAt"
shouldScrollIntoViewOnExternalSync
/> />
</div> </div>
</div> </div>

View File

@ -28,6 +28,7 @@ export default function AdminPhotosTable({
canEdit = true, canEdit = true,
canDelete = true, canDelete = true,
timezone, timezone,
shouldScrollIntoViewOnExternalSync,
}: { }: {
photos: Photo[], photos: Photo[],
onLastPhotoVisible?: () => void onLastPhotoVisible?: () => void
@ -38,6 +39,7 @@ export default function AdminPhotosTable({
canEdit?: boolean canEdit?: boolean
canDelete?: boolean canDelete?: boolean
timezone?: Timezone timezone?: Timezone
shouldScrollIntoViewOnExternalSync?: boolean
}) { }) {
const { invalidateSwr } = useAppState(); const { invalidateSwr } = useAppState();
@ -70,7 +72,7 @@ export default function AdminPhotosTable({
<span className={clsx( <span className={clsx(
photo.hidden && 'text-dim', photo.hidden && 'text-dim',
)}> )}>
{titleForPhoto(photo)} {titleForPhoto(photo, false)}
{photo.hidden && <span className="whitespace-nowrap"> {photo.hidden && <span className="whitespace-nowrap">
{' '} {' '}
<IconHidden <IconHidden
@ -122,6 +124,8 @@ export default function AdminPhotosTable({
className={opacityForPhotoId(photo.id)} className={opacityForPhotoId(photo.id)}
shouldConfirm shouldConfirm
shouldToast shouldToast
shouldScrollIntoViewOnExternalSync={
shouldScrollIntoViewOnExternalSync}
/> />
{canDelete && {canDelete &&
<DeletePhotoButton <DeletePhotoButton

View File

@ -2,8 +2,10 @@ import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions'; import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/components/icons/IconGrSync'; import IconGrSync from '@/components/icons/IconGrSync';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import { ComponentProps, useState } from 'react'; import { ComponentProps, useRef, useState } from 'react';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import clsx from 'clsx/lite';
import useScrollIntoView from '@/utility/useScrollIntoView';
export default function PhotoSyncButton({ export default function PhotoSyncButton({
photoId, photoId,
@ -15,6 +17,7 @@ export default function PhotoSyncButton({
disabled, disabled,
shouldConfirm, shouldConfirm,
shouldToast, shouldToast,
shouldScrollIntoViewOnExternalSync,
}: { }: {
photoId: string photoId: string
photoTitle?: string photoTitle?: string
@ -23,7 +26,10 @@ export default function PhotoSyncButton({
hasAiTextGeneration?: boolean hasAiTextGeneration?: boolean
shouldConfirm?: boolean shouldConfirm?: boolean
shouldToast?: boolean shouldToast?: boolean
shouldScrollIntoViewOnExternalSync?: boolean
} & ComponentProps<typeof LoaderButton>) { } & ComponentProps<typeof LoaderButton>) {
const ref = useRef<HTMLButtonElement>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const confirmText = ['Overwrite']; const confirmText = ['Overwrite'];
@ -33,10 +39,18 @@ export default function PhotoSyncButton({
'AI text will be generated for undefined fields.'); } 'AI text will be generated for undefined fields.'); }
confirmText.push('This action cannot be undone.'); confirmText.push('This action cannot be undone.');
useScrollIntoView({
ref,
shouldScrollIntoView:
isSyncingExternal &&
shouldScrollIntoViewOnExternalSync,
});
return ( return (
<Tooltip content="Regenerate photo data"> <Tooltip content="Regenerate photo data">
<LoaderButton <LoaderButton
className={className} ref={ref}
className={clsx('scroll-mt-8', className)}
icon={<IconGrSync icon={<IconGrSync
className="translate-y-[0.5px] translate-x-[0.5px]" className="translate-y-[0.5px] translate-x-[0.5px]"
/>} />}

View File

@ -2,10 +2,16 @@
import Spinner, { SpinnerColor } from '@/components/Spinner'; import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'; import {
ButtonHTMLAttributes,
ComponentProps,
ReactNode,
RefObject,
} from 'react';
import Tooltip from '../Tooltip'; import Tooltip from '../Tooltip';
export default function LoaderButton({ export default function LoaderButton({
ref,
children, children,
isLoading, isLoading,
icon, icon,
@ -25,6 +31,7 @@ export default function LoaderButton({
tooltipColor, tooltipColor,
...rest ...rest
}: { }: {
ref?: RefObject<HTMLButtonElement | null>
isLoading?: boolean isLoading?: boolean
icon?: ReactNode icon?: ReactNode
spinnerColor?: SpinnerColor spinnerColor?: SpinnerColor
@ -41,6 +48,7 @@ export default function LoaderButton({
const button = const button =
<button <button
{...rest} {...rest}
ref={ref}
type={type} type={type}
onClick={e => { onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); } if (shouldPreventDefault) { e.preventDefault(); }

View File

@ -1,6 +1,6 @@
import { RefObject, useCallback, useEffect, useState } from 'react'; import { RefObject, useCallback, useMemo, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside'; import useClickInsideOutside from '@/utility/useClickInsideOutside';
import useScrollIntoView from '@/utility/useScrollIntoView';
export default function useRecipeOverlay({ export default function useRecipeOverlay({
ref, ref,
@ -19,16 +19,18 @@ export default function useRecipeOverlay({
setIsShowingRecipeOverlay(current => !current), setIsShowingRecipeOverlay(current => !current),
[]); []);
const htmlElements = useMemo(() =>
[ref, ...refTriggers], [ref, refTriggers]);
useClickInsideOutside({ useClickInsideOutside({
htmlElements: [ref, ...refTriggers], htmlElements,
onClickOutside: hideRecipeOverlay, onClickOutside: hideRecipeOverlay,
}); });
useEffect(() => { useScrollIntoView({
if (isShowingRecipeOverlay && !isElementEntirelyInViewport(ref?.current)) { ref,
ref?.current?.scrollIntoView({ behavior: 'smooth' }); shouldScrollIntoView: isShowingRecipeOverlay,
} });
}, [ref, isShowingRecipeOverlay]);
return { return {
isShowingRecipeOverlay, isShowingRecipeOverlay,

View File

@ -0,0 +1,20 @@
import { RefObject, useEffect } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
export default function useScrollIntoView({
ref,
shouldScrollIntoView,
}: {
ref?: RefObject<HTMLElement | null>
shouldScrollIntoView?: boolean
}) {
useEffect(() => {
if (
ref?.current &&
!isElementEntirelyInViewport(ref.current) &&
shouldScrollIntoView
) {
ref.current.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldScrollIntoView]);
}

View File

@ -1,6 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useEffect } from 'react';
export default function useVisualViewportHeight() { export default function useVisualViewportHeight() {
const [viewportHeight, setViewportHeight] = useState<number>(); const [viewportHeight, setViewportHeight] = useState<number>();