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 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]"
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@ -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(); }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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 { 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>();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user