Refine recipe scanning ux

This commit is contained in:
Sam Becker 2025-03-13 21:55:27 -05:00
parent 8b6ea0da6d
commit ee050b550e
8 changed files with 272 additions and 163 deletions

View File

@ -10,6 +10,7 @@ import {
} from '@/app/config';
import ErrorNote from '@/components/ErrorNote';
import { getRecipeTitleForData } from '@/photo/db/query';
import { FilmSimulation } from '@/simulation';
export const maxDuration = 60;
@ -48,8 +49,11 @@ export default async function UploadPage({ params }: Params) {
] = await Promise.all([
getUniqueTagsCached(),
getUniqueRecipesCached(),
formDataFromExif?.recipeData
? getRecipeTitleForData(formDataFromExif.recipeData)
formDataFromExif?.recipeData && formDataFromExif.filmSimulation
? getRecipeTitleForData(
formDataFromExif.recipeData,
formDataFromExif.filmSimulation as FilmSimulation,
)
: undefined,
]);

View File

@ -16,6 +16,7 @@ export default function FieldSetWithStatus({
value,
isModified,
onChange,
className,
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
@ -31,6 +32,7 @@ export default function FieldSetWithStatus({
inputRef,
accessory,
hideLabel,
checkboxAccessory,
}: {
id: string
label?: string
@ -39,6 +41,7 @@ export default function FieldSetWithStatus({
value: string
isModified?: boolean
onChange?: (value: string) => void
className?: string
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
@ -54,142 +57,157 @@ export default function FieldSetWithStatus({
inputRef?: Ref<HTMLInputElement>
accessory?: React.ReactNode
hideLabel?: boolean
checkboxAccessory?: React.ReactNode
}) {
const { pending } = useFormStatus();
const renderInput =
<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}
spellCheck={spellCheck}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending || loading}
disabled={type === 'checkbox' && (
readOnly || pending || loading
)}
className={clsx(
(
type === 'text' ||
type === 'email' ||
type === 'password'
) && 'w-full',
type === 'checkbox' && (
readOnly || pending || loading
) && 'opacity-50 cursor-not-allowed',
Boolean(error) && 'error',
)}
/>;
return (
<div className={clsx(
'space-y-1',
type === 'checkbox' && 'flex items-center gap-2',
)}>
{!hideLabel && label &&
<label
className={clsx(
'flex flex-wrap gap-x-2 items-center select-none',
type === 'checkbox' && 'order-2 pt-[3px]',
)}
htmlFor={id}
>
{label}
{note && !error &&
<span className="text-gray-400 dark:text-gray-600">
({note})
</span>}
{isModified && !error &&
<span className={clsx(
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]',
)}>
*
</span>}
{error &&
<span className="text-error">
{error}
</span>}
{required &&
<span className="text-gray-400 dark:text-gray-600">
Required
</span>}
{loading &&
<span className="translate-y-[1.5px]">
<Spinner />
</span>}
</label>}
<div className="flex gap-2">
{selectOptions
? <div className="relative w-full">
<select
id={id}
name={id}
value={value}
onChange={e => onChange?.(e.target.value)}
className={clsx(
'w-full',
clsx(Boolean(error) && 'error'),
// Use special class because `select` can't be readonly
readOnly || pending && 'disabled-select',
)}
>
{selectOptionsDefaultLabel &&
<option value="">{selectOptionsDefaultLabel}</option>}
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
<option
key={optionValue}
value={optionValue}
>
{optionLabel}
</option>)}
</select>
<div className={clsx(
'absolute top-0 right-3 z-10 pointer-events-none',
'flex h-full items-center',
'text-extra-dim text-2xl',
)}>
<FiChevronDown />
</div>
</div>
: tagOptions
? <TagInput
id={id}
name={id}
value={value}
options={tagOptions}
onChange={onChange}
showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading}
placeholder={placeholder}
limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage}
/>
: type === 'textarea'
? <textarea
type === 'hidden'
? renderInput
: <div className={clsx(
'space-y-1',
type === 'checkbox' && 'flex items-center gap-2',
className,
)}>
{!hideLabel && label &&
<label
className={clsx(
'flex flex-wrap gap-x-2 items-center select-none',
type === 'checkbox' && 'order-2 pt-[4px] ml-1',
)}
htmlFor={id}
>
{label}
{note && !error &&
<span className="text-gray-400 dark:text-gray-600">
({note})
</span>}
{isModified && !error &&
<span className={clsx(
'text-main font-medium text-[0.9rem]',
' -ml-1.5 translate-y-[-1px]',
)}>
*
</span>}
{error &&
<span className="text-error">
{error}
</span>}
{required &&
<span className="text-gray-400 dark:text-gray-600">
Required
</span>}
{loading &&
<span className="translate-y-[1.5px]">
<Spinner />
</span>}
</label>}
<div className="flex gap-2">
{selectOptions
? <div className="relative w-full">
<select
id={id}
name={id}
value={value}
placeholder={placeholder}
onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading}
spellCheck={spellCheck}
autoCapitalize={!capitalize ? 'off' : undefined}
className={clsx(
'w-full h-24 resize-none',
Boolean(error) && 'error',
'w-full',
clsx(Boolean(error) && 'error'),
// Use special class because `select` can't be readonly
readOnly || pending && 'disabled-select',
)}
/>
: <input
ref={inputRef}
>
{selectOptionsDefaultLabel &&
<option value="">{selectOptionsDefaultLabel}</option>}
{selectOptions.map(({
value: optionValue,
label: optionLabel,
}) =>
<option
key={optionValue}
value={optionValue}
>
{optionLabel}
</option>)}
</select>
<div className={clsx(
'absolute top-0 right-3 z-10 pointer-events-none',
'flex h-full items-center',
'text-extra-dim text-2xl',
)}>
<FiChevronDown />
</div>
</div>
: tagOptions
? <TagInput
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}
spellCheck={spellCheck}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
options={tagOptions}
onChange={onChange}
showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading}
disabled={type === 'checkbox' && (
readOnly || pending || loading
)}
className={clsx(
(
type === 'text' ||
type === 'email' ||
type === 'password'
) && 'w-full',
type === 'checkbox' && (
readOnly || pending || loading
) && 'opacity-50 cursor-not-allowed',
Boolean(error) && 'error',
)}
/>}
{accessory && <div>
{accessory}
</div>}
placeholder={placeholder}
limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage}
/>
: type === 'textarea'
? <textarea
id={id}
name={id}
value={value}
placeholder={placeholder}
onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading}
spellCheck={spellCheck}
autoCapitalize={!capitalize ? 'off' : undefined}
className={clsx(
'w-full h-24 resize-none',
Boolean(error) && 'error',
)}
/>
: type === 'checkbox' && checkboxAccessory
? <span className="w-[13px]">
{checkboxAccessory}
</span>
: renderInput}
{accessory && <div>
{accessory}
</div>}
</div>
</div>
</div>
);
};

