Finalize batch sync logic
This commit is contained in:
parent
f8c0a46f2f
commit
fde890ed17
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"
|
||||
/>}
|
||||
|
||||
@ -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(); }
|
||||
|
||||
@ -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,
|
||||
|
||||
20
src/utility/useScrollIntoView.ts
Normal file
20
src/utility/useScrollIntoView.ts
Normal 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]);
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user