Merge pull request #123 from sambecker/add-status-update

Improve photo installation visualization
This commit is contained in:
Sam Becker 2024-07-06 21:07:01 -07:00 committed by GitHub
commit dc3f5b2f9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 202 additions and 135 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 { UrlAddStatus } from './AdminUploadsClient';
const UPLOAD_BATCH_SIZE = 4;
@ -29,18 +30,17 @@ export default function AdminAddAllUploads({
uniqueTags,
isAdding,
setIsAdding,
setAddedUploadUrls,
setUrlAddStatuses,
}: {
storageUrls: string[]
uniqueTags?: Tags
isAdding: boolean
setIsAdding: (isAdding: boolean) => void
setAddedUploadUrls?: Dispatch<SetStateAction<string[]>>
setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>>
}) {
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;
setUrlAddStatuses(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,10 @@ 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);
setUrlAddStatuses(current => current.map((url, index) => ({
...url,
status: index === 0 ? 'adding' : 'waiting',
})));
const uploadsToAdd = storageUrls.slice();
try {
while (uploadsToAdd.length > 0) {
@ -166,7 +178,6 @@ export default function AdminAddAllUploads({
);
}
setButtonText('Complete');
setButtonSubheadText('All uploads added');
setAddingProgress(1);
setIsAdding(false);
setIsAddingComplete(true);
@ -184,10 +195,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 urlAddStatuses={blobPhotoUrls} />
</div>}
{/* Use custom spacing to address gap/space-y compatibility quirks */}
<div className="space-y-[6px] sm:space-y-[10px]">

View File

@ -2,32 +2,41 @@
import { StorageListResponse } from '@/services/storage';
import AdminAddAllUploads from './AdminAddAllUploads';
import AdminUploadsTable from './AdminUploadsTable';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Tags } from '@/tag';
import AdminUploadsTable from './AdminUploadsTable';
export type UrlAddStatus = {
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 [urlAddStatuses, setUrlAddStatuses] = useState<UrlAddStatus[]>(urls);
const storageUrls = useMemo(() => urls.map(({ url }) => url), [urls]);
return (
<div className="space-y-4">
{urls.length > 1 &&
<AdminAddAllUploads
storageUrls={urls.map(({ url }) => url)}
uniqueTags={uniqueTags}
isAdding={isAdding}
setIsAdding={setIsAdding}
setAddedUploadUrls={setAddedUploadUrls}
/>}
<AdminUploadsTable {...{ title, urls, isAdding, addedUploadUrls }} />
{(urls.length > 1 || isAdding) &&
<AdminAddAllUploads {...{
storageUrls,
uniqueTags,
isAdding,
setIsAdding,
setUrlAddStatuses,
}} />}
<AdminUploadsTable {...{ isAdding, urlAddStatuses }} />
</div>
);
}

View File

@ -1,99 +1,116 @@
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 { 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 { UrlAddStatus } from './AdminUploadsClient';
import ResponsiveDate from '@/components/ResponsiveDate';
export default function AdminUploadsTable({
title,
urls,
addedUploadUrls,
isAdding,
urlAddStatuses,
}: {
title?: string
urls: StorageListResponse
addedUploadUrls?: string[]
isAdding?: boolean
urlAddStatuses: UrlAddStatus[]
}) {
const isComplete = urlAddStatuses.every(({ status }) => status === 'added');
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
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>
<div className="space-y-4">
{urlAddStatuses.map(({ url, status, statusMessage, uploadedAt }) =>
<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',
<div
className={clsx(
'flex items-center grow gap-2',
'transition-opacity',
isAdding && !isComplete && status !== 'adding' && 'opacity-30',
)}
>
<div className={clsx(
'shrink-0 transition-transform',
isAdding && !isComplete && status === 'adding' &&
'translate-x-[-2px] scale-[1.15]',
isAdding && !isComplete && status !== 'adding'
? 'scale-90'
: 'scale-100',
)}>
{addedUploadUrls?.includes(url)
? <FaRegCircleCheck size={18} />
: <Spinner
size={19}
className="translate-y-[2px]"
/>}
<ImageSmall
src={url}
alt={url}
aspectRatio={3.0 / 2.0}
className={clsx(
'rounded-[3px] overflow-hidden',
'border-subtle',
isAdding && !isComplete && status === 'adding' &&
'animate-hover-drift shadow-lg',
)}
/>
</div>
<span className="grow min-w-0">
<div className="overflow-hidden text-ellipsis">
{getIdFromStorageUrl(url)}
</div>
<div className="text-dim overflow-hidden text-ellipsis">
{isAdding || isComplete
? status === 'added'
? 'Added'
: status === 'adding'
? statusMessage ?? 'Adding ...'
: 'Waiting'
: uploadedAt
? <ResponsiveDate date={uploadedAt} />
: '—'}
</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>
</>}
</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={pathForAdminUploadUrl(url)} />
<FormWithConfirm
action={deleteBlobPhotoAction}
confirmText="Are you sure you want to delete this upload?"
>
<input
type="hidden"
name="redirectToPhotos"
value={urlAddStatuses.length < 2 ? 'true' : 'false'}
readOnly
/>
<input
type="hidden"
name="url"
value={url}
readOnly
/>
<DeleteButton />
</FormWithConfirm>
</>}
</span>
</div>
</Fragment>;})}
</AdminTable>
</div>)}
</div>
);
}
}

View File

@ -44,7 +44,7 @@ export default function PhotoSmall({
selected && 'brightness-50',
'min-w-[50px]',
'rounded-[3px] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
'border-subtle',
)}
prefetch={prefetch}
>

View File

@ -46,6 +46,7 @@ import {
import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc';
import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } 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<UrlAddStatus>();
const streamUpdate = (subhead: string) =>
const streamUpdate = (
statusMessage: string,
status: UrlAddStatus['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');
@ -141,7 +144,7 @@ export const addAllUploadsAction = async ({
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
};
streamUpdate('Moving upload to photo storage');
streamUpdate('Transferring to photo storage');
const updatedUrl = await convertUploadToPhoto({
urlOrigin: url,
@ -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,
})));

View File

@ -150,6 +150,11 @@
@apply
text-red-500 dark:text-red-400
}
/* Utilities: Border */
.border-subtle {
@apply
border border-gray-200 dark:border-gray-800
}
/* Utilities: Background */
.bg-main {
@apply

View File

@ -27,6 +27,10 @@ module.exports = {
animation: {
'rotate-pulse':
'rotate-pulse 0.75s linear infinite normal both running',
'hover-drift':
'hover-drift 8s linear infinite',
'hover-wobble':
'hover-wobble 6s linear infinite normal both running',
},
keyframes: {
'rotate-pulse': {
@ -34,6 +38,22 @@ module.exports = {
'50%': { transform: 'rotate(180deg) scale(0.8)' },
'100%': { transform: 'rotate(360deg) scale(1)' },
},
'hover-drift': {
'0%': { transform: 'translate(0, 0)' },
'20%': { transform: 'translate(1px, -2px)' },
'40%': { transform: 'translate(1px, 1.5px)' },
'60%': { transform: 'translate(-1px, 2px)' },
'80%': { transform: 'translate(-1.5px, -1.75px)' },
'100%': { transform: 'translate(0, 0)' },
},
'hover-wobble': {
'0%': { transform: 'rotate(0deg)' },
'20%': { transform: 'rotate(3.5deg)' },
'40%': { transform: 'rotate(-2deg)' },
'60%': { transform: 'rotate(2.5deg)' },
'80%': { transform: 'rotate(-2.5deg)' },
'100%': { transform: 'rotate(0deg)' },
},
},
},
},