Finalize sync/cleanup ux

This commit is contained in:
Sam Becker 2024-06-17 00:13:20 -05:00
parent aec9748d9a
commit 787f638cd7
16 changed files with 267 additions and 130 deletions

View File

@ -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<string[]>([]);
const router = useRouter();
return (
<AdminChildPage
backLabel="Photos"
backPath={PATH_ADMIN_PHOTOS}
breadcrumb={<>
<span className="hidden sm:inline-block">
Outdated ({photos.length})
</span>
<span className="sm:hidden">
Outdated
</span>
</>}
accessory={<LoaderButton
icon={<IconGrSync className="translate-y-[1px]" />}
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();
});
}
}}
>
<span className="hidden sm:inline-block">
Sync {UPDATE_BATCH_SIZE} Oldest Photos
</span>
<span className="sm:hidden">
Sync {UPDATE_BATCH_SIZE} Oldest
</span>
</LoaderButton>}
>
<div className="space-y-6">
<Banner>
<div className="space-y-1.5">
{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'}
</div>
</Banner>
<div className="space-y-4">
<AdminPhotosTable
photos={photos}
photoIdsSyncing={photoIdsSyncing}
hasAiTextGeneration={hasAiTextGeneration}
canEdit={false}
canDelete={false}
showUpdatedAt
/>
</div>
</div>
</AdminChildPage>
);
}

View File

