Finalize photo editing AI experience

This commit is contained in:
Sam Becker 2024-03-21 09:41:43 -05:00
parent 6fd8ff34e2
commit 9f08716568
11 changed files with 124 additions and 56 deletions

View File

@ -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,
}} />
);
};

View File

@ -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>
);
};

View File

@ -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
View 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>
);
}

View File

@ -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',

View File

@ -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,

View File

@ -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) {

View File

@ -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;
}

View File

@ -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) {

View File

@ -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 = (

View File

@ -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