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

View File

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

View File

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

View File

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

View File

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