Refine recipe scanning ux
This commit is contained in:
parent
8b6ea0da6d
commit
ee050b550e
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@ -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,19 +57,55 @@ 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(
|
||||
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-[3px]',
|
||||
type === 'checkbox' && 'order-2 pt-[4px] ml-1',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
@ -77,7 +116,8 @@ export default function FieldSetWithStatus({
|
||||
</span>}
|
||||
{isModified && !error &&
|
||||
<span className={clsx(
|
||||
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]',
|
||||
'text-main font-medium text-[0.9rem]',
|
||||
' -ml-1.5 translate-y-[-1px]',
|
||||
)}>
|
||||
*
|
||||
</span>}
|
||||
@ -111,7 +151,10 @@ export default function FieldSetWithStatus({
|
||||
>
|
||||
{selectOptionsDefaultLabel &&
|
||||
<option value="">{selectOptionsDefaultLabel}</option>}
|
||||
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
|
||||
{selectOptions.map(({
|
||||
value: optionValue,
|
||||
label: optionLabel,
|
||||
}) =>
|
||||
<option
|
||||
key={optionValue}
|
||||
value={optionValue}
|
||||
@ -156,36 +199,11 @@ export default function FieldSetWithStatus({
|
||||
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}
|
||||
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',
|
||||
)}
|
||||
/>}
|
||||
: type === 'checkbox' && checkboxAccessory
|
||||
? <span className="w-[13px]">
|
||||
{checkboxAccessory}
|
||||
</span>
|
||||
: renderInput}
|
||||
{accessory && <div>
|
||||
{accessory}
|
||||
</div>}
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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}
|
||||
WHERE recipe_title IS NULL
|
||||
AND recipe_data=${data}
|
||||
AND film_simulation=${simulation}
|
||||
`, 'updateAllMatchingRecipeTitles');
|
||||
|
||||
export const getUniqueFilmSimulations = async () =>
|
||||
|
||||
@ -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 {...{
|
||||
shouldShowFieldSet
|
||||
? <FieldSetWithStatus {...{
|
||||
...props,
|
||||
label: `Apply title to ${matchingPhotosCount} photos`,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user