Post upload status from server action
This commit is contained in:
parent
ed5e041c77
commit
1fca04320c
@ -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>
|
||||
|
||||
33
src/admin/AdminUploadsClient.tsx
Normal file
33
src/admin/AdminUploadsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user