Accept titles when adding uploads
This commit is contained in:
parent
5d85591dba
commit
63fafb87af
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user