Finalize photo editing AI experience
This commit is contained in:
parent
6fd8ff34e2
commit
9f08716568
@ -15,13 +15,13 @@ export default async function PhotoEditPage({
|
||||
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
|
||||
const aiTextGeneration = AI_TEXT_GENERATION_ENABLED;
|
||||
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
|
||||
|
||||
return (
|
||||
<PhotoEditPageClient {...{
|
||||
photo,
|
||||
uniqueTags,
|
||||
aiTextGeneration,
|
||||
hasAiTextGeneration,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -101,25 +101,38 @@ export default function FieldSetWithStatus({
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending || loading}
|
||||
/>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
: type === 'textarea'
|
||||
? <textarea
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
'w-full h-24 resize-none',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,7 +4,11 @@ import AdminChildPage from '@/components/AdminChildPage';
|
||||
import { Photo } from '.';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { PhotoFormData, convertPhotoToFormData } from './form';
|
||||
import {
|
||||
PhotoFormData,
|
||||
convertPhotoToFormData,
|
||||
formHasTextContent,
|
||||
} from './form';
|
||||
import PhotoForm from './form/PhotoForm';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { areSimpleObjectsEqual } from '@/utility/object';
|
||||
@ -13,17 +17,16 @@ import { getExifDataAction } from './actions';
|
||||
import { Tags } from '@/tag';
|
||||
import { useState } from 'react';
|
||||
import useAiImageQueries from './ai/useAiImageQueries';
|
||||
import { HiSparkles } from 'react-icons/hi';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import AiButton from './ai/AiButton';
|
||||
|
||||
export default function PhotoEditPageClient({
|
||||
photo,
|
||||
uniqueTags,
|
||||
aiTextGeneration,
|
||||
hasAiTextGeneration,
|
||||
}: {
|
||||
photo: Photo
|
||||
uniqueTags: Tags
|
||||
aiTextGeneration: boolean
|
||||
hasAiTextGeneration: boolean
|
||||
}) {
|
||||
const seedExifData = { url: photo.url };
|
||||
|
||||
@ -32,8 +35,12 @@ export default function PhotoEditPageClient({
|
||||
seedExifData,
|
||||
);
|
||||
|
||||
const photoForm = convertPhotoToFormData(photo);
|
||||
|
||||
const [pending, setIsPending] = useState(false);
|
||||
const [updatedTitle, setUpdatedTitle] = useState('');
|
||||
const [hasTextContent, setHasTextContent] =
|
||||
useState(formHasTextContent(photoForm));
|
||||
|
||||
const hasExifDataBeenFound = !areSimpleObjectsEqual(
|
||||
updatedExifData,
|
||||
@ -51,15 +58,7 @@ export default function PhotoEditPageClient({
|
||||
: photo.title || photo.id}
|
||||
accessory={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="min-w-[3.25rem] flex justify-center"
|
||||
onClick={aiContent.request}
|
||||
disabled={!aiContent.isReady || aiContent.isLoading}
|
||||
>
|
||||
{aiContent.isLoading
|
||||
? <Spinner />
|
||||
: <HiSparkles size={16} />}
|
||||
</button>
|
||||
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />
|
||||
<form action={action}>
|
||||
<input name="photoUrl" value={photo.url} hidden readOnly />
|
||||
<SubmitButtonWithStatus
|
||||
@ -75,13 +74,14 @@ export default function PhotoEditPageClient({
|
||||
>
|
||||
<PhotoForm
|
||||
type="edit"
|
||||
initialPhotoForm={convertPhotoToFormData(photo)}
|
||||
initialPhotoForm={photoForm}
|
||||
updatedExifData={hasExifDataBeenFound
|
||||
? updatedExifData
|
||||
: undefined}
|
||||
uniqueTags={uniqueTags}
|
||||
aiContent={aiTextGeneration ? aiContent : undefined}
|
||||
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
||||
onTitleChange={setUpdatedTitle}
|
||||
onTextContentChange={setHasTextContent}
|
||||
onFormStatusChange={setIsPending}
|
||||
/>
|
||||
</AdminChildPage>
|
||||
|
||||
28
src/photo/ai/AiButton.tsx
Normal file
28
src/photo/ai/AiButton.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { AiContent } from './useAiImageQueries';
|
||||
import { HiSparkles } from 'react-icons/hi';
|
||||
|
||||
export default function AiButton({
|
||||
aiContent: { request, isReady, isLoading },
|
||||
shouldConfirm,
|
||||
}: {
|
||||
aiContent: AiContent
|
||||
shouldConfirm?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="min-w-[3.25rem] flex justify-center"
|
||||
onClick={() => {
|
||||
if (
|
||||
!shouldConfirm ||
|
||||
confirm('Are you sure you want to overwrite existing content?')
|
||||
) {
|
||||
request();
|
||||
}
|
||||
}}
|
||||
disabled={!isReady || isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -17,7 +17,7 @@ export const AI_IMAGE_QUERIES: Record<AiImageQuery, string> = {
|
||||
'caption': 'What is a pithy caption for this image in 8 words or less?',
|
||||
'title-and-caption': 'Write a short title and pithy caption of 8 words or less for this image, using the format Title: "title" Caption: "caption"',
|
||||
'tags': 'Describe this image three or less comma-separated keywords with no adjective or adverbs',
|
||||
'description-small': 'Describe this image succinctly',
|
||||
'description-small': 'Describe this image succinctly without the initial text "This image shows" or "This is a picture of"',
|
||||
'description': 'Describe this image',
|
||||
'description-large': 'Describe this image in detail',
|
||||
'description-semantic': 'List up to 5 things in this image without description as a comma-separated list',
|
||||
|
||||
@ -13,7 +13,8 @@ export default function useAiImageQueries() {
|
||||
requestTitleCaption,
|
||||
title,
|
||||
caption,
|
||||
isLoadingTitleCaption,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
] = useTitleCaptionAiImageQuery(imageData);
|
||||
|
||||
const [
|
||||
@ -26,7 +27,7 @@ export default function useAiImageQueries() {
|
||||
requestSemantic,
|
||||
semanticDescription,
|
||||
isLoadingSemantic,
|
||||
] = useAiImageQuery(imageData, 'description-semantic');
|
||||
] = useAiImageQuery(imageData, 'description-small');
|
||||
|
||||
const hasContent = Boolean(
|
||||
title ||
|
||||
@ -36,13 +37,13 @@ export default function useAiImageQueries() {
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingTitleCaption ||
|
||||
isLoadingTitle ||
|
||||
isLoadingCaption ||
|
||||
isLoadingTags ||
|
||||
isLoadingSemantic;
|
||||
|
||||
const request = useCallback(async () => {
|
||||
if (!isLoading) {
|
||||
console.log('REQUESTING ALL IMAGE QUERIES');
|
||||
requestTitleCaption();
|
||||
requestTags();
|
||||
requestSemantic();
|
||||
@ -58,7 +59,8 @@ export default function useAiImageQueries() {
|
||||
isReady,
|
||||
hasContent,
|
||||
isLoading,
|
||||
isLoadingTitleCaption,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
isLoadingTags,
|
||||
isLoadingSemantic,
|
||||
setImageData,
|
||||
|
||||
@ -20,7 +20,9 @@ export default function useAiImageQuery(
|
||||
query,
|
||||
);
|
||||
for await (const text of readStreamableValue(textStream)) {
|
||||
setText((text ?? '').replaceAll('\n', ' '));
|
||||
setText((text ?? '')
|
||||
.replaceAll('\n', ' ')
|
||||
.replace(/\.$/, ''));
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
|
||||
@ -13,7 +13,7 @@ export default function useTitleCaptionAiImageQuery(
|
||||
|
||||
const { title, caption } = useMemo(() => {
|
||||
const matches = text.includes('Title')
|
||||
? text.match(/^[`']*Title: "*(.*?)\.*"* Caption: "*(.*?)\.*"*[`']*$/)
|
||||
? text.match(/^[`']*Title: "*(.*?)"* Caption: "*(.*?)\.*"*[`']*$/)
|
||||
: text.match(/^(.*?): (.*?)$/);
|
||||
|
||||
return {
|
||||
@ -22,11 +22,15 @@ export default function useTitleCaptionAiImageQuery(
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const isLoadingTitle = isLoading && !caption;
|
||||
const isLoadingCaption = isLoading;
|
||||
|
||||
return [
|
||||
request,
|
||||
title,
|
||||
caption,
|
||||
isLoading,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
error,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
FORM_METADATA_ENTRIES,
|
||||
PhotoFormData,
|
||||
convertFormKeysToLabels,
|
||||
formHasTextContent,
|
||||
getFormErrors,
|
||||
isFormValid,
|
||||
} from '.';
|
||||
@ -37,6 +38,7 @@ export default function PhotoForm({
|
||||
aiContent,
|
||||
debugBlur,
|
||||
onTitleChange,
|
||||
onTextContentChange,
|
||||
onFormStatusChange,
|
||||
}: {
|
||||
initialPhotoForm: Partial<PhotoFormData>
|
||||
@ -47,6 +49,7 @@ export default function PhotoForm({
|
||||
setImageData?: (imageData: string) => void
|
||||
debugBlur?: boolean
|
||||
onTitleChange?: (updatedTitle: string) => void
|
||||
onTextContentChange?: (hasContent: boolean) => void,
|
||||
onFormStatusChange?: (pending: boolean) => void
|
||||
}) {
|
||||
const [formData, setFormData] =
|
||||
@ -135,9 +138,9 @@ export default function PhotoForm({
|
||||
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return aiContent?.isLoadingTitleCaption;
|
||||
return aiContent?.isLoadingTitle;
|
||||
case 'caption':
|
||||
return aiContent?.isLoadingTitleCaption;
|
||||
return aiContent?.isLoadingCaption;
|
||||
case 'tags':
|
||||
return aiContent?.isLoadingTags;
|
||||
case 'semanticDescription':
|
||||
@ -227,7 +230,9 @@ export default function PhotoForm({
|
||||
error={formErrors[key]}
|
||||
value={formData[key] ?? ''}
|
||||
onChange={value => {
|
||||
setFormData({ ...formData, [key]: value });
|
||||
const formUpdated = { ...formData, [key]: value };
|
||||
setFormData(formUpdated);
|
||||
onTextContentChange?.(formHasTextContent(formUpdated));
|
||||
if (validate) {
|
||||
setFormErrors({ ...formErrors, [key]: validate(value) });
|
||||
} else if (validateStringMaxLength !== undefined) {
|
||||
|
||||
@ -27,7 +27,8 @@ export type FieldSetType =
|
||||
'text' |
|
||||
'email' |
|
||||
'password' |
|
||||
'checkbox';
|
||||
'checkbox' |
|
||||
'textarea';
|
||||
|
||||
export type AnnotatedTag = {
|
||||
value: string,
|
||||
@ -70,7 +71,8 @@ const FORM_METADATA = (
|
||||
label: 'caption',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
||||
shouldHide: ({ title, caption }) => !title && !caption,
|
||||
shouldHide: ({ title, caption }) =>
|
||||
!aiTextGeneration && (!title && !caption),
|
||||
},
|
||||
tags: {
|
||||
label: 'tags',
|
||||
@ -80,6 +82,7 @@ const FORM_METADATA = (
|
||||
: undefined,
|
||||
},
|
||||
semanticDescription: {
|
||||
type: 'textarea',
|
||||
label: 'semantic description (not visible)',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
||||
@ -148,6 +151,14 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
||||
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
|
||||
);
|
||||
|
||||
export const formHasTextContent = ({
|
||||
title,
|
||||
caption,
|
||||
tags,
|
||||
semanticDescription,
|
||||
}: Partial<PhotoFormData>) =>
|
||||
Boolean(title || caption || tags || semanticDescription);
|
||||
|
||||
// CREATE FORM DATA: FROM PHOTO
|
||||
|
||||
export const convertPhotoToFormData = (
|
||||
|
||||
@ -19,21 +19,24 @@
|
||||
}
|
||||
.control,
|
||||
button, .button,
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
input[type=text], input[type=email], input[type=password], select, textarea {
|
||||
@apply
|
||||
px-2.5 py-2
|
||||
border rounded-md
|
||||
bg-main
|
||||
border-gray-200 dark:border-gray-700
|
||||
font-mono text-base leading-tight
|
||||
min-h-[2.4rem]
|
||||
font-mono text-base leading-tight
|
||||
}
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
input[type=text], input[type=email], input[type=password], select, textarea {
|
||||
@apply
|
||||
text-[1rem] /* Prevent iOS auto-zoom behavior */
|
||||
min-w-[20rem] read-only:cursor-default
|
||||
}
|
||||
input[type=text], input[type=email], input[type=password] {
|
||||
input[type=text], input[type=email], input[type=password], select {
|
||||
@apply
|
||||
min-h-[2.4rem]
|
||||
}
|
||||
input[type=text], input[type=email], input[type=password], textarea {
|
||||
@apply
|
||||
read-only:bg-gray-100
|
||||
dark:read-only:bg-gray-900 dark:read-only:text-gray-400
|
||||
|
||||
Loading…
Reference in New Issue
Block a user