View File

@ -60,16 +60,17 @@ import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
import { FilmSimulation } from '@/simulation';
// Private actions
export const createPhotoAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const shouldStripGpsData = formData.get('shouldStripGpsData') === 'true';
formData.delete('shouldStripGpsData');
const photo =
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData);
const photo = await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(
formData,
);
const updatedUrl = await convertUploadToPhoto({
urlOrigin: photo.url,
@ -308,9 +309,15 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
export const getPhotosNeedingRecipeTitleCountAction = async (
recipeData: string,
simulation: FilmSimulation,
photoIdToExclude?: string,
) =>
runAuthenticatedAdminServerAction(async () =>
await getPhotosNeedingRecipeTitleCount(recipeData),
await getPhotosNeedingRecipeTitleCount(
recipeData,
simulation,
photoIdToExclude,
),
);
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>

View File

@ -348,33 +348,47 @@ export const getUniqueRecipes = async () =>
})))
, 'getUniqueRecipes');
export const getRecipeTitleForData = async (data: string | object) =>
export const getRecipeTitleForData = async (
data: string | object,
simulation: FilmSimulation,
) =>
// Includes legacy check on pre-stringified JSON
safelyQueryPhotos(() => sql`
SELECT recipe_title FROM photos
WHERE hidden IS NOT TRUE AND
recipe_data = ${typeof data === 'string' ? data : JSON.stringify(data)}
WHERE hidden IS NOT TRUE
AND recipe_data=${typeof data === 'string' ? data : JSON.stringify(data)}
AND film_simulation=${simulation}
LIMIT 1
`
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
, 'getRecipeTitleForData');
export const getPhotosNeedingRecipeTitleCount = async (data: string) =>
export const getPhotosNeedingRecipeTitleCount = async (
data: string,
simulation: FilmSimulation,
photoIdToExclude?: string,
) =>
safelyQueryPhotos(() => sql`
SELECT COUNT(*)
FROM photos
WHERE recipe_title IS NULL AND recipe_data = ${data}
WHERE recipe_title IS NULL
AND recipe_data=${data}
AND film_simulation=${simulation}
AND id <> ${photoIdToExclude}
`.then(({ rows }) => parseInt(rows[0].count, 10))
, 'getPhotosNeedingRecipeTitleCount');
export const updateAllMatchingRecipeTitles = (
title: string,
data: string,
simulation: FilmSimulation,
) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET recipe_title = ${title}
WHERE recipe_title IS NULL AND recipe_data = ${data}
SET recipe_title=${title}
WHERE recipe_title IS NULL
AND recipe_data=${data}
AND film_simulation=${simulation}
`, 'updateAllMatchingRecipeTitles');
export const getUniqueFilmSimulations = async () =>

View File

@ -1,34 +1,59 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ComponentProps, useEffect, useState } from 'react';
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
import { FilmSimulation } from '@/simulation';
import Spinner from '@/components/Spinner';
export default function ApplyRecipeTitleGloballyCheckbox({
photoId,
recipeTitle,
hasRecipeTitleChanged,
recipeData,
simulation,
onMatchResults,
...props
}: ComponentProps<typeof FieldSetWithStatus> & {
photoId?: string
recipeTitle?: string
hasRecipeTitleChanged?: boolean
recipeData?: string
simulation?: FilmSimulation
onMatchResults: (didFindMatchingPhotos: boolean) => void
}) {
const [matchingPhotosCount, setMatchingPhotosCount] = useState<number>();
const isLoading = matchingPhotosCount === undefined;
useEffect(() => {
if (recipeTitle && hasRecipeTitleChanged && recipeData) {
if (recipeTitle && hasRecipeTitleChanged && recipeData && simulation) {
setMatchingPhotosCount(undefined);
getPhotosNeedingRecipeTitleCountAction(recipeData)
getPhotosNeedingRecipeTitleCountAction(recipeData, simulation, photoId)
.then(setMatchingPhotosCount);
} else {
setMatchingPhotosCount(0);
}
}, [recipeTitle, hasRecipeTitleChanged, recipeData]);
}, [recipeTitle, hasRecipeTitleChanged, recipeData, simulation, photoId]);
useEffect(() => {
onMatchResults((matchingPhotosCount ?? 0) > 0);
}, [matchingPhotosCount, onMatchResults]);
const shouldShowFieldSet = isLoading || matchingPhotosCount > 0;
return (
<FieldSetWithStatus {...{
...props,
label: `Apply title to ${matchingPhotosCount} photos`,
type: 'checkbox',
}} />
shouldShowFieldSet
? <FieldSetWithStatus {...{
...props,
label: isLoading
? 'Scanning photos for matching recipes ...'
: `Apply title to ${matchingPhotosCount} matching photos`,
type: 'checkbox',
readOnly: isLoading,
className: '-mt-4 translate-x-[1px]',
checkboxAccessory: isLoading
? <Spinner className="translate-y-[1.5px]" />
: null,
}} />
: null
);
}

View File

@ -1,6 +1,12 @@
'use client';
import { ComponentProps, useEffect, useMemo, useState } from 'react';
import {
ComponentProps,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
FIELDS_WITH_JSON,
FORM_METADATA_ENTRIES,
@ -35,6 +41,7 @@ import ErrorNote from '@/components/ErrorNote';
import { convertRecipesForForm, Recipes } from '@/recipe';
import deepEqual from 'fast-deep-equal/es6/react';
import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox';
import { FilmSimulation } from '@/simulation';
const THUMBNAIL_SIZE = 300;
@ -244,6 +251,15 @@ export default function PhotoForm({
}
};
const onMatchResults = useCallback((didFindMatchingPhotos: boolean) => {
setFormData(data => ({
...data,
applyRecipeTitleGlobally: didFindMatchingPhotos
? 'true'
: 'false',
}));
}, []);
return (
<div className="space-y-8 max-w-[38rem] relative">
<div className="flex gap-2">
@ -309,6 +325,7 @@ export default function PhotoForm({
convertTagsForForm(uniqueTags),
convertRecipesForForm(uniqueRecipes),
aiContent !== undefined,
shouldStripGpsData,
)
.map(([key, {
label,
@ -320,6 +337,7 @@ export default function PhotoForm({
tagOptionsLimit,
tagOptionsLimitValidationMessage,
readOnly,
hideModificationStatus,
validate,
validateStringMaxLength,
spellCheck,
@ -328,6 +346,7 @@ export default function PhotoForm({
shouldHide,
loadingMessage,
type,
staticValue,
}]) => {
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
@ -339,8 +358,11 @@ export default function PhotoForm({
),
note,
error: formErrors[key],
value: formData[key] ?? '',
isModified: changedFormKeys.includes(key),
value: staticValue ?? formData[key] ?? '',
isModified: (
!hideModificationStatus &&
changedFormKeys.includes(key)
),
onChange: value => {
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
@ -380,27 +402,28 @@ export default function PhotoForm({
type,
accessory: accessoryForField(key),
};
return key === 'applyRecipeTitleGlobally'
? <ApplyRecipeTitleGloballyCheckbox
switch (key) {
case 'applyRecipeTitleGlobally':
return <ApplyRecipeTitleGloballyCheckbox
key={key}
photoId={initialPhotoForm.id}
recipeTitle={formData.recipeTitle}
recipeData={formData.recipeData}
hasRecipeTitleChanged={
changedFormKeys.includes('recipeTitle')}
recipeData={formData.recipeData}
simulation={formData.filmSimulation as FilmSimulation}
onMatchResults={onMatchResults}
{...fieldProps}
/>
: <FieldSetWithStatus
/>;
default:
return <FieldSetWithStatus
key={key}
{...fieldProps}
/>;
}
}
})}
<input
type="hidden"
name="shouldStripGpsData"
value={shouldStripGpsData ? 'true' : 'false'}
readOnly
/>
</div>
{/* Actions */}
<div className={clsx(

View File

@ -27,7 +27,8 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
type VirtualFields =
'favorite' |
'applyRecipeTitleGlobally';
'applyRecipeTitleGlobally' |
'shouldStripGpsData';
export type FormFields = keyof PhotoDbInsert | VirtualFields;
@ -38,7 +39,8 @@ export type FieldSetType =
'email' |
'password' |
'checkbox' |
'textarea';
'textarea' |
'hidden';
export type AnnotatedTag = {
value: string,
@ -52,6 +54,7 @@ export type FormMeta = {
required?: boolean
excludeFromInsert?: boolean
readOnly?: boolean
hideModificationStatus?: boolean
validate?: (value?: string) => string | undefined
validateStringMaxLength?: number
spellCheck?: boolean
@ -70,6 +73,7 @@ export type FormMeta = {
tagOptionsLimitValidationMessage?: string
shouldNotOverwriteWithNullDataOnSync?: boolean
isJson?: boolean
staticValue?: string
};
const STRING_MAX_LENGTH_SHORT = 255;
@ -79,6 +83,7 @@ const FORM_METADATA = (
tagOptions?: AnnotatedTag[],
recipeOptions?: AnnotatedTag[],
aiTextGeneration?: boolean,
shouldStripGpsData?: boolean,
): Record<keyof PhotoFormData, FormMeta> => ({
title: {
label: 'title',
@ -134,6 +139,7 @@ const FORM_METADATA = (
label: 'apply recipe title globally',
type: 'checkbox',
excludeFromInsert: true,
hideModificationStatus: true,
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
!(
make === MAKE_FUJIFILM &&
@ -184,6 +190,12 @@ const FORM_METADATA = (
priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
hidden: { label: 'hidden', type: 'checkbox' },
shouldStripGpsData: {
label: 'strip gps data',
type: 'hidden',
excludeFromInsert: true,
staticValue: shouldStripGpsData ? 'true' : 'false',
},
});
export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA())

View File

@ -206,8 +206,11 @@ export const convertFormDataToPhotoDbInsertAndLookupRecipeTitle =
Promise<ReturnType<typeof convertFormDataToPhotoDbInsert>> => {
const photo = convertFormDataToPhotoDbInsert(...args);
if (photo.recipeData && !photo.recipeTitle) {
const recipeTitle = await getRecipeTitleForData(photo.recipeData);
if (photo.recipeData && !photo.recipeTitle && photo.filmSimulation) {
const recipeTitle = await getRecipeTitleForData(
photo.recipeData,
photo.filmSimulation,
);
if (recipeTitle) {
photo.recipeTitle = recipeTitle;
}
@ -221,14 +224,17 @@ export const propagateRecipeTitleIfNecessary = async (
photo: PhotoDbInsert,
) => {
if (
formData.get('applyRecipeTitleGlobally') === 'true' &&
// Only propagate recipe title if set by user before lookup
formData.get('recipeTitle') &&
photo.recipeTitle &&
photo.recipeData
photo.recipeData &&
photo.filmSimulation
) {
await updateAllMatchingRecipeTitles(
photo.recipeTitle,
photo.recipeData,
photo.filmSimulation,
);
}
};