Post upload status from server action
This commit is contained in:
parent
ed5e041c77
commit
1fca04320c
@ -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>
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
@ -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>}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user