@ -12,7 +12,7 @@ import { PATH_ADMIN_OUTDATED } from '@/site/paths';
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import { StorageListResponse } from '@/services/storage'; import { StorageListResponse } from '@/services/storage';
import { useState } from 'react'; import { useState } from 'react';
import { FaRegClock } from 'react-icons/fa6'; import { LiaBroomSolid } from 'react-icons/lia';
export default function AdminPhotosClient({ export default function AdminPhotosClient({
photos, photos,
@ -48,7 +48,7 @@ export default function AdminPhotosClient({
</div> </div>
{photosCountOutdated > 0 && <PathLoaderButton {photosCountOutdated > 0 && <PathLoaderButton
path={PATH_ADMIN_OUTDATED} path={PATH_ADMIN_OUTDATED}
icon={<FaRegClock size={15} className="translate-y-[1px]" />} icon={<LiaBroomSolid size={18} className="translate-y-[-1px]" />}
title={`${photosCountOutdated} Outdated Photos`} title={`${photosCountOutdated} Outdated Photos`}
className={clsx( className={clsx(
isUploading && 'hidden md:inline-flex', isUploading && 'hidden md:inline-flex',

View File

@ -12,10 +12,7 @@ import PhotoDate from '@/photo/PhotoDate';
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import EditButton from './EditButton'; import EditButton from './EditButton';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import { import { deletePhotoFormAction } from '@/photo/actions';
deletePhotoFormAction,
syncPhotoAction,
} from '@/photo/actions';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton'; import PhotoSyncButton from './PhotoSyncButton';
@ -24,21 +21,28 @@ export default function AdminPhotosTable({
photos, photos,
onLastPhotoVisible, onLastPhotoVisible,
revalidatePhoto, revalidatePhoto,
photoIdsSyncing = [],
hasAiTextGeneration, hasAiTextGeneration,
showCreatedAt, showUpdatedAt,
canEdit = true, canEdit = true,
canDelete = true, canDelete = true,
}: { }: {
photos: Photo[], photos: Photo[],
onLastPhotoVisible?: () => void onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
hasAiTextGeneration?: boolean photoIdsSyncing?: string[]
showCreatedAt?: boolean hasAiTextGeneration: boolean
showUpdatedAt?: boolean
canEdit?: boolean canEdit?: boolean
canDelete?: boolean canDelete?: boolean
}) { }) {
const { invalidateSwr } = useAppState(); const { invalidateSwr } = useAppState();
const opacityForPhotoId = (photoId: string) =>
photoIdsSyncing.length > 0 && !photoIdsSyncing.includes(photoId)
? 'opacity-40'
: undefined;
return ( return (
<AdminTable> <AdminTable>
{photos.map((photo, index) => {photos.map((photo, index) =>
@ -48,8 +52,12 @@ export default function AdminPhotosTable({
onVisible={index === photos.length - 1 onVisible={index === photos.length - 1
? onLastPhotoVisible ? onLastPhotoVisible
: undefined} : undefined}
className={opacityForPhotoId(photo.id)}
/> />
<div className="flex flex-col lg:flex-row"> <div className={clsx(
'flex flex-col lg:flex-row',
opacityForPhotoId(photo.id),
)}>
<Link <Link
key={photo.id} key={photo.id}
href={pathForPhoto({ photo })} href={pathForPhoto({ photo })}
@ -81,9 +89,10 @@ export default function AdminPhotosTable({
'lg:w-[50%] uppercase', 'lg:w-[50%] uppercase',
'text-dim', 'text-dim',
)}> )}>
{showCreatedAt <PhotoDate {...{
? <PhotoDate {...{ photo, dateType: 'createdAt' }} /> photo,
: <PhotoDate {...{ photo }} />} dateType: showUpdatedAt ? 'updatedAt' : undefined,
}} />
</div> </div>
</div> </div>
<div className={clsx( <div className={clsx(
@ -93,11 +102,13 @@ export default function AdminPhotosTable({
{canEdit && {canEdit &&
<EditButton path={pathForAdminPhotoEdit(photo)} />} <EditButton path={pathForAdminPhotoEdit(photo)} />}
<PhotoSyncButton <PhotoSyncButton
action={syncPhotoAction} photoId={photo.id}
photoTitle={titleForPhoto(photo)} photoTitle={titleForPhoto(photo)}
formData={{ photoId: photo.id }} onSyncComplete={invalidateSwr}
onFormSubmit={invalidateSwr} isSyncingExternal={photoIdsSyncing.includes(photo.id)}
hasAiTextGeneration={hasAiTextGeneration} hasAiTextGeneration={hasAiTextGeneration}
disabled={photoIdsSyncing.length > 0}
className={opacityForPhotoId(photo.id)}
shouldConfirm shouldConfirm
shouldToast shouldToast
/> />

View File

@ -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<typeof SubmitButtonWithStatus>) {
return (
<FormWithConfirm
action={action}
className={className}
>
<input name="photoUrl" value={photoUrl} hidden readOnly />
<SubmitButtonWithStatus
title="Update photo from original file"
icon={<IconGrSync
className={clsx(
'translate-y-[0.5px] translate-x-[0.5px]',
label && 'sm:translate-x-[-0.5px]',
)} />}
onFormSubmit={onFormSubmit}
>
{label}
</SubmitButtonWithStatus>
</FormWithConfirm>
);
}

View File

@ -1,61 +1,61 @@
import FormWithConfirm from '@/components/FormWithConfirm'; import LoaderButton from '@/components/primitives/LoaderButton';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/site/IconGrSync'; import IconGrSync from '@/site/IconGrSync';
import { clsx } from 'clsx/lite'; import { toastSuccess } from '@/toast';
import { ComponentProps } from 'react'; import { ComponentProps, useState } from 'react';
export default function PhotoSyncButton({ export default function PhotoSyncButton({
action, photoId,
label,
onFormSubmit,
formData: { photoId, photoUrl } = {},
photoTitle, photoTitle,
onSyncComplete,
className,
isSyncingExternal,
hasAiTextGeneration, hasAiTextGeneration,
disabled,
shouldConfirm, shouldConfirm,
shouldToast, shouldToast,
}: { }: {
action: (formData: FormData) => void photoId: string
label?: string
formData?: {
photoId?: string
photoUrl?: string
}
photoTitle?: string photoTitle?: string
onSyncComplete?: () => void
isSyncingExternal?: boolean
hasAiTextGeneration?: boolean hasAiTextGeneration?: boolean
shouldConfirm?: boolean shouldConfirm?: boolean
shouldToast?: boolean shouldToast?: boolean
} & ComponentProps<typeof SubmitButtonWithStatus>) { } & ComponentProps<typeof LoaderButton>) {
const [isSyncing, setIsSyncing] = useState(false);
const confirmText = ['Overwrite']; const confirmText = ['Overwrite'];
if (photoTitle) { confirmText.push(`"${photoTitle}"`); } if (photoTitle) { confirmText.push(`"${photoTitle}"`); }
confirmText.push('data from original file?'); confirmText.push('data from original file?');
if (hasAiTextGeneration) { confirmText.push( if (hasAiTextGeneration) { confirmText.push(
'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.');
return ( return (
<FormWithConfirm <LoaderButton
action={action} title="Update photo from original file"
confirmText={shouldConfirm ? confirmText.join(' ') : undefined} className={className}
> icon={<IconGrSync
{photoId && className="translate-y-[0.5px] translate-x-[0.5px]"
<input name="photoId" value={photoId} hidden readOnly />} />}
{photoUrl && onClick={() => {
<input name="photoUrl" value={photoUrl} hidden readOnly />} if (!shouldConfirm || window.confirm(confirmText.join(' '))) {
<SubmitButtonWithStatus setIsSyncing(true);
title="Update photo from original file" syncPhotoAction(photoId)
icon={<IconGrSync .then(() => {
className={clsx( onSyncComplete?.();
'translate-y-[0.5px] translate-x-[0.5px]', if (shouldToast) {
label && 'sm:translate-x-[-0.5px]', toastSuccess(photoTitle
)} />} ? `"${photoTitle}" data synced`
onFormSubmitToastMessage={shouldToast : 'Data synced');
? photoTitle }
? `"${photoTitle}" data synced` })
: 'Data synced' .finally(() => setIsSyncing(false));
: undefined} }
onFormSubmit={onFormSubmit} }}
> isLoading={isSyncing || isSyncingExternal}
{label} disabled={disabled}
</SubmitButtonWithStatus> />
</FormWithConfirm>
); );
} }

View File

@ -1,65 +1,20 @@
import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
import { getPhotos } from '@/photo/db/query'; import { getPhotos } from '@/photo/db/query';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import { OUTDATED_THRESHOLD } from '@/photo'; import { OUTDATED_THRESHOLD } from '@/photo';
import LoaderButton from '@/components/primitives/LoaderButton'; import AdminOutdatedClient from '@/admin/AdminOutdatedClient';
import IconGrSync from '@/site/IconGrSync'; import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
import Banner from '@/components/Banner';
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
const UPDATE_BATCH_SIZE = 5; export default async function AdminOutdatedPage() {
export default async function AdminPhotosPage() {
const photos = await getPhotos({ const photos = await getPhotos({
hidden: 'include', hidden: 'include',
sortBy: 'createdAtAsc', sortBy: 'createdAtAsc',
takenBefore: OUTDATED_THRESHOLD, updatedBefore: OUTDATED_THRESHOLD,
limit: 1_000, limit: 1_000,
}).catch(() => []); }).catch(() => []);
return ( return (
<AdminChildPage <AdminOutdatedClient {...{
backLabel="Photos" photos,
backPath={PATH_ADMIN_PHOTOS} hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
breadcrumb={`Outdated (${photos.length})`} }} />
accessory={<LoaderButton
icon={<IconGrSync className="translate-y-[1px]" />}
hideTextOnMobile={false}
className="primary"
>
<span className="hidden sm:inline-block">
Sync Oldest {UPDATE_BATCH_SIZE} Photos
</span>
<span className="sm:hidden">
Sync Oldest
</span>
</LoaderButton>}
>
<div className="space-y-6">
<Banner>
<div className="space-y-1.5">
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.
</div>
</Banner>
<div className="space-y-4">
<AdminPhotosTable
photos={photos}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
canEdit={false}
canDelete={false}
showCreatedAt
/>
</div>
</div>
</AdminChildPage>
); );
} }

View File

@ -27,7 +27,7 @@ export default async function AdminPhotosPage() {
.catch(() => 0), .catch(() => 0),
getPhotosMetaCached({ getPhotosMetaCached({
hidden: 'include', hidden: 'include',
takenBefore: OUTDATED_THRESHOLD, updatedBefore: OUTDATED_THRESHOLD,
}) })
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),

View File

@ -6,11 +6,13 @@ export default function FormWithConfirm({
action, action,
confirmText, confirmText,
onSubmit, onSubmit,
className,
children, children,
}: { }: {
action: (formData: FormData) => void action: (formData: FormData) => void
confirmText?: string confirmText?: string
onSubmit?: () => void onSubmit?: () => void
className?: string
children: ReactNode children: ReactNode
}) { }) {
return ( return (
@ -24,6 +26,7 @@ export default function FormWithConfirm({
e.preventDefault(); e.preventDefault();
} }
}} }}
className={className}
> >
{children} {children}
</form> </form>

View File

@ -4,13 +4,18 @@ import { clsx } from 'clsx/lite';
export default function ResponsiveDate({ export default function ResponsiveDate({
date, date,
className, className,
titleLabel,
}: { }: {
date: Date date: Date
className?: string className?: string
titleLabel?: string
}) { }) {
const title = titleLabel
? `${titleLabel}: ${formatDate(date).toLocaleUpperCase()}`
: formatDate(date).toLocaleUpperCase();
return ( return (
<span <span
title={formatDate(date).toLocaleUpperCase()} title={title}
className={clsx(className, 'uppercase')} className={clsx(className, 'uppercase')}
> >
{/* Small */} {/* Small */}

View File

@ -6,13 +6,6 @@ import { clsx } from 'clsx/lite';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
interface Props extends ComponentProps<typeof LoaderButton> {
onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string
onFormSubmit?: () => void
primary?: boolean
}
export default function SubmitButtonWithStatus({ export default function SubmitButtonWithStatus({
icon, icon,
styleAs, styleAs,
@ -26,7 +19,12 @@ export default function SubmitButtonWithStatus({
primary, primary,
type: _type, type: _type,
...buttonProps ...buttonProps
}: Props) { }: {
onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string
onFormSubmit?: () => void
primary?: boolean
} & ComponentProps<typeof LoaderButton>) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
const pendingPrevious = useRef(pending); const pendingPrevious = useRef(pending);

View File

@ -9,19 +9,38 @@ export default function PhotoDate({
}: { }: {
photo: Photo photo: Photo
className?: string className?: string
dateType?: 'takenAt' | 'createdAt' dateType?: 'takenAt' | 'createdAt' | 'updatedAt'
}) { }) {
const date = useMemo(() => { const date = useMemo(() => {
const date = new Date(dateType === 'takenAt' const date = new Date(dateType === 'takenAt'
? photo.takenAt ? photo.takenAt
: photo.createdAt); : dateType === 'createdAt'
? photo.createdAt
: photo.updatedAt);
return isNaN(date.getTime()) ? new Date() : date; return isNaN(date.getTime()) ? new Date() : date;
}, [ }, [
dateType, dateType,
photo.createdAt,
photo.takenAt, photo.takenAt,
photo.createdAt,
photo.updatedAt,
]); ]);
const getTitleLabel = () => {
switch (dateType) {
case 'takenAt':
return 'TAKEN';
case 'createdAt':
return 'CREATED';
case 'updatedAt':
return 'UPDATED';
}
};
return ( return (
<ResponsiveDate {...{ date, className }} /> <ResponsiveDate {...{
date,
className,
titleLabel: getTitleLabel(),
}} />
); );
} }

View File

@ -14,7 +14,7 @@ import { getExifDataAction } from './actions';
import { TagsWithMeta } from '@/tag'; import { TagsWithMeta } from '@/tag';
import AiButton from './ai/AiButton'; import AiButton from './ai/AiButton';
import usePhotoFormParent from './form/usePhotoFormParent'; import usePhotoFormParent from './form/usePhotoFormParent';
import PhotoSyncButton from '@/admin/PhotoSyncButton'; import ExifSyncButton from '@/admin/ExifSyncButton';
export default function PhotoEditPageClient({ export default function PhotoEditPageClient({
photo, photo,
@ -68,10 +68,10 @@ export default function PhotoEditPageClient({
<div className="flex gap-2"> <div className="flex gap-2">
{hasAiTextGeneration && {hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />} <AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<PhotoSyncButton <ExifSyncButton
action={action} action={action}
label="EXIF" label="EXIF"
formData={{ photoUrl: photo.url }} photoUrl={photo.url}
/> />
</div>} </div>}
isLoading={pending} isLoading={pending}

View File

@ -43,7 +43,7 @@ export default function PhotoSmall({
'active:brightness-75', 'active:brightness-75',
selected && 'brightness-50', selected && 'brightness-50',
'min-w-[50px]', 'min-w-[50px]',
'rounded-[0.15rem] overflow-hidden', 'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800', 'border border-gray-200 dark:border-gray-800',
)} )}
prefetch={prefetch} prefetch={prefetch}

View File

@ -291,9 +291,8 @@ export const getExifDataAction = async (
// - strip GPS data if necessary // - strip GPS data if necessary
// - update blur data (or destroy if blur is disabled) // - update blur data (or destroy if blur is disabled)
// - generate AI text data, if enabled, and auto-generated fields are empty // - 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 () => { runAuthenticatedAdminServerAction(async () => {
const photoId = formData.get('photoId') as string | undefined;
const photo = await getPhoto(photoId ?? '', true); const photo = await getPhoto(photoId ?? '', true);
if (photo) { 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 () => export const clearCacheAction = async () =>
runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths); runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths);

View File

@ -62,7 +62,8 @@ const getPhotosCacheKeyForOption = (
return value ? `${option}-${createLensKey(value)}` : null; return value ? `${option}-${createLensKey(value)}` : null;
} }
case 'takenBefore': case 'takenBefore':
case 'takenAfterInclusive': { case 'takenAfterInclusive':
case 'updatedBefore': {
const value = options[option]; const value = options[option];
return value ? `${option}-${value.toISOString()}` : null; return value ? `${option}-${value.toISOString()}` : null;
} }

View File

@ -19,6 +19,7 @@ export type GetPhotosOptions = {
focal?: number focal?: number
takenBefore?: Date takenBefore?: Date
takenAfterInclusive?: Date takenAfterInclusive?: Date
updatedBefore?: Date
hidden?: 'exclude' | 'include' | 'only' hidden?: 'exclude' | 'include' | 'only'
}; };
@ -30,6 +31,7 @@ export const getWheresFromOptions = (
hidden = 'exclude', hidden = 'exclude',
takenBefore, takenBefore,
takenAfterInclusive, takenAfterInclusive,
updatedBefore,
query, query,
tag, tag,
camera, camera,
@ -59,6 +61,10 @@ export const getWheresFromOptions = (
wheres.push(`taken_at >= $${valuesIndex++}`); wheres.push(`taken_at >= $${valuesIndex++}`);
wheresValues.push(takenAfterInclusive.toISOString()); wheresValues.push(takenAfterInclusive.toISOString());
} }
if (updatedBefore) {
wheres.push(`updated_at < $${valuesIndex++}`);
wheresValues.push(updatedBefore.toISOString());
}
if (query) { if (query) {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`); wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);