Accept titles when adding uploads

This commit is contained in:
Sam Becker 2025-06-17 09:33:07 -05:00
parent 5d85591dba
commit 63fafb87af
7 changed files with 112 additions and 58 deletions

View File

@ -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<SetStateAction<boolean>>
@ -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({
<div className="flex">
<div className="grow text-main">
{showBulkSettings
? `Apply to ${pluralize(storageUrls.length, 'upload')}`
: `Found ${pluralize(storageUrls.length, 'upload')}`}
? `Apply to ${pluralize(uploadUrls.length, 'upload')}`
: `Found ${pluralize(uploadUrls.length, 'upload')}`}
</div>
<FieldSetWithStatus
label="Apply to All"
@ -177,19 +183,23 @@ export default function AdminBatchUploadActions({
}
onClick={async () => {
// 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}
</ProgressButton>
<DeleteUploadButton
urls={storageUrls}
urls={uploadUrls}
onDeleteStart={() => setIsDeleting(true)}
onDelete={didFail => {
if (!didFail) {

View File

@ -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<UrlAddStatus[]>(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({
<div className="space-y-4">
{(urls.length > 1 || isAdding) &&
<AdminBatchUploadActions {...{
storageUrls,
uploadUrls,
uploadTitles,
uniqueTags,
isAdding,
setIsAdding,

View File

@ -13,11 +13,13 @@ import { pathForAdminUploadUrl } from '@/app/paths';
import DeleteBlobButton from './DeleteUploadButton';
import { useEffect, useRef } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
export default function AdminUploadsTableRow({
url,
status,
statusMessage,
draftTitle = '',
uploadedAt,
size,
isAdding,
@ -36,6 +38,8 @@ export default function AdminUploadsTableRow({
}) {
const ref = useRef<HTMLDivElement>(null);
const extension = getExtensionFromStorageUrl(url)?.toUpperCase();
useEffect(() => {
if (
status === 'adding' &&
@ -73,25 +77,45 @@ export default function AdminUploadsTableRow({
<div className={clsx(
'flex flex-col w-full self-start',
'gap-2 sm:gap-4',
'p-2.5 pl-3',
'sm:p-4 sm:pl-6',
'p-3 sm:p-4',
)}>
<div className="flex flex-col gap-0.5 h-full">
<div className="truncate font-medium">
{uploadedAt
? <ResponsiveDate date={uploadedAt} />
: '—'}
</div>
<div className="text-dim overflow-hidden text-ellipsis">
{isAdding || isComplete
? status === 'added'
? 'Added'
: status === 'adding'
? statusMessage ?? 'Adding ...'
: 'Waiting'
: size
? `${size} ${getExtensionFromStorageUrl(url)?.toUpperCase()}`
: getExtensionFromStorageUrl(url)?.toUpperCase()}
<div className="flex flex-col gap-1.5 h-full">
<FieldSetWithStatus
label="Title"
value={draftTitle}
onChange={titleUpdated => {
setUrlAddStatuses?.(urlAddStatuses.map(status => ({
...status,
draftTitle: status.url === url
? titleUpdated
: status.draftTitle,
})));
}}
placeholder="Optional title"
tabIndex={urlAddStatuses
.findIndex(status => status.url === url) + 1}
hideLabel
/>
<div className={clsx(
'flex gap-y-1 gap-x-3 max-lg:flex-col',
'ml-0.5',
)}>
<div>
{isAdding || isComplete
? status === 'added'
? 'Added'
: status === 'adding'
? statusMessage ?? 'Adding ...'
: 'Waiting'
: uploadedAt
? <ResponsiveDate date={uploadedAt} length="medium" />
: '—'}
</div>
<div className="text-dim">
{size
? `${size} ${extension}`
: extension}
</div>
</div>
</div>
<span className="flex items-center gap-2">

View File

@ -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<HTMLInputElement | null>
accessory?: React.ReactNode
hideLabel?: boolean
tabIndex?: number
}) {
const inputRefInternal = useRef<HTMLInputElement>(null);
@ -104,6 +106,7 @@ export default function FieldSetWithStatus({
) && 'opacity-50 cursor-not-allowed',
Boolean(error) && 'error',
),
tabIndex,
};
return (

View File

@ -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<typeof formatDate>[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<typeof formatDate>[0] = {
date,
length,
timezone,
};
const formatDateDynamic: Parameters<typeof formatDate>[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 (
<span
title={showPlaceholder ? 'LOADING LOCAL TIME' : title}
@ -58,20 +60,20 @@ export default function ResponsiveDate({
className={clsx('xs:hidden', contentClass)}
aria-hidden
>
{formatDate({ ...formatDateProps, length: 'short' })}
{formatDate({ ...formatDateDynamic, length: 'short' })}
</span>
{/* Medium */}
<span
className={clsx('hidden xs:inline sm:hidden', contentClass)}
aria-hidden
>
{formatDate({ ...formatDateProps, length: 'medium' })}
{formatDate({ ...formatDateDynamic, length: 'medium' })}
</span>
{/* Large */}
<span
className={clsx('hidden sm:inline', contentClass)}
>
{formatDate(formatDateProps)}
{formatDate(formatDateDynamic)}
</span>
</span>
);

View File

@ -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<PhotoFormData> = {
...formDataFromExif,
title,
title: title || aiTitle,
caption,
tags: tags || aiTags,
hidden,

View File

@ -53,10 +53,13 @@ export type AiImageQuery =
export const getAiImageQuery = (
query: AiImageQuery,
existingTags: Tags = [],
existingTitle?: string,
): string => {
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.';