Update photo install visualization

This commit is contained in:
Sam Becker 2024-07-06 12:42:26 -05:00
parent 448281ac59
commit 5f028a3b07
9 changed files with 162 additions and 125 deletions

View File

@ -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<SetStateAction<string[]>>
setAddedUrlStatuses: Dispatch<SetStateAction<AddedUrlStatus[]>>
}) {
const divRef = useRef<HTMLDivElement>(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<string[]>([]);
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}
</ProgressButton>
{buttonSubheadText &&
<div className="text-dim text-sm text-center">
{buttonSubheadText}
</div>}
</div>
</div>
</Container>

View File

@ -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}
</PathLoaderButton>}
</div>
{!isUploading && blobPhotoUrls.length > 0 &&
{blobPhotoUrls.length > 0 &&
<div className={clsx(
'border-b pb-6',
'border-gray-200 dark:border-gray-700',
'space-y-4',
)}>
<AdminUploadsTable
title={`Photo Blobs (${blobPhotoUrls.length})`}
urls={blobPhotoUrls}
/>
<div className="font-bold">
Photo Blobs ({blobPhotoUrls.length})
</div>
<AdminUploadsTable urls={blobPhotoUrls} />
</div>}
{/* Use custom spacing to address gap/space-y compatibility quirks */}
<div className="space-y-[6px] sm:space-y-[10px]">

View File

@ -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<string[]>([]);
const [addedUrlStatuses, setAddedUrlStatuses] =
useState<AddedUrlStatus[]>(urls.map(({ url, uploadedAt }) => ({
url,
uploadedAt,
status: 'waiting',
})));
return (
<div className="space-y-4">
{urls.length > 1 &&
@ -25,9 +37,9 @@ export default function AdminUploadsClient({
uniqueTags={uniqueTags}
isAdding={isAdding}
setIsAdding={setIsAdding}
setAddedUploadUrls={setAddedUploadUrls}
setAddedUrlStatuses={setAddedUrlStatuses}
/>}
<AdminUploadsTable {...{ title, urls, isAdding, addedUploadUrls }} />
<AdminUploadsTable {...{ isAdding, urls: addedUrlStatuses }} />
</div>
);
}

View File

@ -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 (
<AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => {
<div className="space-y-4">
{urls.map(({ url, status, statusMessage, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url);
const uploadId = getIdFromStorageUrl(url);
return <Fragment key={url}>
<Link href={addUploadPath} prefetch={false}>
<ImageSmall
alt={`Upload: ${uploadFileName}`}
src={url}
aspectRatio={3.0 / 2.0}
className={clsx(
'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
/>
</Link>
<Link
href={addUploadPath}
className="break-all"
title={uploadedAt
? `${url} @ ${formatDate(uploadedAt, 'yyyy-MM-dd HH:mm:ss')}`
: url}
prefetch={false}
>
{uploadId}
</Link>
return <div key={url}>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
'flex items-center gap-2 w-full min-h-8',
)}>
{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]"
/>}
<motion.div
className="flex items-center grow gap-2"
animate={isAdding
? {
opacity: status === 'adding' || isComplete ? 1 : 0.5,
translateX: isComplete
? 0
: status === 'adding' || isComplete ? -4 : 4,
}
: { opacity: 1, translateX: 0 }}
>
<ImageSmall
src={url}
alt={url}
aspectRatio={3.0 / 2.0}
className="rounded-sm overflow-hidden"
/>
<span className="grow">
<div>{getIdFromStorageUrl(url)}</div>
<div className="text-dim">
{isAdding || isComplete
? status === 'added'
? 'Complete'
: status === 'adding'
? statusMessage ?? 'Adding ...'
: 'Waiting'
: uploadedAt
? <span className="uppercase">
{formatDate(uploadedAt, 'medium')}
</span>
: '—'}
</div>
</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>
</>}
</motion.div>
<span className="flex items-center gap-2">
{isAdding || isComplete
? <>
{status === 'added'
? <FaRegCircleCheck size={18} />
: status === 'adding'
? <Spinner
size={19}
className="translate-y-[2px]"
/>
: <span className="pr-1.5 text-dim">
</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>
</>}
</span>
</div>
</Fragment>;})}
</AdminTable>
</div>;
})}
</div>
);
}
}

View File

@ -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<AddedUrlStatus>();
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');
}
}
};

View File

@ -72,6 +72,7 @@ export const awsS3List = async (
}))
.then((data) => data.Contents?.map(({ Key, LastModified }) => ({
url: urlForKey(Key),
fileName: Key ?? '',
uploadedAt: LastModified,
})) ?? []);

View File

@ -91,6 +91,7 @@ export const cloudflareR2List = async (
}))
.then((data) => data.Contents?.map(({ Key, LastModified }) => ({
url: urlForKey(Key),
fileName: Key ?? '',
uploadedAt: LastModified,
})) ?? []);

View File

@ -35,6 +35,7 @@ export const generateStorageId = () => generateNanoid(16);
export type StorageListResponse = {
url: string
fileName: string
uploadedAt?: Date
}[];

View File

@ -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,
})));