Post upload status from server action

This commit is contained in:
Sam Becker 2024-05-27 22:29:45 -05:00
parent ed5e041c77
commit 1fca04320c
5 changed files with 208 additions and 89 deletions

View File

@ -15,6 +15,7 @@ import {
generateLocalNaivePostgresString, generateLocalNaivePostgresString,
generateLocalPostgresString, generateLocalPostgresString,
} from '@/utility/date'; } from '@/utility/date';
import { readStreamableValue } from 'ai/rsc';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@ -23,13 +24,20 @@ import { BiImageAdd } from 'react-icons/bi';
export default function AdminAddAllUploads({ export default function AdminAddAllUploads({
storageUrlCount, storageUrlCount,
uniqueTags, uniqueTags,
isAdding,
setIsAdding,
onUploadAdded,
}: { }: {
storageUrlCount: number storageUrlCount: number
uniqueTags?: TagsWithMeta uniqueTags?: TagsWithMeta
isAdding: boolean
setIsAdding: (isAdding: boolean) => void
onUploadAdded?: (addedUploadUrls: string[]) => void
}) { }) {
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false); const [buttonText, setButtonText] = useState('Add All Uploads');
const [buttonSubheadText, setButtonSubheadText] = useState('');
const [showTags, setShowTags] = useState(false); const [showTags, setShowTags] = useState(false);
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [actionErrorMessage, setActionErrorMessage] = useState(''); const [actionErrorMessage, setActionErrorMessage] = useState('');
@ -65,12 +73,12 @@ export default function AdminAddAllUploads({
, 100); , 100);
} }
}} }}
readOnly={isLoading} readOnly={isAdding}
/> />
</div> </div>
<div <div
ref={divRef} ref={divRef}
className={showTags ? undefined : 'hidden'} className={showTags && !actionErrorMessage ? undefined : 'hidden'}
> >
<FieldSetWithStatus <FieldSetWithStatus
id="tags" id="tags"
@ -81,40 +89,50 @@ export default function AdminAddAllUploads({
setTags(tags); setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? ''); setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}} }}
readOnly={isLoading} readOnly={isAdding}
error={tagErrorMessage} error={tagErrorMessage}
required={false} required={false}
hideLabel hideLabel
/> />
</div> </div>
<div> <div className="space-y-2">
<LoaderButton <LoaderButton
className="primary w-full justify-center" className="primary w-full justify-center"
isLoading={isLoading} isLoading={isAdding}
disabled={Boolean(tagErrorMessage)} disabled={Boolean(tagErrorMessage)}
icon={<BiImageAdd size={18} className="translate-x-[1px]" />} icon={<BiImageAdd size={18} className="translate-x-[1px]" />}
onClick={() => { onClick={async () => {
if (confirm( if (confirm(
`Are you sure you want to add all ${storageUrlCount} uploads?` `Are you sure you want to add all ${storageUrlCount} uploads?`
)) { )) {
setIsLoading(true); setIsAdding(true);
addAllUploadsAction({ try {
tags: showTags ? tags : undefined, const stream = await addAllUploadsAction({
takenAtLocal: generateLocalPostgresString(), tags: showTags ? tags : undefined,
takenAtNaiveLocal: generateLocalNaivePostgresString(), takenAtLocal: generateLocalPostgresString(),
}) takenAtNaiveLocal: generateLocalNaivePostgresString(),
.then(() =>
router.push(PATH_ADMIN_PHOTOS))
.catch(e => {
setIsLoading(false);
setActionErrorMessage(e.message);
}); });
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} hideTextOnMobile={false}
> >
Add all {storageUrlCount} uploads {buttonText}
</LoaderButton> </LoaderButton>
{buttonSubheadText &&
<div className="text-dim text-sm text-center">
{buttonSubheadText}
</div>}
</div> </div>
</div> </div>
</InfoBlock> </InfoBlock>

View File

@ -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<string[]>([]);
return (
<div className="space-y-4">
{urls.length > 1 &&
<AdminAddAllUploads
storageUrlCount={urls.length}
uniqueTags={uniqueTags}
isAdding={isAdding}
setIsAdding={setIsAdding}
onUploadAdded={setAddedUploadUrls}
/>}
<AdminUploadsTable {...{ title, urls, isAdding, addedUploadUrls }} />
</div>
);
}

View File

@ -1,7 +1,11 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import AdminTable from './AdminTable'; import AdminTable from './AdminTable';
import Link from 'next/link'; import Link from 'next/link';
import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage'; import {
StorageListResponse,
fileNameForStorageUrl,
getIdFromStorageUrl,
} from '@/services/storage';
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import { deleteBlobPhotoAction } from '@/photo/actions'; import { deleteBlobPhotoAction } from '@/photo/actions';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
@ -10,19 +14,26 @@ import { pathForAdminUploadUrl } from '@/site/paths';
import AddButton from './AddButton'; import AddButton from './AddButton';
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import ImageSmall from '@/components/image/ImageSmall'; import ImageSmall from '@/components/image/ImageSmall';
import { FaRegCircleCheck } from 'react-icons/fa6';
import Spinner from '@/components/Spinner';
export default function AdminUploadsTable({ export default function AdminUploadsTable({
title, title,
urls, urls,
addedUploadUrls,
isAdding,
}: { }: {
title?: string title?: string
urls: StorageListResponse urls: StorageListResponse
addedUploadUrls?: string[]
isAdding?: boolean
}) { }) {
return ( return (
<AdminTable {...{ title }} > <AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => { {urls.map(({ url, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url); const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url); const uploadFileName = fileNameForStorageUrl(url);
const uploadId = getIdFromStorageUrl(url);
return <Fragment key={url}> return <Fragment key={url}>
<Link href={addUploadPath} prefetch={false}> <Link href={addUploadPath} prefetch={false}>
<ImageSmall <ImageSmall
@ -30,7 +41,7 @@ export default function AdminUploadsTable({
src={url} src={url}
aspectRatio={3.0 / 2.0} aspectRatio={3.0 / 2.0}
className={clsx( className={clsx(
'rounded-sm overflow-hidden', 'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800', 'border border-gray-200 dark:border-gray-800',
)} )}
/> />
@ -43,31 +54,44 @@ export default function AdminUploadsTable({
: url} : url}
prefetch={false} prefetch={false}
> >
{uploadFileName} {uploadId}
</Link> </Link>
<div className={clsx( <div className={clsx(
'flex flex-nowrap', 'flex flex-nowrap',
'gap-2 sm:gap-3 items-center', 'gap-2 sm:gap-3 items-center',
)}> )}>
<AddButton path={addUploadPath} /> {addedUploadUrls?.includes(url) || isAdding
<FormWithConfirm ? <span className={clsx(
action={deleteBlobPhotoAction} 'h-9 flex items-center justify-end w-full pr-3',
confirmText="Are you sure you want to delete this upload?" )}>
> {addedUploadUrls?.includes(url)
<input ? <FaRegCircleCheck size={18} />
type="hidden" : <Spinner
name="redirectToPhotos" size={19}
value={urls.length < 2 ? 'true' : 'false'} className="translate-y-[2px]"
readOnly />}
/> </span>
<input : <>
type="hidden" <AddButton path={addUploadPath} />
name="url" <FormWithConfirm
value={url} action={deleteBlobPhotoAction}
readOnly confirmText="Are you sure you want to delete this upload?"
/> >
<DeleteButton /> <input
</FormWithConfirm> type="hidden"
name="redirectToPhotos"
value={urls.length < 2 ? 'true' : 'false'}
readOnly
/>
<input
type="hidden"
name="url"
value={url}
readOnly
/>
<DeleteButton />
</FormWithConfirm>
</>}
</div> </div>
</Fragment>;})} </Fragment>;})}
</AdminTable> </AdminTable>

View File

@ -1,24 +1,20 @@
import AdminUploadsTable from '@/admin/AdminUploadsTable';
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import AdminAddAllUploads from '@/admin/AdminAddAllUploads';
import { getUniqueTagsCached } from '@/photo/cache'; import { getUniqueTagsCached } from '@/photo/cache';
import AdminUploadsClient from '@/admin/AdminUploadsClient';
export const maxDuration = 60; export const maxDuration = 60;
export default async function AdminUploadsPage() { export default async function AdminUploadsPage() {
const storageUrls = await getStorageUploadUrlsNoStore(); const urls = await getStorageUploadUrlsNoStore();
const uniqueTags = await getUniqueTagsCached(); const uniqueTags = await getUniqueTagsCached();
return ( return (
<SiteGrid <SiteGrid
contentMain={<div className="space-y-4"> contentMain={
{storageUrls.length > 1 && <AdminUploadsClient {...{
<AdminAddAllUploads urls,
storageUrlCount={storageUrls.length} uniqueTags,
uniqueTags={uniqueTags} }} />}
/>}
<AdminUploadsTable urls={storageUrls} />
</div>}
/> />
); );
} }

View File

@ -48,6 +48,7 @@ import {
} from '@/site/config'; } from '@/site/config';
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import { generateAiImageQueries } from './ai/server'; import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc';
// Private actions // Private actions
@ -76,50 +77,97 @@ export const addAllUploadsAction = async ({
}) => }) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const uploadUrls = await getStorageUploadUrlsNoStore(); const uploadUrls = await getStorageUploadUrlsNoStore();
const uploadTotal = uploadUrls.length;
const addedUploadUrls: string[] = [];
for (const { url } of uploadUrls) { const stream = createStreamableValue<{
const { headline: string,
photoFormExif, subhead?: string,
imageResizedBase64, addedUploadUrls: string,
} = await extractImageDataFromBlobPath(url, { }, string>({
includeInitialPhotoFields: true, headline: `Adding ${uploadTotal} Photos...`,
generateBlurData: BLUR_ENABLED, addedUploadUrls: '',
generateResizedImage: AI_TEXT_GENERATION_ENABLED, });
});
if (photoFormExif) { (async () => {
const { try {
title, for (const [index, { url }] of uploadUrls.entries()) {
caption, const headline = `Adding ${index + 1} of ${uploadTotal}`;
tags: aiTags,
semanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
AI_TEXT_AUTO_GENERATED_FIELDS,
);
const form: Partial<PhotoFormData> = { stream.update({
...photoFormExif, headline,
title, subhead: 'Parsing EXIF data',
caption, addedUploadUrls: addedUploadUrls.join(','),
tags: tags || aiTags, });
semanticDescription,
takenAt: photoFormExif.takenAt || takenAtLocal,
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
};
const updatedUrl = await convertUploadToPhoto(url); const {
if (updatedUrl) { photoFormExif,
const photo = convertFormDataToPhotoDbInsert(form); imageResizedBase64,
console.log(photo); } = await extractImageDataFromBlobPath(url, {
photo.url = updatedUrl; includeInitialPhotoFields: true,
await insertPhoto(photo); 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<PhotoFormData> = {
...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(); return stream.value;
redirect(PATH_ADMIN_PHOTOS);
}); });
export const updatePhotoAction = async (formData: FormData) => export const updatePhotoAction = async (formData: FormData) =>