From 787f638cd7e8bb88d8710d84669e8fd16c5ecc23 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 17 Jun 2024 00:13:20 -0500 Subject: [PATCH] Finalize sync/cleanup ux --- src/admin/AdminOutdatedClient.tsx | 95 +++++++++++++++++++++++ src/admin/AdminPhotosClient.tsx | 4 +- src/admin/AdminPhotosTable.tsx | 39 ++++++---- src/admin/ExifSyncButton.tsx | 37 +++++++++ src/admin/PhotoSyncButton.tsx | 80 +++++++++---------- src/app/admin/outdated/page.tsx | 61 ++------------- src/app/admin/photos/page.tsx | 2 +- src/components/FormWithConfirm.tsx | 3 + src/components/ResponsiveDate.tsx | 7 +- src/components/SubmitButtonWithStatus.tsx | 14 ++-- src/photo/PhotoDate.tsx | 27 ++++++- src/photo/PhotoEditPageClient.tsx | 6 +- src/photo/PhotoSmall.tsx | 2 +- src/photo/actions.ts | 11 ++- src/photo/cache.ts | 3 +- src/photo/db/index.ts | 6 ++ 16 files changed, 267 insertions(+), 130 deletions(-) create mode 100644 src/admin/AdminOutdatedClient.tsx create mode 100644 src/admin/ExifSyncButton.tsx diff --git a/src/admin/AdminOutdatedClient.tsx b/src/admin/AdminOutdatedClient.tsx new file mode 100644 index 00000000..e9c093ee --- /dev/null +++ b/src/admin/AdminOutdatedClient.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { OUTDATED_THRESHOLD, Photo } from '@/photo'; +import AdminPhotosTable from '@/admin/AdminPhotosTable'; +import LoaderButton from '@/components/primitives/LoaderButton'; +import IconGrSync from '@/site/IconGrSync'; +import Banner from '@/components/Banner'; +import AdminChildPage from '@/components/AdminChildPage'; +import { PATH_ADMIN_PHOTOS } from '@/site/paths'; +import { useState } from 'react'; +import { syncPhotosAction } from '@/photo/actions'; +import { useRouter } from 'next/navigation'; + +const UPDATE_BATCH_SIZE = 4; + +export default function AdminOutdatedClient({ + photos, + hasAiTextGeneration, +}: { + photos: Photo[] + hasAiTextGeneration: boolean +}) { + const [photoIdsSyncing, setPhotoIdsSyncing] = useState([]); + + const router = useRouter(); + + return ( + + + Outdated ({photos.length}) + + + Outdated + + } + accessory={} + hideTextOnMobile={false} + className="primary" + onClick={async () => { + // eslint-disable-next-line max-len + if (window.confirm(`Are you sure you want to sync the oldest ${UPDATE_BATCH_SIZE} photos?`)) { + const photosToSync = photos + .slice(0, UPDATE_BATCH_SIZE) + .map(photo => photo.id); + setPhotoIdsSyncing(photosToSync); + syncPhotosAction(photosToSync) + .finally(() => { + setPhotoIdsSyncing([]); + router.refresh(); + }); + } + }} + > + + Sync {UPDATE_BATCH_SIZE} Oldest Photos + + + Sync {UPDATE_BATCH_SIZE} Oldest + + } + > +
+ +
+ {photos.length} + {' '} + {photos.length === 1 ? 'photo' : 'photos'} + {' ('}uploaded before + {' '} + {new Date(OUTDATED_THRESHOLD).toLocaleDateString()}{')'} + {' '} + may have: missing EXIF fields, inaccurate blur data, + {' '} + undesired privacy settings + {hasAiTextGeneration && ', missing AI-generated text'} +
+
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 1087ddf0..23248d8a 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -12,7 +12,7 @@ import { PATH_ADMIN_OUTDATED } from '@/site/paths'; import { Photo } from '@/photo'; import { StorageListResponse } from '@/services/storage'; import { useState } from 'react'; -import { FaRegClock } from 'react-icons/fa6'; +import { LiaBroomSolid } from 'react-icons/lia'; export default function AdminPhotosClient({ photos, @@ -48,7 +48,7 @@ export default function AdminPhotosClient({ {photosCountOutdated > 0 && } + icon={} title={`${photosCountOutdated} Outdated Photos`} className={clsx( isUploading && 'hidden md:inline-flex', diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 601aa7d2..bd580183 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -12,10 +12,7 @@ import PhotoDate from '@/photo/PhotoDate'; import FormWithConfirm from '@/components/FormWithConfirm'; import EditButton from './EditButton'; import DeleteButton from './DeleteButton'; -import { - deletePhotoFormAction, - syncPhotoAction, -} from '@/photo/actions'; +import { deletePhotoFormAction } from '@/photo/actions'; import { useAppState } from '@/state/AppState'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import PhotoSyncButton from './PhotoSyncButton'; @@ -24,21 +21,28 @@ export default function AdminPhotosTable({ photos, onLastPhotoVisible, revalidatePhoto, + photoIdsSyncing = [], hasAiTextGeneration, - showCreatedAt, + showUpdatedAt, canEdit = true, canDelete = true, }: { photos: Photo[], onLastPhotoVisible?: () => void revalidatePhoto?: RevalidatePhoto - hasAiTextGeneration?: boolean - showCreatedAt?: boolean + photoIdsSyncing?: string[] + hasAiTextGeneration: boolean + showUpdatedAt?: boolean canEdit?: boolean canDelete?: boolean }) { const { invalidateSwr } = useAppState(); + const opacityForPhotoId = (photoId: string) => + photoIdsSyncing.length > 0 && !photoIdsSyncing.includes(photoId) + ? 'opacity-40' + : undefined; + return ( {photos.map((photo, index) => @@ -48,8 +52,12 @@ export default function AdminPhotosTable({ onVisible={index === photos.length - 1 ? onLastPhotoVisible : undefined} + className={opacityForPhotoId(photo.id)} /> -
+
- {showCreatedAt - ? - : } +
} 0} + className={opacityForPhotoId(photo.id)} shouldConfirm shouldToast /> diff --git a/src/admin/ExifSyncButton.tsx b/src/admin/ExifSyncButton.tsx new file mode 100644 index 00000000..b91367a9 --- /dev/null +++ b/src/admin/ExifSyncButton.tsx @@ -0,0 +1,37 @@ +import FormWithConfirm from '@/components/FormWithConfirm'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import IconGrSync from '@/site/IconGrSync'; +import { clsx } from 'clsx/lite'; +import { ComponentProps } from 'react'; + +export default function ExifSyncButton({ + action, + label, + onFormSubmit, + photoUrl, + className, +}: { + action: (formData: FormData) => void + label?: string + photoUrl?: string +} & ComponentProps) { + return ( + + + } + onFormSubmit={onFormSubmit} + > + {label} + + + ); +} diff --git a/src/admin/PhotoSyncButton.tsx b/src/admin/PhotoSyncButton.tsx index 2eda5877..ea410d82 100644 --- a/src/admin/PhotoSyncButton.tsx +++ b/src/admin/PhotoSyncButton.tsx @@ -1,61 +1,61 @@ -import FormWithConfirm from '@/components/FormWithConfirm'; -import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import LoaderButton from '@/components/primitives/LoaderButton'; +import { syncPhotoAction } from '@/photo/actions'; import IconGrSync from '@/site/IconGrSync'; -import { clsx } from 'clsx/lite'; -import { ComponentProps } from 'react'; +import { toastSuccess } from '@/toast'; +import { ComponentProps, useState } from 'react'; export default function PhotoSyncButton({ - action, - label, - onFormSubmit, - formData: { photoId, photoUrl } = {}, + photoId, photoTitle, + onSyncComplete, + className, + isSyncingExternal, hasAiTextGeneration, + disabled, shouldConfirm, shouldToast, }: { - action: (formData: FormData) => void - label?: string - formData?: { - photoId?: string - photoUrl?: string - } + photoId: string photoTitle?: string + onSyncComplete?: () => void + isSyncingExternal?: boolean hasAiTextGeneration?: boolean shouldConfirm?: boolean shouldToast?: boolean -} & ComponentProps) { +} & ComponentProps) { + const [isSyncing, setIsSyncing] = useState(false); + const confirmText = ['Overwrite']; if (photoTitle) { confirmText.push(`"${photoTitle}"`); } confirmText.push('data from original file?'); if (hasAiTextGeneration) { confirmText.push( 'AI text will be generated for undefined fields.'); } confirmText.push('This action cannot be undone.'); + return ( - - {photoId && - } - {photoUrl && - } - } - onFormSubmitToastMessage={shouldToast - ? photoTitle - ? `"${photoTitle}" data synced` - : 'Data synced' - : undefined} - onFormSubmit={onFormSubmit} - > - {label} - - + } + onClick={() => { + if (!shouldConfirm || window.confirm(confirmText.join(' '))) { + setIsSyncing(true); + syncPhotoAction(photoId) + .then(() => { + onSyncComplete?.(); + if (shouldToast) { + toastSuccess(photoTitle + ? `"${photoTitle}" data synced` + : 'Data synced'); + } + }) + .finally(() => setIsSyncing(false)); + } + }} + isLoading={isSyncing || isSyncingExternal} + disabled={disabled} + /> ); } diff --git a/src/app/admin/outdated/page.tsx b/src/app/admin/outdated/page.tsx index 3dcabeba..a6d707b1 100644 --- a/src/app/admin/outdated/page.tsx +++ b/src/app/admin/outdated/page.tsx @@ -1,65 +1,20 @@ -import { AI_TEXT_GENERATION_ENABLED } from '@/site/config'; import { getPhotos } from '@/photo/db/query'; -import AdminPhotosTable from '@/admin/AdminPhotosTable'; import { OUTDATED_THRESHOLD } from '@/photo'; -import LoaderButton from '@/components/primitives/LoaderButton'; -import IconGrSync from '@/site/IconGrSync'; -import Banner from '@/components/Banner'; -import AdminChildPage from '@/components/AdminChildPage'; -import { PATH_ADMIN_PHOTOS } from '@/site/paths'; +import AdminOutdatedClient from '@/admin/AdminOutdatedClient'; +import { AI_TEXT_GENERATION_ENABLED } from '@/site/config'; -const UPDATE_BATCH_SIZE = 5; - -export default async function AdminPhotosPage() { +export default async function AdminOutdatedPage() { const photos = await getPhotos({ hidden: 'include', sortBy: 'createdAtAsc', - takenBefore: OUTDATED_THRESHOLD, + updatedBefore: OUTDATED_THRESHOLD, limit: 1_000, }).catch(() => []); return ( - } - hideTextOnMobile={false} - className="primary" - > - - Sync Oldest {UPDATE_BATCH_SIZE} Photos - - - Sync Oldest - - } - > -
- -
- These photos {'('}uploaded before - {' '} - {new Date(OUTDATED_THRESHOLD).toLocaleDateString()}{')'} - {' '} - may have: missing EXIF fields, inaccurate blur data, - {' '} - undesired privacy settings, - {' '} - and missing AI-generated text. -
-
-
- -
-
- + ); } diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 8f8d4279..41c59dad 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -27,7 +27,7 @@ export default async function AdminPhotosPage() { .catch(() => 0), getPhotosMetaCached({ hidden: 'include', - takenBefore: OUTDATED_THRESHOLD, + updatedBefore: OUTDATED_THRESHOLD, }) .then(({ count }) => count) .catch(() => 0), diff --git a/src/components/FormWithConfirm.tsx b/src/components/FormWithConfirm.tsx index d0d68a17..1b97ea84 100644 --- a/src/components/FormWithConfirm.tsx +++ b/src/components/FormWithConfirm.tsx @@ -6,11 +6,13 @@ export default function FormWithConfirm({ action, confirmText, onSubmit, + className, children, }: { action: (formData: FormData) => void confirmText?: string onSubmit?: () => void + className?: string children: ReactNode }) { return ( @@ -24,6 +26,7 @@ export default function FormWithConfirm({ e.preventDefault(); } }} + className={className} > {children} diff --git a/src/components/ResponsiveDate.tsx b/src/components/ResponsiveDate.tsx index 353a5416..77606a4c 100644 --- a/src/components/ResponsiveDate.tsx +++ b/src/components/ResponsiveDate.tsx @@ -4,13 +4,18 @@ import { clsx } from 'clsx/lite'; export default function ResponsiveDate({ date, className, + titleLabel, }: { date: Date className?: string + titleLabel?: string }) { + const title = titleLabel + ? `${titleLabel}: ${formatDate(date).toLocaleUpperCase()}` + : formatDate(date).toLocaleUpperCase(); return ( {/* Small */} diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 9ab870a8..3b6ab685 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -6,13 +6,6 @@ import { clsx } from 'clsx/lite'; import { toastSuccess } from '@/toast'; import LoaderButton from '@/components/primitives/LoaderButton'; -interface Props extends ComponentProps { - onFormStatusChange?: (pending: boolean) => void - onFormSubmitToastMessage?: string - onFormSubmit?: () => void - primary?: boolean -} - export default function SubmitButtonWithStatus({ icon, styleAs, @@ -26,7 +19,12 @@ export default function SubmitButtonWithStatus({ primary, type: _type, ...buttonProps -}: Props) { +}: { + onFormStatusChange?: (pending: boolean) => void + onFormSubmitToastMessage?: string + onFormSubmit?: () => void + primary?: boolean +} & ComponentProps) { const { pending } = useFormStatus(); const pendingPrevious = useRef(pending); diff --git a/src/photo/PhotoDate.tsx b/src/photo/PhotoDate.tsx index 2d8686e3..93e5eba1 100644 --- a/src/photo/PhotoDate.tsx +++ b/src/photo/PhotoDate.tsx @@ -9,19 +9,38 @@ export default function PhotoDate({ }: { photo: Photo className?: string - dateType?: 'takenAt' | 'createdAt' + dateType?: 'takenAt' | 'createdAt' | 'updatedAt' }) { const date = useMemo(() => { const date = new Date(dateType === 'takenAt' ? photo.takenAt - : photo.createdAt); + : dateType === 'createdAt' + ? photo.createdAt + : photo.updatedAt); return isNaN(date.getTime()) ? new Date() : date; }, [ dateType, - photo.createdAt, photo.takenAt, + photo.createdAt, + photo.updatedAt, ]); + + const getTitleLabel = () => { + switch (dateType) { + case 'takenAt': + return 'TAKEN'; + case 'createdAt': + return 'CREATED'; + case 'updatedAt': + return 'UPDATED'; + } + }; + return ( - + ); } diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index 8b7fc05f..4214f088 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -14,7 +14,7 @@ import { getExifDataAction } from './actions'; import { TagsWithMeta } from '@/tag'; import AiButton from './ai/AiButton'; import usePhotoFormParent from './form/usePhotoFormParent'; -import PhotoSyncButton from '@/admin/PhotoSyncButton'; +import ExifSyncButton from '@/admin/ExifSyncButton'; export default function PhotoEditPageClient({ photo, @@ -68,10 +68,10 @@ export default function PhotoEditPageClient({
{hasAiTextGeneration && } -
} isLoading={pending} diff --git a/src/photo/PhotoSmall.tsx b/src/photo/PhotoSmall.tsx index 66a9819a..282685f5 100644 --- a/src/photo/PhotoSmall.tsx +++ b/src/photo/PhotoSmall.tsx @@ -43,7 +43,7 @@ export default function PhotoSmall({ 'active:brightness-75', selected && 'brightness-50', 'min-w-[50px]', - 'rounded-[0.15rem] overflow-hidden', + 'rounded-[3px] overflow-hidden', 'border border-gray-200 dark:border-gray-800', )} prefetch={prefetch} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 83460245..80f69397 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -291,9 +291,8 @@ export const getExifDataAction = async ( // - strip GPS data if necessary // - update blur data (or destroy if blur is disabled) // - generate AI text data, if enabled, and auto-generated fields are empty -export const syncPhotoAction = async (formData: FormData) => +export const syncPhotoAction = async (photoId: string) => runAuthenticatedAdminServerAction(async () => { - const photoId = formData.get('photoId') as string | undefined; const photo = await getPhoto(photoId ?? '', true); if (photo) { @@ -347,6 +346,14 @@ export const syncPhotoAction = async (formData: FormData) => } }); +export const syncPhotosAction = async (photoIds: string[]) => + runAuthenticatedAdminServerAction(async () => { + for (const photoId of photoIds) { + await syncPhotoAction(photoId); + } + revalidateAllKeysAndPaths(); + }); + export const clearCacheAction = async () => runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths); diff --git a/src/photo/cache.ts b/src/photo/cache.ts index 07e3ce7c..fc21653f 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -62,7 +62,8 @@ const getPhotosCacheKeyForOption = ( return value ? `${option}-${createLensKey(value)}` : null; } case 'takenBefore': - case 'takenAfterInclusive': { + case 'takenAfterInclusive': + case 'updatedBefore': { const value = options[option]; return value ? `${option}-${value.toISOString()}` : null; } diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index 350cbf4f..8ee6db19 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -19,6 +19,7 @@ export type GetPhotosOptions = { focal?: number takenBefore?: Date takenAfterInclusive?: Date + updatedBefore?: Date hidden?: 'exclude' | 'include' | 'only' }; @@ -30,6 +31,7 @@ export const getWheresFromOptions = ( hidden = 'exclude', takenBefore, takenAfterInclusive, + updatedBefore, query, tag, camera, @@ -59,6 +61,10 @@ export const getWheresFromOptions = ( wheres.push(`taken_at >= $${valuesIndex++}`); wheresValues.push(takenAfterInclusive.toISOString()); } + if (updatedBefore) { + wheres.push(`updated_at < $${valuesIndex++}`); + wheresValues.push(updatedBefore.toISOString()); + } if (query) { // eslint-disable-next-line max-len wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);