Allow users to opt out of global recipe titling
This commit is contained in:
parent
3b39a1b62e
commit
8b6ea0da6d
@ -12,6 +12,7 @@ import {
|
||||
getUniqueTags,
|
||||
deletePhotoRecipeGlobally,
|
||||
renamePhotoRecipeGlobally,
|
||||
getPhotosNeedingRecipeTitleCount,
|
||||
} from '@/photo/db/query';
|
||||
import { GetPhotosOptions, areOptionsSensitive } from './db';
|
||||
import {
|
||||
@ -305,6 +306,13 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
|
||||
}
|
||||
});
|
||||
|
||||
export const getPhotosNeedingRecipeTitleCountAction = async (
|
||||
recipeData: string,
|
||||
) =>
|
||||
runAuthenticatedAdminServerAction(async () =>
|
||||
await getPhotosNeedingRecipeTitleCount(recipeData),
|
||||
);
|
||||
|
||||
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const recipe = formData.get('recipe') as string;
|
||||
|
||||
@ -359,6 +359,14 @@ export const getRecipeTitleForData = async (data: string | object) =>
|
||||
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
|
||||
, 'getRecipeTitleForData');
|
||||
|
||||
export const getPhotosNeedingRecipeTitleCount = async (data: string) =>
|
||||
safelyQueryPhotos(() => sql`
|
||||
SELECT COUNT(*)
|
||||
FROM photos
|
||||
WHERE recipe_title IS NULL AND recipe_data = ${data}
|
||||
`.then(({ rows }) => parseInt(rows[0].count, 10))
|
||||
, 'getPhotosNeedingRecipeTitleCount');
|
||||
|
||||
export const updateAllMatchingRecipeTitles = (
|
||||
title: string,
|
||||
data: string,
|
||||
|
||||
34
src/photo/form/ApplyRecipesGloballyCheckbox.tsx
Normal file
34
src/photo/form/ApplyRecipesGloballyCheckbox.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
|
||||
|
||||
export default function ApplyRecipeTitleGloballyCheckbox({
|
||||
recipeTitle,
|
||||
hasRecipeTitleChanged,
|
||||
recipeData,
|
||||
...props
|
||||
}: ComponentProps<typeof FieldSetWithStatus> & {
|
||||
recipeTitle?: string
|
||||
hasRecipeTitleChanged?: boolean
|
||||
recipeData?: string
|
||||
}) {
|
||||
const [matchingPhotosCount, setMatchingPhotosCount] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (recipeTitle && hasRecipeTitleChanged && recipeData) {
|
||||
setMatchingPhotosCount(undefined);
|
||||
getPhotosNeedingRecipeTitleCountAction(recipeData)
|
||||
.then(setMatchingPhotosCount);
|
||||
} else {
|
||||
setMatchingPhotosCount(0);
|
||||
}
|
||||
}, [recipeTitle, hasRecipeTitleChanged, recipeData]);
|
||||
|
||||
return (
|
||||
<FieldSetWithStatus {...{
|
||||
...props,
|
||||
label: `Apply title to ${matchingPhotosCount} photos`,
|
||||
type: 'checkbox',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComponentProps, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FIELDS_WITH_JSON,
|
||||
FORM_METADATA_ENTRIES,
|
||||
FormFields,
|
||||
FormMeta,
|
||||
PhotoFormData,
|
||||
convertFormKeysToLabels,
|
||||
formHasTextContent,
|
||||
@ -29,10 +31,10 @@ import { useAppState } from '@/state/AppState';
|
||||
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
||||
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
||||
import { PhotoDbInsert } from '..';
|
||||
import ErrorNote from '@/components/ErrorNote';
|
||||
import { convertRecipesForForm, Recipes } from '@/recipe';
|
||||
import deepEqual from 'fast-deep-equal/es6/react';
|
||||
import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -223,9 +225,9 @@ export default function PhotoForm({
|
||||
};
|
||||
|
||||
const shouldHideField = (
|
||||
key: keyof PhotoDbInsert | 'favorite',
|
||||
key: FormFields,
|
||||
hideIfEmpty?: boolean,
|
||||
shouldHide?: (formData: Partial<PhotoFormData>) => boolean,
|
||||
shouldHide?: FormMeta['shouldHide'],
|
||||
) => {
|
||||
if (
|
||||
key === 'blurData' &&
|
||||
@ -237,7 +239,7 @@ export default function PhotoForm({
|
||||
} else {
|
||||
return (
|
||||
(hideIfEmpty && !formData[key]) ||
|
||||
shouldHide?.(formData)
|
||||
shouldHide?.(formData, changedFormKeys)
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -326,25 +328,27 @@ export default function PhotoForm({
|
||||
shouldHide,
|
||||
loadingMessage,
|
||||
type,
|
||||
}]) =>
|
||||
!shouldHideField(key, hideIfEmpty, shouldHide) &&
|
||||
<FieldSetWithStatus
|
||||
key={key}
|
||||
id={key}
|
||||
label={label + (
|
||||
}]) => {
|
||||
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
|
||||
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
|
||||
id: key,
|
||||
label: label + (
|
||||
key === 'blurData' && shouldDebugImageFallbacks
|
||||
? ` (${(formData[key] ?? '').length} chars.)`
|
||||
: ''
|
||||
)}
|
||||
note={note}
|
||||
error={formErrors[key]}
|
||||
value={formData[key] ?? ''}
|
||||
isModified={changedFormKeys.includes(key)}
|
||||
onChange={value => {
|
||||
),
|
||||
note,
|
||||
error: formErrors[key],
|
||||
value: formData[key] ?? '',
|
||||
isModified: changedFormKeys.includes(key),
|
||||
onChange: value => {
|
||||
const formUpdated = { ...formData, [key]: value };
|
||||
setFormData(formUpdated);
|
||||
if (validate) {
|
||||
setFormErrors({ ...formErrors, [key]: validate(value) });
|
||||
setFormErrors({
|
||||
...formErrors, [key]:
|
||||
validate(value),
|
||||
});
|
||||
} else if (validateStringMaxLength !== undefined) {
|
||||
setFormErrors({
|
||||
...formErrors,
|
||||
@ -356,26 +360,41 @@ export default function PhotoForm({
|
||||
if (key === 'title') {
|
||||
onTitleChange?.(value.trim());
|
||||
}
|
||||
}}
|
||||
selectOptions={selectOptions}
|
||||
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
|
||||
tagOptions={tagOptions}
|
||||
tagOptionsLimit={tagOptionsLimit}
|
||||
// eslint-disable-next-line max-len
|
||||
tagOptionsLimitValidationMessage={tagOptionsLimitValidationMessage}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
spellCheck={spellCheck}
|
||||
capitalize={capitalize}
|
||||
placeholder={loadingMessage && !formData[key]
|
||||
},
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel: selectOptionsDefaultLabel,
|
||||
tagOptions,
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
required,
|
||||
readOnly,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
placeholder: loadingMessage && !formData[key]
|
||||
? loadingMessage
|
||||
: undefined}
|
||||
loading={
|
||||
: undefined,
|
||||
loading: (
|
||||
(loadingMessage && !formData[key] ? true : false) ||
|
||||
isFieldGeneratingAi(key)}
|
||||
type={type}
|
||||
accessory={accessoryForField(key)}
|
||||
/>)}
|
||||
isFieldGeneratingAi(key)
|
||||
),
|
||||
type,
|
||||
accessory: accessoryForField(key),
|
||||
};
|
||||
return key === 'applyRecipeTitleGlobally'
|
||||
? <ApplyRecipeTitleGloballyCheckbox
|
||||
key={key}
|
||||
recipeTitle={formData.recipeTitle}
|
||||
recipeData={formData.recipeData}
|
||||
hasRecipeTitleChanged={
|
||||
changedFormKeys.includes('recipeTitle')}
|
||||
{...fieldProps}
|
||||
/>
|
||||
: <FieldSetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
}
|
||||
})}
|
||||
<input
|
||||
type="hidden"
|
||||
name="shouldStripGpsData"
|
||||
|
||||
@ -25,9 +25,13 @@ import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
||||
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||
|
||||
type VirtualFields = 'favorite';
|
||||
type VirtualFields =
|
||||
'favorite' |
|
||||
'applyRecipeTitleGlobally';
|
||||
|
||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
|
||||
export type FormFields = keyof PhotoDbInsert | VirtualFields;
|
||||
|
||||
export type PhotoFormData = Record<FormFields, string>
|
||||
|
||||
export type FieldSetType =
|
||||
'text' |
|
||||
@ -42,7 +46,7 @@ export type AnnotatedTag = {
|
||||
annotationAria?: string,
|
||||
};
|
||||
|
||||
type FormMeta = {
|
||||
export type FormMeta = {
|
||||
label: string
|
||||
note?: string
|
||||
required?: boolean
|
||||
@ -52,9 +56,11 @@ type FormMeta = {
|
||||
validateStringMaxLength?: number
|
||||
spellCheck?: boolean
|
||||
capitalize?: boolean
|
||||
hide?: boolean
|
||||
hideIfEmpty?: boolean
|
||||
shouldHide?: (formData: Partial<PhotoFormData>) => boolean
|
||||
shouldHide?: (
|
||||
formData: Partial<PhotoFormData>,
|
||||
changedFormKeys?: (keyof PhotoFormData)[],
|
||||
) => boolean
|
||||
loadingMessage?: string
|
||||
type?: FieldSetType
|
||||
selectOptions?: { value: string, label: string }[]
|
||||
@ -96,7 +102,7 @@ const FORM_METADATA = (
|
||||
label: 'semantic description (not visible)',
|
||||
capitalize: true,
|
||||
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
||||
hide: !aiTextGeneration,
|
||||
shouldHide: () => !aiTextGeneration,
|
||||
},
|
||||
id: { label: 'id', readOnly: true, hideIfEmpty: true },
|
||||
blurData: {
|
||||
@ -124,6 +130,18 @@ const FORM_METADATA = (
|
||||
capitalize: false,
|
||||
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
||||
},
|
||||
applyRecipeTitleGlobally: {
|
||||
label: 'apply recipe title globally',
|
||||
type: 'checkbox',
|
||||
excludeFromInsert: true,
|
||||
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
|
||||
!(
|
||||
make === MAKE_FUJIFILM &&
|
||||
recipeData &&
|
||||
recipeTitle &&
|
||||
changedFormKeys?.includes('recipeTitle')
|
||||
),
|
||||
},
|
||||
recipeData: {
|
||||
type: 'textarea',
|
||||
label: 'recipe data',
|
||||
@ -152,7 +170,7 @@ const FORM_METADATA = (
|
||||
iso: { label: 'ISO' },
|
||||
exposureTime: { label: 'exposure time' },
|
||||
exposureCompensation: { label: 'exposure compensation' },
|
||||
locationName: { label: 'location name', hide: true },
|
||||
locationName: { label: 'location name', shouldHide: () => true },
|
||||
latitude: { label: 'latitude' },
|
||||
longitude: { label: 'longitude' },
|
||||
takenAt: {
|
||||
@ -180,8 +198,7 @@ export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC =
|
||||
export const FORM_METADATA_ENTRIES = (
|
||||
...args: Parameters<typeof FORM_METADATA>
|
||||
) =>
|
||||
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][])
|
||||
.filter(([_, meta]) => !meta.hide);
|
||||
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
|
||||
|
||||
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
|
||||
keys.map(key => FORM_METADATA()[key].label.toUpperCase());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user