Refine recipe scanning ux
This commit is contained in:
parent
8b6ea0da6d
commit
ee050b550e
@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import ErrorNote from '@/components/ErrorNote';
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
import { getRecipeTitleForData } from '@/photo/db/query';
|
import { getRecipeTitleForData } from '@/photo/db/query';
|
||||||
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@ -48,8 +49,11 @@ export default async function UploadPage({ params }: Params) {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUniqueTagsCached(),
|
getUniqueTagsCached(),
|
||||||
getUniqueRecipesCached(),
|
getUniqueRecipesCached(),
|
||||||
formDataFromExif?.recipeData
|
formDataFromExif?.recipeData && formDataFromExif.filmSimulation
|
||||||
? getRecipeTitleForData(formDataFromExif.recipeData)
|
? getRecipeTitleForData(
|
||||||
|
formDataFromExif.recipeData,
|
||||||
|
formDataFromExif.filmSimulation as FilmSimulation,
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export default function FieldSetWithStatus({
|
|||||||
value,
|
value,
|
||||||
isModified,
|
isModified,
|
||||||
onChange,
|
onChange,
|
||||||
|
className,
|
||||||
selectOptions,
|
selectOptions,
|
||||||
selectOptionsDefaultLabel,
|
selectOptionsDefaultLabel,
|
||||||
tagOptions,
|
tagOptions,
|
||||||
@ -31,6 +32,7 @@ export default function FieldSetWithStatus({
|
|||||||
inputRef,
|
inputRef,
|
||||||
accessory,
|
accessory,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
|
checkboxAccessory,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string
|
||||||
label?: string
|
label?: string
|
||||||
@ -39,6 +41,7 @@ export default function FieldSetWithStatus({
|
|||||||
value: string
|
value: string
|
||||||
isModified?: boolean
|
isModified?: boolean
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
|
className?: string
|
||||||
selectOptions?: { value: string, label: string }[]
|
selectOptions?: { value: string, label: string }[]
|
||||||
selectOptionsDefaultLabel?: string
|
selectOptionsDefaultLabel?: string
|
||||||
tagOptions?: AnnotatedTag[]
|
tagOptions?: AnnotatedTag[]
|
||||||
@ -54,19 +57,55 @@ export default function FieldSetWithStatus({
|
|||||||
inputRef?: Ref<HTMLInputElement>
|
inputRef?: Ref<HTMLInputElement>
|
||||||
accessory?: React.ReactNode
|
accessory?: React.ReactNode
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
|
checkboxAccessory?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { pending } = useFormStatus();
|
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 (
|
return (
|
||||||
<div className={clsx(
|
type === 'hidden'
|
||||||
|
? renderInput
|
||||||
|
: <div className={clsx(
|
||||||
'space-y-1',
|
'space-y-1',
|
||||||
type === 'checkbox' && 'flex items-center gap-2',
|
type === 'checkbox' && 'flex items-center gap-2',
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
{!hideLabel && label &&
|
{!hideLabel && label &&
|
||||||
<label
|
<label
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex flex-wrap gap-x-2 items-center select-none',
|
'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}
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
@ -77,7 +116,8 @@ export default function FieldSetWithStatus({
|
|||||||
</span>}
|
</span>}
|
||||||
{isModified && !error &&
|
{isModified && !error &&
|
||||||
<span className={clsx(
|
<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>}
|
</span>}
|
||||||
@ -111,7 +151,10 @@ export default function FieldSetWithStatus({
|
|||||||
>
|
>
|
||||||
{selectOptionsDefaultLabel &&
|
{selectOptionsDefaultLabel &&
|
||||||
<option value="">{selectOptionsDefaultLabel}</option>}
|
<option value="">{selectOptionsDefaultLabel}</option>}
|
||||||
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
|
{selectOptions.map(({
|
||||||
|
value: optionValue,
|
||||||
|
label: optionLabel,
|
||||||
|
}) =>
|
||||||
<option
|
<option
|
||||||
key={optionValue}
|
key={optionValue}
|
||||||
value={optionValue}
|
value={optionValue}
|
||||||
@ -156,36 +199,11 @@ export default function FieldSetWithStatus({
|
|||||||
Boolean(error) && 'error',
|
Boolean(error) && 'error',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
: <input
|
: type === 'checkbox' && checkboxAccessory
|
||||||
ref={inputRef}
|
? <span className="w-[13px]">
|
||||||
id={id}
|
{checkboxAccessory}
|
||||||
name={id}
|
</span>
|
||||||
value={value}
|
: renderInput}
|
||||||
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',
|
|
||||||
)}
|
|
||||||
/>}
|
|
||||||
{accessory && <div>
|
{accessory && <div>
|
||||||
{accessory}
|
{accessory}
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@ -60,16 +60,17 @@ import { convertUploadToPhoto } from './storage';
|
|||||||
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
|
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
|
||||||
import { convertStringToArray } from '@/utility/string';
|
import { convertStringToArray } from '@/utility/string';
|
||||||
import { after } from 'next/server';
|
import { after } from 'next/server';
|
||||||
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
|
||||||
// Private actions
|
// Private actions
|
||||||
|
|
||||||
export const createPhotoAction = async (formData: FormData) =>
|
export const createPhotoAction = async (formData: FormData) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const shouldStripGpsData = formData.get('shouldStripGpsData') === 'true';
|
const shouldStripGpsData = formData.get('shouldStripGpsData') === 'true';
|
||||||
formData.delete('shouldStripGpsData');
|
|
||||||
|
|
||||||
const photo =
|
const photo = await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(
|
||||||
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData);
|
formData,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto({
|
const updatedUrl = await convertUploadToPhoto({
|
||||||
urlOrigin: photo.url,
|
urlOrigin: photo.url,
|
||||||
@ -308,9 +309,15 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
|
|||||||
|
|
||||||
export const getPhotosNeedingRecipeTitleCountAction = async (
|
export const getPhotosNeedingRecipeTitleCountAction = async (
|
||||||
recipeData: string,
|
recipeData: string,
|
||||||
|
simulation: FilmSimulation,
|
||||||
|
photoIdToExclude?: string,
|
||||||
) =>
|
) =>
|
||||||
runAuthenticatedAdminServerAction(async () =>
|
runAuthenticatedAdminServerAction(async () =>
|
||||||
await getPhotosNeedingRecipeTitleCount(recipeData),
|
await getPhotosNeedingRecipeTitleCount(
|
||||||
|
recipeData,
|
||||||
|
simulation,
|
||||||
|
photoIdToExclude,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
|
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
|
||||||
|
|||||||
@ -348,33 +348,47 @@ export const getUniqueRecipes = async () =>
|
|||||||
})))
|
})))
|
||||||
, 'getUniqueRecipes');
|
, 'getUniqueRecipes');
|
||||||
|
|
||||||
export const getRecipeTitleForData = async (data: string | object) =>
|
export const getRecipeTitleForData = async (
|
||||||
|
data: string | object,
|
||||||
|
simulation: FilmSimulation,
|
||||||
|
) =>
|
||||||
// Includes legacy check on pre-stringified JSON
|
// Includes legacy check on pre-stringified JSON
|
||||||
safelyQueryPhotos(() => sql`
|
safelyQueryPhotos(() => sql`
|
||||||
SELECT recipe_title FROM photos
|
SELECT recipe_title FROM photos
|
||||||
WHERE hidden IS NOT TRUE AND
|
WHERE hidden IS NOT TRUE
|
||||||
recipe_data = ${typeof data === 'string' ? data : JSON.stringify(data)}
|
AND recipe_data=${typeof data === 'string' ? data : JSON.stringify(data)}
|
||||||
|
AND film_simulation=${simulation}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
|
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
|
||||||
, 'getRecipeTitleForData');
|
, 'getRecipeTitleForData');
|
||||||
|
|
||||||
export const getPhotosNeedingRecipeTitleCount = async (data: string) =>
|
export const getPhotosNeedingRecipeTitleCount = async (
|
||||||
|
data: string,
|
||||||
|
simulation: FilmSimulation,
|
||||||
|
photoIdToExclude?: string,
|
||||||
|
) =>
|
||||||
safelyQueryPhotos(() => sql`
|
safelyQueryPhotos(() => sql`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM photos
|
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))
|
`.then(({ rows }) => parseInt(rows[0].count, 10))
|
||||||
, 'getPhotosNeedingRecipeTitleCount');
|
, 'getPhotosNeedingRecipeTitleCount');
|
||||||
|
|
||||||
export const updateAllMatchingRecipeTitles = (
|
export const updateAllMatchingRecipeTitles = (
|
||||||
title: string,
|
title: string,
|
||||||
data: string,
|
data: string,
|
||||||
|
simulation: FilmSimulation,
|
||||||
) =>
|
) =>
|
||||||
safelyQueryPhotos(() => sql`
|
safelyQueryPhotos(() => sql`
|
||||||
UPDATE photos
|
UPDATE photos
|
||||||
SET recipe_title=${title}
|
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');
|
`, 'updateAllMatchingRecipeTitles');
|
||||||
|
|
||||||
export const getUniqueFilmSimulations = async () =>
|
export const getUniqueFilmSimulations = async () =>
|
||||||
|
|||||||
@ -1,34 +1,59 @@
|
|||||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
import { ComponentProps, useEffect, useState } from 'react';
|
import { ComponentProps, useEffect, useState } from 'react';
|
||||||
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
|
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
|
||||||
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
import Spinner from '@/components/Spinner';
|
||||||
|
|
||||||
export default function ApplyRecipeTitleGloballyCheckbox({
|
export default function ApplyRecipeTitleGloballyCheckbox({
|
||||||
|
photoId,
|
||||||
recipeTitle,
|
recipeTitle,
|
||||||
hasRecipeTitleChanged,
|
hasRecipeTitleChanged,
|
||||||
recipeData,
|
recipeData,
|
||||||
|
simulation,
|
||||||
|
onMatchResults,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<typeof FieldSetWithStatus> & {
|
}: ComponentProps<typeof FieldSetWithStatus> & {
|
||||||
|
photoId?: string
|
||||||
recipeTitle?: string
|
recipeTitle?: string
|
||||||
hasRecipeTitleChanged?: boolean
|
hasRecipeTitleChanged?: boolean
|
||||||
recipeData?: string
|
recipeData?: string
|
||||||
|
simulation?: FilmSimulation
|
||||||
|
onMatchResults: (didFindMatchingPhotos: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const [matchingPhotosCount, setMatchingPhotosCount] = useState<number>();
|
const [matchingPhotosCount, setMatchingPhotosCount] = useState<number>();
|
||||||
|
|
||||||
|
const isLoading = matchingPhotosCount === undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recipeTitle && hasRecipeTitleChanged && recipeData) {
|
if (recipeTitle && hasRecipeTitleChanged && recipeData && simulation) {
|
||||||
setMatchingPhotosCount(undefined);
|
setMatchingPhotosCount(undefined);
|
||||||
getPhotosNeedingRecipeTitleCountAction(recipeData)
|
getPhotosNeedingRecipeTitleCountAction(recipeData, simulation, photoId)
|
||||||
.then(setMatchingPhotosCount);
|
.then(setMatchingPhotosCount);
|
||||||
} else {
|
} else {
|
||||||
setMatchingPhotosCount(0);
|
setMatchingPhotosCount(0);
|
||||||
}
|
}
|
||||||
}, [recipeTitle, hasRecipeTitleChanged, recipeData]);
|
}, [recipeTitle, hasRecipeTitleChanged, recipeData, simulation, photoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMatchResults((matchingPhotosCount ?? 0) > 0);
|
||||||
|
}, [matchingPhotosCount, onMatchResults]);
|
||||||
|
|
||||||
|
const shouldShowFieldSet = isLoading || matchingPhotosCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSetWithStatus {...{
|
shouldShowFieldSet
|
||||||
|
? <FieldSetWithStatus {...{
|
||||||
...props,
|
...props,
|
||||||
label: `Apply title to ${matchingPhotosCount} photos`,
|
label: isLoading
|
||||||
|
? 'Scanning photos for matching recipes ...'
|
||||||
|
: `Apply title to ${matchingPhotosCount} matching photos`,
|
||||||
type: 'checkbox',
|
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';
|
'use client';
|
||||||
|
|
||||||
import { ComponentProps, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
ComponentProps,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
FIELDS_WITH_JSON,
|
FIELDS_WITH_JSON,
|
||||||
FORM_METADATA_ENTRIES,
|
FORM_METADATA_ENTRIES,
|
||||||
@ -35,6 +41,7 @@ import ErrorNote from '@/components/ErrorNote';
|
|||||||
import { convertRecipesForForm, Recipes } from '@/recipe';
|
import { convertRecipesForForm, Recipes } from '@/recipe';
|
||||||
import deepEqual from 'fast-deep-equal/es6/react';
|
import deepEqual from 'fast-deep-equal/es6/react';
|
||||||
import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox';
|
import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox';
|
||||||
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
|
||||||
const THUMBNAIL_SIZE = 300;
|
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 (
|
return (
|
||||||
<div className="space-y-8 max-w-[38rem] relative">
|
<div className="space-y-8 max-w-[38rem] relative">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -309,6 +325,7 @@ export default function PhotoForm({
|
|||||||
convertTagsForForm(uniqueTags),
|
convertTagsForForm(uniqueTags),
|
||||||
convertRecipesForForm(uniqueRecipes),
|
convertRecipesForForm(uniqueRecipes),
|
||||||
aiContent !== undefined,
|
aiContent !== undefined,
|
||||||
|
shouldStripGpsData,
|
||||||
)
|
)
|
||||||
.map(([key, {
|
.map(([key, {
|
||||||
label,
|
label,
|
||||||
@ -320,6 +337,7 @@ export default function PhotoForm({
|
|||||||
tagOptionsLimit,
|
tagOptionsLimit,
|
||||||
tagOptionsLimitValidationMessage,
|
tagOptionsLimitValidationMessage,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
hideModificationStatus,
|
||||||
validate,
|
validate,
|
||||||
validateStringMaxLength,
|
validateStringMaxLength,
|
||||||
spellCheck,
|
spellCheck,
|
||||||
@ -328,6 +346,7 @@ export default function PhotoForm({
|
|||||||
shouldHide,
|
shouldHide,
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
type,
|
type,
|
||||||
|
staticValue,
|
||||||
}]) => {
|
}]) => {
|
||||||
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
|
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
|
||||||
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
|
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
|
||||||
@ -339,8 +358,11 @@ export default function PhotoForm({
|
|||||||
),
|
),
|
||||||
note,
|
note,
|
||||||
error: formErrors[key],
|
error: formErrors[key],
|
||||||
value: formData[key] ?? '',
|
value: staticValue ?? formData[key] ?? '',
|
||||||
isModified: changedFormKeys.includes(key),
|
isModified: (
|
||||||
|
!hideModificationStatus &&
|
||||||
|
changedFormKeys.includes(key)
|
||||||
|
),
|
||||||
onChange: value => {
|
onChange: value => {
|
||||||
const formUpdated = { ...formData, [key]: value };
|
const formUpdated = { ...formData, [key]: value };
|
||||||
setFormData(formUpdated);
|
setFormData(formUpdated);
|
||||||
@ -380,27 +402,28 @@ export default function PhotoForm({
|
|||||||
type,
|
type,
|
||||||
accessory: accessoryForField(key),
|
accessory: accessoryForField(key),
|
||||||
};
|
};
|
||||||
return key === 'applyRecipeTitleGlobally'
|
|
||||||
? <ApplyRecipeTitleGloballyCheckbox
|
switch (key) {
|
||||||
|
case 'applyRecipeTitleGlobally':
|
||||||
|
return <ApplyRecipeTitleGloballyCheckbox
|
||||||
key={key}
|
key={key}
|
||||||
|
photoId={initialPhotoForm.id}
|
||||||
recipeTitle={formData.recipeTitle}
|
recipeTitle={formData.recipeTitle}
|
||||||
recipeData={formData.recipeData}
|
|
||||||
hasRecipeTitleChanged={
|
hasRecipeTitleChanged={
|
||||||
changedFormKeys.includes('recipeTitle')}
|
changedFormKeys.includes('recipeTitle')}
|
||||||
|
recipeData={formData.recipeData}
|
||||||
|
simulation={formData.filmSimulation as FilmSimulation}
|
||||||
|
onMatchResults={onMatchResults}
|
||||||
{...fieldProps}
|
{...fieldProps}
|
||||||
/>
|
/>;
|
||||||
: <FieldSetWithStatus
|
default:
|
||||||
|
return <FieldSetWithStatus
|
||||||
key={key}
|
key={key}
|
||||||
{...fieldProps}
|
{...fieldProps}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="shouldStripGpsData"
|
|
||||||
value={shouldStripGpsData ? 'true' : 'false'}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
|
|||||||
@ -27,7 +27,8 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
|||||||
|
|
||||||
type VirtualFields =
|
type VirtualFields =
|
||||||
'favorite' |
|
'favorite' |
|
||||||
'applyRecipeTitleGlobally';
|
'applyRecipeTitleGlobally' |
|
||||||
|
'shouldStripGpsData';
|
||||||
|
|
||||||
export type FormFields = keyof PhotoDbInsert | VirtualFields;
|
export type FormFields = keyof PhotoDbInsert | VirtualFields;
|
||||||
|
|
||||||
@ -38,7 +39,8 @@ export type FieldSetType =
|
|||||||
'email' |
|
'email' |
|
||||||
'password' |
|
'password' |
|
||||||
'checkbox' |
|
'checkbox' |
|
||||||
'textarea';
|
'textarea' |
|
||||||
|
'hidden';
|
||||||
|
|
||||||
export type AnnotatedTag = {
|
export type AnnotatedTag = {
|
||||||
value: string,
|
value: string,
|
||||||
@ -52,6 +54,7 @@ export type FormMeta = {
|
|||||||
required?: boolean
|
required?: boolean
|
||||||
excludeFromInsert?: boolean
|
excludeFromInsert?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
hideModificationStatus?: boolean
|
||||||
validate?: (value?: string) => string | undefined
|
validate?: (value?: string) => string | undefined
|
||||||
validateStringMaxLength?: number
|
validateStringMaxLength?: number
|
||||||
spellCheck?: boolean
|
spellCheck?: boolean
|
||||||
@ -70,6 +73,7 @@ export type FormMeta = {
|
|||||||
tagOptionsLimitValidationMessage?: string
|
tagOptionsLimitValidationMessage?: string
|
||||||
shouldNotOverwriteWithNullDataOnSync?: boolean
|
shouldNotOverwriteWithNullDataOnSync?: boolean
|
||||||
isJson?: boolean
|
isJson?: boolean
|
||||||
|
staticValue?: string
|
||||||
};
|
};
|
||||||
|
|
||||||
const STRING_MAX_LENGTH_SHORT = 255;
|
const STRING_MAX_LENGTH_SHORT = 255;
|
||||||
@ -79,6 +83,7 @@ const FORM_METADATA = (
|
|||||||
tagOptions?: AnnotatedTag[],
|
tagOptions?: AnnotatedTag[],
|
||||||
recipeOptions?: AnnotatedTag[],
|
recipeOptions?: AnnotatedTag[],
|
||||||
aiTextGeneration?: boolean,
|
aiTextGeneration?: boolean,
|
||||||
|
shouldStripGpsData?: boolean,
|
||||||
): Record<keyof PhotoFormData, FormMeta> => ({
|
): Record<keyof PhotoFormData, FormMeta> => ({
|
||||||
title: {
|
title: {
|
||||||
label: 'title',
|
label: 'title',
|
||||||
@ -134,6 +139,7 @@ const FORM_METADATA = (
|
|||||||
label: 'apply recipe title globally',
|
label: 'apply recipe title globally',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
excludeFromInsert: true,
|
excludeFromInsert: true,
|
||||||
|
hideModificationStatus: true,
|
||||||
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
|
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
|
||||||
!(
|
!(
|
||||||
make === MAKE_FUJIFILM &&
|
make === MAKE_FUJIFILM &&
|
||||||
@ -184,6 +190,12 @@ const FORM_METADATA = (
|
|||||||
priorityOrder: { label: 'priority order' },
|
priorityOrder: { label: 'priority order' },
|
||||||
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
|
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
|
||||||
hidden: { label: 'hidden', type: 'checkbox' },
|
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())
|
export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA())
|
||||||
|
|||||||
@ -206,8 +206,11 @@ export const convertFormDataToPhotoDbInsertAndLookupRecipeTitle =
|
|||||||
Promise<ReturnType<typeof convertFormDataToPhotoDbInsert>> => {
|
Promise<ReturnType<typeof convertFormDataToPhotoDbInsert>> => {
|
||||||
const photo = convertFormDataToPhotoDbInsert(...args);
|
const photo = convertFormDataToPhotoDbInsert(...args);
|
||||||
|
|
||||||
if (photo.recipeData && !photo.recipeTitle) {
|
if (photo.recipeData && !photo.recipeTitle && photo.filmSimulation) {
|
||||||
const recipeTitle = await getRecipeTitleForData(photo.recipeData);
|
const recipeTitle = await getRecipeTitleForData(
|
||||||
|
photo.recipeData,
|
||||||
|
photo.filmSimulation,
|
||||||
|
);
|
||||||
if (recipeTitle) {
|
if (recipeTitle) {
|
||||||
photo.recipeTitle = recipeTitle;
|
photo.recipeTitle = recipeTitle;
|
||||||
}
|
}
|
||||||
@ -221,14 +224,17 @@ export const propagateRecipeTitleIfNecessary = async (
|
|||||||
photo: PhotoDbInsert,
|
photo: PhotoDbInsert,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
|
formData.get('applyRecipeTitleGlobally') === 'true' &&
|
||||||
// Only propagate recipe title if set by user before lookup
|
// Only propagate recipe title if set by user before lookup
|
||||||
formData.get('recipeTitle') &&
|
formData.get('recipeTitle') &&
|
||||||
photo.recipeTitle &&
|
photo.recipeTitle &&
|
||||||
photo.recipeData
|
photo.recipeData &&
|
||||||
|
photo.filmSimulation
|
||||||
) {
|
) {
|
||||||
await updateAllMatchingRecipeTitles(
|
await updateAllMatchingRecipeTitles(
|
||||||
photo.recipeTitle,
|
photo.recipeTitle,
|
||||||
photo.recipeData,
|
photo.recipeData,
|
||||||
|
photo.filmSimulation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user