From 63fafb87af14a9b9fc8c63c5ee2128a00c8b1415 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 17 Jun 2025 09:33:07 -0500 Subject: [PATCH] Accept titles when adding uploads --- src/admin/AdminBatchUploadActions.tsx | 40 +++++++++++------- src/admin/AdminUploadsClient.tsx | 10 ++++- src/admin/AdminUploadsTableRow.tsx | 60 +++++++++++++++++++-------- src/components/FieldSetWithStatus.tsx | 3 ++ src/components/ResponsiveDate.tsx | 34 ++++++++------- src/photo/actions.ts | 16 ++++--- src/photo/ai/index.ts | 7 +++- 7 files changed, 112 insertions(+), 58 deletions(-) diff --git a/src/admin/AdminBatchUploadActions.tsx b/src/admin/AdminBatchUploadActions.tsx index e2659086..f0ea10d1 100644 --- a/src/admin/AdminBatchUploadActions.tsx +++ b/src/admin/AdminBatchUploadActions.tsx @@ -3,7 +3,7 @@ import ErrorNote from '@/components/ErrorNote'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import Container from '@/components/Container'; -import { addAllUploadsAction } from '@/photo/actions'; +import { addUploadsAction } from '@/photo/actions'; import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import { Tags } from '@/tag'; import { @@ -27,7 +27,8 @@ import FieldsetHidden from '@/photo/form/FieldsetHidden'; const UPLOAD_BATCH_SIZE = 2; export default function AdminBatchUploadActions({ - storageUrls, + uploadUrls, + uploadTitles, uniqueTags, isAdding, setIsAdding, @@ -35,7 +36,8 @@ export default function AdminBatchUploadActions({ isDeleting, setIsDeleting, }: { - storageUrls: string[] + uploadUrls: string[] + uploadTitles: string[] uniqueTags?: Tags isAdding: boolean setIsAdding: Dispatch> @@ -59,10 +61,15 @@ export default function AdminBatchUploadActions({ const router = useRouter(); const addedUploadCount = useRef(0); - const addUploadUrls = async (uploadUrls: string[], isFinalBatch: boolean) => { + const addUploadUrls = async ( + urls: string[], + titles: string[], + isFinalBatch: boolean, + ) => { try { - const stream = await addAllUploadsAction({ - uploadUrls, + const stream = await addUploadsAction({ + uploadUrls: urls, + uploadTitles: titles, ...showBulkSettings && { tags, favorite, @@ -73,9 +80,8 @@ export default function AdminBatchUploadActions({ shouldRevalidateAllKeysAndPaths: isFinalBatch, }); for await (const data of readStreamableValue(stream)) { - setButtonText(addedUploadCount.current === 0 - ? `Adding 1 of ${storageUrls.length}` - : `Adding ${addedUploadCount.current + 1} of ${storageUrls.length}`, + setButtonText( + `Adding ${addedUploadCount.current + 1} of ${uploadUrls.length}`, ); setUrlAddStatuses(current => { const update = current.map(status => @@ -100,7 +106,7 @@ export default function AdminBatchUploadActions({ ((addedUploadCount.current || 1) - 1) + (data?.progress ?? 0) ) / - storageUrls.length + uploadUrls.length ) * 0.95; // Prevent out-of-order updates causing progress to go backwards return Math.max(current, updatedProgress); @@ -123,8 +129,8 @@ export default function AdminBatchUploadActions({
{showBulkSettings - ? `Apply to ${pluralize(storageUrls.length, 'upload')}` - : `Found ${pluralize(storageUrls.length, 'upload')}`} + ? `Apply to ${pluralize(uploadUrls.length, 'upload')}` + : `Found ${pluralize(uploadUrls.length, 'upload')}`}
{ // eslint-disable-next-line max-len - if (confirm(`Are you sure you want to add all ${storageUrls.length} uploads?`)) { + if (confirm(`Are you sure you want to add all ${uploadUrls.length} uploads?`)) { setIsAdding(true); setUrlAddStatuses(current => current.map((url, index) => ({ ...url, status: index === 0 ? 'adding' : 'waiting', }))); - const uploadsToAdd = storageUrls.slice(); + const uploadsToAdd = uploadUrls.slice(); + const titlesToAdd = uploadTitles.slice(); try { while (uploadsToAdd.length > 0) { const nextBatch = uploadsToAdd .splice(0, UPLOAD_BATCH_SIZE); + const nextTitles = titlesToAdd + .splice(0, UPLOAD_BATCH_SIZE); await addUploadUrls( nextBatch, + nextTitles, uploadsToAdd.length === 0, ); } @@ -212,7 +222,7 @@ export default function AdminBatchUploadActions({ {buttonText} setIsDeleting(true)} onDelete={didFail => { if (!didFail) { diff --git a/src/admin/AdminUploadsClient.tsx b/src/admin/AdminUploadsClient.tsx index 9027c2e2..5a35c3e6 100644 --- a/src/admin/AdminUploadsClient.tsx +++ b/src/admin/AdminUploadsClient.tsx @@ -9,6 +9,7 @@ import AdminUploadsTable from './AdminUploadsTable'; export type UrlAddStatus = StorageListItem & { status?: 'waiting' | 'adding' | 'added' statusMessage?: string + draftTitle?: string progress?: number }; @@ -21,7 +22,11 @@ export default function AdminUploadsClient({ }) { const [isAdding, setIsAdding] = useState(false); const [urlAddStatuses, setUrlAddStatuses] = useState(urls); - const storageUrls = useMemo(() => urls.map(({ url }) => url), [urls]); + + const uploadUrls = useMemo(() => urlAddStatuses + .map(({ url }) => url), [urlAddStatuses]); + const uploadTitles = useMemo(() => urlAddStatuses + .map(({ draftTitle }) => draftTitle ?? ''), [urlAddStatuses]); const [isDeleting, setIsDeleting] = useState(false); @@ -29,7 +34,8 @@ export default function AdminUploadsClient({
{(urls.length > 1 || isAdding) && (null); + const extension = getExtensionFromStorageUrl(url)?.toUpperCase(); + useEffect(() => { if ( status === 'adding' && @@ -73,25 +77,45 @@ export default function AdminUploadsTableRow({
-
-
- {uploadedAt - ? - : '—'} -
-
- {isAdding || isComplete - ? status === 'added' - ? 'Added' - : status === 'adding' - ? statusMessage ?? 'Adding ...' - : 'Waiting' - : size - ? `${size} ${getExtensionFromStorageUrl(url)?.toUpperCase()}` - : getExtensionFromStorageUrl(url)?.toUpperCase()} +
+ { + setUrlAddStatuses?.(urlAddStatuses.map(status => ({ + ...status, + draftTitle: status.url === url + ? titleUpdated + : status.draftTitle, + }))); + }} + placeholder="Optional title" + tabIndex={urlAddStatuses + .findIndex(status => status.url === url) + 1} + hideLabel + /> +
+
+ {isAdding || isComplete + ? status === 'added' + ? 'Added' + : status === 'adding' + ? statusMessage ?? 'Adding ...' + : 'Waiting' + : uploadedAt + ? + : '—'} +
+
+ {size + ? `${size} ${extension}` + : extension} +
diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index ee48dbf3..382ac7ce 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -38,6 +38,7 @@ export default function FieldSetWithStatus({ inputRef: inputRefProp, accessory, hideLabel, + tabIndex, }: { id?: string label: string @@ -65,6 +66,7 @@ export default function FieldSetWithStatus({ inputRef?: RefObject accessory?: React.ReactNode hideLabel?: boolean + tabIndex?: number }) { const inputRefInternal = useRef(null); @@ -104,6 +106,7 @@ export default function FieldSetWithStatus({ ) && 'opacity-50 cursor-not-allowed', Boolean(error) && 'error', ), + tabIndex, }; return ( diff --git a/src/components/ResponsiveDate.tsx b/src/components/ResponsiveDate.tsx index e8a3657a..371ac467 100644 --- a/src/components/ResponsiveDate.tsx +++ b/src/components/ResponsiveDate.tsx @@ -1,23 +1,20 @@ 'use client'; import { formatDate } from '@/utility/date'; -import { Timezone } from '@/utility/timezone'; import { clsx } from 'clsx/lite'; import { useEffect, useState } from 'react'; export default function ResponsiveDate({ date, + length, className, titleLabel, timezone: timezoneFromProps, hideTime, }: { - date: Date className?: string titleLabel?: string - timezone?: Timezone - hideTime?: boolean, -}) { +} & Parameters[0]) { const [timezone, setTimezone] = useState(timezoneFromProps); useEffect(() => { @@ -28,7 +25,19 @@ export default function ResponsiveDate({ const showPlaceholder = timezone === undefined; - const titleDateFormatted = formatDate({ date, timezone }) + const formatDateProps: Parameters[0] = { + date, + length, + timezone, + }; + + const formatDateDynamic: Parameters[0] = { + ...formatDateProps, + showPlaceholder, + hideTime, + }; + + const titleDateFormatted = formatDate(formatDateProps) .toLocaleUpperCase(); const title = titleLabel @@ -37,13 +46,6 @@ export default function ResponsiveDate({ const contentClass = showPlaceholder && 'opacity-0 select-none'; - const formatDateProps = { - date, - timezone, - showPlaceholder, - hideTime, - } as const; - return ( - {formatDate({ ...formatDateProps, length: 'short' })} + {formatDate({ ...formatDateDynamic, length: 'short' })} {/* Medium */} - {formatDate({ ...formatDateProps, length: 'medium' })} + {formatDate({ ...formatDateDynamic, length: 'medium' })} {/* Large */} - {formatDate(formatDateProps)} + {formatDate(formatDateDynamic)} ); diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 7fc9e7d2..38a0ccf7 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -85,8 +85,9 @@ export const createPhotoAction = async (formData: FormData) => } }); -export const addAllUploadsAction = async ({ +export const addUploadsAction = async ({ uploadUrls, + uploadTitles, tags, favorite, hidden, @@ -95,6 +96,7 @@ export const addAllUploadsAction = async ({ shouldRevalidateAllKeysAndPaths = true, }: { uploadUrls: string[] + uploadTitles: string[] tags?: string favorite?: string hidden?: string @@ -124,8 +126,9 @@ export const addAllUploadsAction = async ({ (async () => { try { - for (const url of uploadUrls) { + for (const [index, url] of uploadUrls.entries()) { currentUploadUrl = url; + const title = uploadTitles[index]; progress = 0; streamUpdate('Parsing EXIF data'); @@ -146,18 +149,21 @@ export const addAllUploadsAction = async ({ } const { - title, + title: aiTitle, caption, tags: aiTags, semanticDescription, } = await generateAiImageQueries( imageResizedBase64, - AI_TEXT_AUTO_GENERATED_FIELDS, + Boolean(title) + ? AI_TEXT_AUTO_GENERATED_FIELDS + .filter(field => field !== 'title') + : AI_TEXT_AUTO_GENERATED_FIELDS, ); const form: Partial = { ...formDataFromExif, - title, + title: title || aiTitle, caption, tags: tags || aiTags, hidden, diff --git a/src/photo/ai/index.ts b/src/photo/ai/index.ts index ad4a1ea5..619c14a0 100644 --- a/src/photo/ai/index.ts +++ b/src/photo/ai/index.ts @@ -53,10 +53,13 @@ export type AiImageQuery = export const getAiImageQuery = ( query: AiImageQuery, existingTags: Tags = [], + existingTitle?: string, ): string => { - switch (query) { + switch (query) { case 'title': return 'Write a compelling title for this image in 3 words or less'; - case 'caption': return 'Write a pithy caption for this image in 6 words or less and no punctuation'; + case 'caption': return existingTitle + ? `Write a pithy caption for this image in 6 words or less and no punctuation that complements the existing title: "${existingTitle}"` + : 'Write a pithy caption for this image in 6 words or less and no punctuation'; case 'title-and-caption': return 'Write a compelling title and pithy caption of 8 words or less for this image, using the format Title: "title" Caption: "caption"'; case 'tags': const tagQuery = 'Describe this image in 1-2 comma-separated unique keywords, with no adjective or adverbs. Avoid using general terms like "nature," "travel," "architecture," or "sky." Use terms that are highly specific to the image and not redundant.';