diff --git a/src/admin/AdminAddAllUploads.tsx b/src/admin/AdminAddAllUploads.tsx index ffe69082..bb5a3003 100644 --- a/src/admin/AdminAddAllUploads.tsx +++ b/src/admin/AdminAddAllUploads.tsx @@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation'; import { Dispatch, SetStateAction, useRef, useState } from 'react'; import { BiCheckCircle, BiImageAdd } from 'react-icons/bi'; import ProgressButton from '@/components/primitives/ProgressButton'; +import { AddedUrlStatus } from './AdminUploadsClient'; const UPLOAD_BATCH_SIZE = 4; @@ -29,18 +30,17 @@ export default function AdminAddAllUploads({ uniqueTags, isAdding, setIsAdding, - setAddedUploadUrls, + setAddedUrlStatuses, }: { storageUrls: string[] uniqueTags?: Tags isAdding: boolean setIsAdding: (isAdding: boolean) => void - setAddedUploadUrls?: Dispatch> + setAddedUrlStatuses: Dispatch> }) { const divRef = useRef(null); const [buttonText, setButtonText] = useState('Add All Uploads'); - const [buttonSubheadText, setButtonSubheadText] = useState(''); const [showTags, setShowTags] = useState(false); const [tags, setTags] = useState(''); const [actionErrorMessage, setActionErrorMessage] = useState(''); @@ -50,7 +50,7 @@ export default function AdminAddAllUploads({ const router = useRouter(); - const addedUploadUrls = useRef([]); + const addedUploadCount = useRef(0); const addUploadUrls = async (uploadUrls: string[]) => { try { const stream = await addAllUploadsAction({ @@ -60,23 +60,31 @@ export default function AdminAddAllUploads({ takenAtNaiveLocal: generateLocalNaivePostgresString(), }); for await (const data of readStreamableValue(stream)) { - setButtonText(addedUploadUrls.current.length === 0 - ? `Adding ${storageUrls.length} uploads` - : `Adding ${addedUploadUrls.current.length} of ${storageUrls.length}` + setButtonText(addedUploadCount.current === 0 + ? `Adding 1 of ${storageUrls.length}` + : `Adding ${addedUploadCount.current + 1} of ${storageUrls.length}` ); - setButtonSubheadText(data?.subhead ?? ''); - setAddedUploadUrls?.(current => { - const urls = data?.addedUploadUrls.split(',') ?? []; - const updatedUrls = current - .filter(url => !urls.includes(url)) - .concat(urls); - addedUploadUrls.current = updatedUrls; - return updatedUrls; + setAddedUrlStatuses(current => { + const update = current.map(status => + status.url === data?.url + ? { + ...status, + // Prevent status regressions + status: status.status !== 'added' ? data.status : 'added', + statusMessage: data.statusMessage, + progress: data.progress, + } + : status + ); + addedUploadCount.current = update + .filter(({ status }) => status === 'added') + .length; + return update; }); setAddingProgress((current = 0) => { const updatedProgress = ( ( - ((addedUploadUrls.current.length || 1) - 1) + + ((addedUploadCount.current || 1) - 1) + (data?.progress ?? 0) ) / storageUrls.length @@ -158,6 +166,9 @@ export default function AdminAddAllUploads({ // eslint-disable-next-line max-len if (confirm(`Are you sure you want to add all ${storageUrls.length} uploads?`)) { setIsAdding(true); + setAddedUrlStatuses(current => current.map((url, index) => + index === 0 ? { ...url, status: 'adding' } : url + )); const uploadsToAdd = storageUrls.slice(); try { while (uploadsToAdd.length > 0) { @@ -166,7 +177,6 @@ export default function AdminAddAllUploads({ ); } setButtonText('Complete'); - setButtonSubheadText('All uploads added'); setAddingProgress(1); setIsAdding(false); setIsAddingComplete(true); @@ -184,10 +194,6 @@ export default function AdminAddAllUploads({ > {buttonText} - {buttonSubheadText && -
- {buttonSubheadText} -
} diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index ac63fd2e..9ef063d9 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -3,7 +3,6 @@ import PhotoUpload from '@/photo/PhotoUpload'; import { clsx } from 'clsx/lite'; import SiteGrid from '@/components/SiteGrid'; -import AdminUploadsTable from '@/admin/AdminUploadsTable'; import { AI_TEXT_GENERATION_ENABLED, PRO_MODE_ENABLED } from '@/site/config'; import AdminPhotosTable from '@/admin/AdminPhotosTable'; import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite'; @@ -13,6 +12,7 @@ import { Photo } from '@/photo'; import { StorageListResponse } from '@/services/storage'; import { useState } from 'react'; import { LiaBroomSolid } from 'react-icons/lia'; +import AdminUploadsTable from './AdminUploadsTable'; export default function AdminPhotosClient({ photos, @@ -58,15 +58,16 @@ export default function AdminPhotosClient({ {photosCountOutdated} } - {!isUploading && blobPhotoUrls.length > 0 && + {blobPhotoUrls.length > 0 &&
- +
+ Photo Blobs ({blobPhotoUrls.length}) +
+
} {/* Use custom spacing to address gap/space-y compatibility quirks */}
diff --git a/src/admin/AdminUploadsClient.tsx b/src/admin/AdminUploadsClient.tsx index f93b2cac..484b84f8 100644 --- a/src/admin/AdminUploadsClient.tsx +++ b/src/admin/AdminUploadsClient.tsx @@ -2,21 +2,33 @@ import { StorageListResponse } from '@/services/storage'; import AdminAddAllUploads from './AdminAddAllUploads'; -import AdminUploadsTable from './AdminUploadsTable'; import { useState } from 'react'; import { Tags } from '@/tag'; +import AdminUploadsTable from './AdminUploadsTable'; + +export type AddedUrlStatus = { + url: string + uploadedAt?: Date + status?: 'waiting' | 'adding' | 'added' + statusMessage?: string + progress?: number +}; export default function AdminUploadsClient({ - title, urls, uniqueTags, }: { - title?: string urls: StorageListResponse uniqueTags?: Tags }) { const [isAdding, setIsAdding] = useState(false); - const [addedUploadUrls, setAddedUploadUrls] = useState([]); + const [addedUrlStatuses, setAddedUrlStatuses] = + useState(urls.map(({ url, uploadedAt }) => ({ + url, + uploadedAt, + status: 'waiting', + }))); + return (
{urls.length > 1 && @@ -25,9 +37,9 @@ export default function AdminUploadsClient({ uniqueTags={uniqueTags} isAdding={isAdding} setIsAdding={setIsAdding} - setAddedUploadUrls={setAddedUploadUrls} + setAddedUrlStatuses={setAddedUrlStatuses} />} - +
); } diff --git a/src/admin/AdminUploadsTable.tsx b/src/admin/AdminUploadsTable.tsx index bf6f2d1a..ec634ca8 100644 --- a/src/admin/AdminUploadsTable.tsx +++ b/src/admin/AdminUploadsTable.tsx @@ -1,99 +1,109 @@ -import { Fragment } from 'react'; -import AdminTable from './AdminTable'; -import Link from 'next/link'; -import { - StorageListResponse, - fileNameForStorageUrl, - getIdFromStorageUrl, -} from '@/services/storage'; +'use client'; + +import ImageSmall from '@/components/image/ImageSmall'; +import Spinner from '@/components/Spinner'; +import { getIdFromStorageUrl } from '@/services/storage'; +import { clsx } from 'clsx/lite'; +import { motion } from 'framer-motion'; +import { FaRegCircleCheck } from 'react-icons/fa6'; +import { pathForAdminUploadUrl } from '@/site/paths'; +import AddButton from './AddButton'; import FormWithConfirm from '@/components/FormWithConfirm'; import { deleteBlobPhotoAction } from '@/photo/actions'; import DeleteButton from './DeleteButton'; -import { clsx } from 'clsx/lite'; -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'; +import { formatDate } from '@/utility/date'; +import { AddedUrlStatus } from './AdminUploadsClient'; export default function AdminUploadsTable({ - title, - urls, - addedUploadUrls, isAdding, + urls, }: { - title?: string - urls: StorageListResponse - addedUploadUrls?: string[] isAdding?: boolean + urls: AddedUrlStatus[] }) { + const isComplete = urls.every(({ status }) => status === 'added'); + return ( - - {urls.map(({ url, uploadedAt }) => { +
+ {urls.map(({ url, status, statusMessage, uploadedAt }) => { const addUploadPath = pathForAdminUploadUrl(url); - const uploadFileName = fileNameForStorageUrl(url); - const uploadId = getIdFromStorageUrl(url); - return - - - - - {uploadId} - + return
- {addedUploadUrls?.includes(url) || isAdding - ? - {addedUploadUrls?.includes(url) - ? - : } + + + +
{getIdFromStorageUrl(url)}
+
+ {isAdding || isComplete + ? status === 'added' + ? 'Complete' + : status === 'adding' + ? statusMessage ?? 'Adding ...' + : 'Waiting' + : uploadedAt + ? + {formatDate(uploadedAt, 'medium')} + + : '—'} +
- : <> - - - - - - - } +
+ + {isAdding || isComplete + ? <> + {status === 'added' + ? + : status === 'adding' + ? + : + — + } + + : <> + + + + + + + } +
- ;})} - +
; + })} +
); -} +} \ No newline at end of file diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 71abf6cb..663cd916 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -46,6 +46,7 @@ import { import { generateAiImageQueries } from './ai/server'; import { createStreamableValue } from 'ai/rsc'; import { convertUploadToPhoto } from './storage'; +import { AddedUrlStatus } from '@/admin/AdminUploadsClient'; // Private actions @@ -84,24 +85,26 @@ export const addAllUploadsAction = async ({ const PROGRESS_TASK_COUNT = AI_TEXT_GENERATION_ENABLED ? 5 : 4; const addedUploadUrls: string[] = []; + let currentUploadUrl = ''; let progress = 0; - const stream = createStreamableValue<{ - subhead: string - addedUploadUrls: string - progress: number - }>(); + const stream = createStreamableValue(); - const streamUpdate = (subhead: string) => + const streamUpdate = ( + statusMessage: string, + status: AddedUrlStatus['status'] = 'adding', + ) => stream.update({ - subhead, - addedUploadUrls: addedUploadUrls.join(','), + url: currentUploadUrl, + status, + statusMessage, progress: ++progress / PROGRESS_TASK_COUNT, }); (async () => { try { for (const url of uploadUrls) { + currentUploadUrl = url; progress = 0; streamUpdate('Parsing EXIF data'); @@ -156,7 +159,7 @@ export const addAllUploadsAction = async ({ await insertPhoto(photo); addedUploadUrls.push(url); // Re-submit with updated url - streamUpdate(subheadFinal); + streamUpdate(subheadFinal, 'added'); } } }; diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index 9e7e2fa1..63884a68 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -72,6 +72,7 @@ export const awsS3List = async ( })) .then((data) => data.Contents?.map(({ Key, LastModified }) => ({ url: urlForKey(Key), + fileName: Key ?? '', uploadedAt: LastModified, })) ?? []); diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 12a45572..8f3f80eb 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -91,6 +91,7 @@ export const cloudflareR2List = async ( })) .then((data) => data.Contents?.map(({ Key, LastModified }) => ({ url: urlForKey(Key), + fileName: Key ?? '', uploadedAt: LastModified, })) ?? []); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 7fb3123f..e773b3b9 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -35,6 +35,7 @@ export const generateStorageId = () => generateNanoid(16); export type StorageListResponse = { url: string + fileName: string uploadedAt?: Date }[]; diff --git a/src/services/storage/vercel-blob.ts b/src/services/storage/vercel-blob.ts index 60cfdd22..1955488b 100644 --- a/src/services/storage/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -1,6 +1,7 @@ import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/site/paths'; import { copy, del, list, put } from '@vercel/blob'; import { upload } from '@vercel/blob/client'; +import { fileNameForStorageUrl } from '.'; const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, @@ -58,5 +59,6 @@ export const vercelBlobDelete = (fileName: string) => del(fileName); export const vercelBlobList = (prefix: string) => list({ prefix }) .then(({ blobs }) => blobs.map(({ url, uploadedAt }) => ({ url, + fileName: fileNameForStorageUrl(url), uploadedAt, })));