diff --git a/src/admin/AdminAddAllUploads.tsx b/src/admin/AdminAddAllUploads.tsx index cc8f452a..78c16120 100644 --- a/src/admin/AdminAddAllUploads.tsx +++ b/src/admin/AdminAddAllUploads.tsx @@ -15,6 +15,7 @@ import { generateLocalNaivePostgresString, generateLocalPostgresString, } from '@/utility/date'; +import { readStreamableValue } from 'ai/rsc'; import { clsx } from 'clsx/lite'; import { useRouter } from 'next/navigation'; import { useRef, useState } from 'react'; @@ -23,13 +24,20 @@ import { BiImageAdd } from 'react-icons/bi'; export default function AdminAddAllUploads({ storageUrlCount, uniqueTags, + isAdding, + setIsAdding, + onUploadAdded, }: { storageUrlCount: number uniqueTags?: TagsWithMeta + isAdding: boolean + setIsAdding: (isAdding: boolean) => void + onUploadAdded?: (addedUploadUrls: string[]) => void }) { const divRef = useRef(null); - const [isLoading, setIsLoading] = useState(false); + const [buttonText, setButtonText] = useState('Add All Uploads'); + const [buttonSubheadText, setButtonSubheadText] = useState(''); const [showTags, setShowTags] = useState(false); const [tags, setTags] = useState(''); const [actionErrorMessage, setActionErrorMessage] = useState(''); @@ -65,12 +73,12 @@ export default function AdminAddAllUploads({ , 100); } }} - readOnly={isLoading} + readOnly={isAdding} />
-
+
} - onClick={() => { + onClick={async () => { if (confirm( `Are you sure you want to add all ${storageUrlCount} uploads?` )) { - setIsLoading(true); - addAllUploadsAction({ - tags: showTags ? tags : undefined, - takenAtLocal: generateLocalPostgresString(), - takenAtNaiveLocal: generateLocalNaivePostgresString(), - }) - .then(() => - router.push(PATH_ADMIN_PHOTOS)) - .catch(e => { - setIsLoading(false); - setActionErrorMessage(e.message); + setIsAdding(true); + try { + const stream = await addAllUploadsAction({ + tags: showTags ? tags : undefined, + takenAtLocal: generateLocalPostgresString(), + takenAtNaiveLocal: generateLocalNaivePostgresString(), }); + for await (const data of readStreamableValue(stream)) { + setButtonText(data?.headline ?? ''); + setButtonSubheadText(data?.subhead ?? ''); + onUploadAdded?.(data?.addedUploadUrls.split(',') ?? []); + } + router.push(PATH_ADMIN_PHOTOS); + } catch (e: any) { + setIsAdding(false); + setButtonText('Try Again'); + setActionErrorMessage(e); + } } }} hideTextOnMobile={false} > - Add all {storageUrlCount} uploads + {buttonText} + {buttonSubheadText && +
+ {buttonSubheadText} +
}
diff --git a/src/admin/AdminUploadsClient.tsx b/src/admin/AdminUploadsClient.tsx new file mode 100644 index 00000000..b8d509cc --- /dev/null +++ b/src/admin/AdminUploadsClient.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { StorageListResponse } from '@/services/storage'; +import AdminAddAllUploads from './AdminAddAllUploads'; +import AdminUploadsTable from './AdminUploadsTable'; +import { useState } from 'react'; +import { TagsWithMeta } from '@/tag'; + +export default function AdminUploadsClient({ + title, + urls, + uniqueTags, +}: { + title?: string + urls: StorageListResponse + uniqueTags?: TagsWithMeta +}) { + const [isAdding, setIsAdding] = useState(false); + const [addedUploadUrls, setAddedUploadUrls] = useState([]); + return ( +
+ {urls.length > 1 && + } + +
+ ); +} diff --git a/src/admin/AdminUploadsTable.tsx b/src/admin/AdminUploadsTable.tsx index cdba2570..bf6f2d1a 100644 --- a/src/admin/AdminUploadsTable.tsx +++ b/src/admin/AdminUploadsTable.tsx @@ -1,7 +1,11 @@ import { Fragment } from 'react'; import AdminTable from './AdminTable'; import Link from 'next/link'; -import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage'; +import { + StorageListResponse, + fileNameForStorageUrl, + getIdFromStorageUrl, +} from '@/services/storage'; import FormWithConfirm from '@/components/FormWithConfirm'; import { deleteBlobPhotoAction } from '@/photo/actions'; import DeleteButton from './DeleteButton'; @@ -10,19 +14,26 @@ import { pathForAdminUploadUrl } from '@/site/paths'; import AddButton from './AddButton'; import { formatDate } from 'date-fns'; import ImageSmall from '@/components/image/ImageSmall'; +import { FaRegCircleCheck } from 'react-icons/fa6'; +import Spinner from '@/components/Spinner'; export default function AdminUploadsTable({ title, urls, + addedUploadUrls, + isAdding, }: { title?: string urls: StorageListResponse + addedUploadUrls?: string[] + isAdding?: boolean }) { return ( {urls.map(({ url, uploadedAt }) => { const addUploadPath = pathForAdminUploadUrl(url); const uploadFileName = fileNameForStorageUrl(url); + const uploadId = getIdFromStorageUrl(url); return @@ -43,31 +54,44 @@ export default function AdminUploadsTable({ : url} prefetch={false} > - {uploadFileName} + {uploadId}
- - - - - - + {addedUploadUrls?.includes(url) || isAdding + ? + {addedUploadUrls?.includes(url) + ? + : } + + : <> + + + + + + + }
;})}
diff --git a/src/app/admin/uploads/page.tsx b/src/app/admin/uploads/page.tsx index e3adb096..531512e6 100644 --- a/src/app/admin/uploads/page.tsx +++ b/src/app/admin/uploads/page.tsx @@ -1,24 +1,20 @@ -import AdminUploadsTable from '@/admin/AdminUploadsTable'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import SiteGrid from '@/components/SiteGrid'; -import AdminAddAllUploads from '@/admin/AdminAddAllUploads'; import { getUniqueTagsCached } from '@/photo/cache'; +import AdminUploadsClient from '@/admin/AdminUploadsClient'; export const maxDuration = 60; export default async function AdminUploadsPage() { - const storageUrls = await getStorageUploadUrlsNoStore(); + const urls = await getStorageUploadUrlsNoStore(); const uniqueTags = await getUniqueTagsCached(); return ( - {storageUrls.length > 1 && - } - - } + contentMain={ + } /> ); } diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 887da0f1..82236f2d 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -48,6 +48,7 @@ import { } from '@/site/config'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { generateAiImageQueries } from './ai/server'; +import { createStreamableValue } from 'ai/rsc'; // Private actions @@ -76,50 +77,97 @@ export const addAllUploadsAction = async ({ }) => runAuthenticatedAdminServerAction(async () => { const uploadUrls = await getStorageUploadUrlsNoStore(); + const uploadTotal = uploadUrls.length; + const addedUploadUrls: string[] = []; - for (const { url } of uploadUrls) { - const { - photoFormExif, - imageResizedBase64, - } = await extractImageDataFromBlobPath(url, { - includeInitialPhotoFields: true, - generateBlurData: BLUR_ENABLED, - generateResizedImage: AI_TEXT_GENERATION_ENABLED, - }); + const stream = createStreamableValue<{ + headline: string, + subhead?: string, + addedUploadUrls: string, + }, string>({ + headline: `Adding ${uploadTotal} Photos...`, + addedUploadUrls: '', + }); - if (photoFormExif) { - const { - title, - caption, - tags: aiTags, - semanticDescription, - } = await generateAiImageQueries( - imageResizedBase64, - AI_TEXT_AUTO_GENERATED_FIELDS, - ); + (async () => { + try { + for (const [index, { url }] of uploadUrls.entries()) { + const headline = `Adding ${index + 1} of ${uploadTotal}`; - const form: Partial = { - ...photoFormExif, - title, - caption, - tags: tags || aiTags, - semanticDescription, - takenAt: photoFormExif.takenAt || takenAtLocal, - takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal, - }; + stream.update({ + headline, + subhead: 'Parsing EXIF data', + addedUploadUrls: addedUploadUrls.join(','), + }); - const updatedUrl = await convertUploadToPhoto(url); - if (updatedUrl) { - const photo = convertFormDataToPhotoDbInsert(form); - console.log(photo); - photo.url = updatedUrl; - await insertPhoto(photo); + const { + photoFormExif, + imageResizedBase64, + } = await extractImageDataFromBlobPath(url, { + includeInitialPhotoFields: true, + generateBlurData: BLUR_ENABLED, + generateResizedImage: AI_TEXT_GENERATION_ENABLED, + }); + + if (photoFormExif) { + if (AI_TEXT_GENERATION_ENABLED) { + stream.update({ + headline, + subhead: 'Generating AI text', + addedUploadUrls: addedUploadUrls.join(','), + }); + } + + const { + title, + caption, + tags: aiTags, + semanticDescription, + } = await generateAiImageQueries( + imageResizedBase64, + AI_TEXT_AUTO_GENERATED_FIELDS, + ); + + const form: Partial = { + ...photoFormExif, + title, + caption, + tags: tags || aiTags, + semanticDescription, + takenAt: photoFormExif.takenAt || takenAtLocal, + takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal, + }; + + stream.update({ + headline, + subhead: 'Moving upload to photo storage', + addedUploadUrls: addedUploadUrls.join(','), + }); + + const updatedUrl = await convertUploadToPhoto(url); + if (updatedUrl) { + stream.update({ + headline, + subhead: 'Adding to database', + addedUploadUrls: addedUploadUrls.join(','), + }); + const photo = convertFormDataToPhotoDbInsert(form); + photo.url = updatedUrl; + await insertPhoto(photo); + addedUploadUrls.push(url); + } + } } + } catch (error: any) { + // eslint-disable-next-line max-len + stream.error(`${error.message} (${addedUploadUrls.length} of ${uploadTotal} photos successfully added)`); + } finally { + revalidateAllKeysAndPaths(); } - } + stream.done(); + })(); - revalidateAllKeysAndPaths(); - redirect(PATH_ADMIN_PHOTOS); + return stream.value; }); export const updatePhotoAction = async (formData: FormData) =>