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,
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<HTMLDivElement>(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}
/>
</div>
<div
ref={divRef}
className={showTags ? undefined : 'hidden'}
className={showTags && !actionErrorMessage ? undefined : 'hidden'}
>
<FieldSetWithStatus
id="tags"
@ -81,40 +89,50 @@ export default function AdminAddAllUploads({
setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}}
readOnly={isLoading}
readOnly={isAdding}
error={tagErrorMessage}
required={false}
hideLabel
/>
</div>
<div>
<div className="space-y-2">
<LoaderButton
className="primary w-full justify-center"
isLoading={isLoading}
isLoading={isAdding}
disabled={Boolean(tagErrorMessage)}
icon={<BiImageAdd size={18} className="translate-x-[1px]" />}
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}
</LoaderButton>
{buttonSubheadText &&
<div className="text-dim text-sm text-center">
{buttonSubheadText}
</div>}
</div>
</div>
</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 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 (
<AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url);
const uploadId = getIdFromStorageUrl(url);
return <Fragment key={url}>
<Link href={addUploadPath} prefetch={false}>
<ImageSmall
@ -30,7 +41,7 @@ export default function AdminUploadsTable({
src={url}
aspectRatio={3.0 / 2.0}
className={clsx(
'rounded-sm overflow-hidden',
'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
/>
@ -43,31 +54,44 @@ export default function AdminUploadsTable({
: url}
prefetch={false}
>
{uploadFileName}
{uploadId}
</Link>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<AddButton path={addUploadPath} />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"
>
<input
type="hidden"
name="redirectToPhotos"
value={urls.length < 2 ? 'true' : 'false'}
readOnly
/>
<input
type="hidden"
name="url"
value={url}
readOnly
/>
<DeleteButton />
</FormWithConfirm>
{addedUploadUrls?.includes(url) || isAdding
? <span className={clsx(
'h-9 flex items-center justify-end w-full pr-3',
)}>
{addedUploadUrls?.includes(url)
? <FaRegCircleCheck size={18} />
: <Spinner
size={19}
className="translate-y-[2px]"
/>}
</span>
: <>
<AddButton path={addUploadPath} />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"
>
<input
type="hidden"
name="redirectToPhotos"
value={urls.length < 2 ? 'true' : 'false'}
readOnly
/>
<input
type="hidden"
name="url"
value={url}
readOnly
/>
<DeleteButton />
</FormWithConfirm>
</>}
</div>
</Fragment>;})}
</AdminTable>

View File

@ -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 (
<SiteGrid
contentMain={<div className="space-y-4">
{storageUrls.length > 1 &&
<AdminAddAllUploads
storageUrlCount={storageUrls.length}
uniqueTags={uniqueTags}
/>}
<AdminUploadsTable urls={storageUrls} />
</div>}
contentMain={
<AdminUploadsClient {...{
urls,
uniqueTags,
}} />}
/>
);
}

View File

@ -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<PhotoFormData> = {
...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<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();
redirect(PATH_ADMIN_PHOTOS);
return stream.value;
});
export const updatePhotoAction = async (formData: FormData) =>