Allow editing recipe data, protect manually configured fuji fields

This commit is contained in:
Sam Becker 2025-02-24 17:28:37 -06:00
parent 2b44a5fa04
commit 541c09c551
8 changed files with 72 additions and 33 deletions

View File

@ -21,7 +21,7 @@ export default async function UploadPage({ params }: Params) {
const { const {
blobId, blobId,
photoFormExif, formDataFromExif,
imageResizedBase64: imageThumbnailBase64, imageResizedBase64: imageThumbnailBase64,
shouldStripGpsData, shouldStripGpsData,
error, error,
@ -32,7 +32,7 @@ export default async function UploadPage({ params }: Params) {
}); });
const isDataMissing = const isDataMissing =
!photoFormExif || !formDataFromExif ||
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64); (AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64);
if (isDataMissing && !error) { if (isDataMissing && !error) {
@ -50,7 +50,7 @@ export default async function UploadPage({ params }: Params) {
!isDataMissing !isDataMissing
? <UploadPageClient {...{ ? <UploadPageClient {...{
blobId, blobId,
photoFormExif, formDataFromExif,
uniqueTags, uniqueTags,
hasAiTextGeneration, hasAiTextGeneration,
textFieldsToAutoGenerate, textFieldsToAutoGenerate,

View File

@ -23,6 +23,7 @@ export default function FieldSetWithStatus({
loading, loading,
required, required,
readOnly, readOnly,
spellCheck,
capitalize, capitalize,
type = 'text', type = 'text',
inputRef, inputRef,
@ -43,6 +44,7 @@ export default function FieldSetWithStatus({
loading?: boolean loading?: boolean
required?: boolean required?: boolean
readOnly?: boolean readOnly?: boolean
spellCheck?: boolean
capitalize?: boolean capitalize?: boolean
type?: FieldSetType type?: FieldSetType
inputRef?: Ref<HTMLInputElement> inputRef?: Ref<HTMLInputElement>
@ -140,6 +142,8 @@ export default function FieldSetWithStatus({
placeholder={placeholder} placeholder={placeholder}
onChange={e => onChange?.(e.target.value)} onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading} readOnly={readOnly || pending || loading}
spellCheck={spellCheck}
autoCapitalize={!capitalize ? 'off' : undefined}
className={clsx( className={clsx(
'w-full h-24 resize-none', 'w-full h-24 resize-none',
Boolean(error) && 'error', Boolean(error) && 'error',
@ -156,6 +160,7 @@ export default function FieldSetWithStatus({
? e.target.value === 'true' ? 'false' : 'true' ? e.target.value === 'true' ? 'false' : 'true'
: e.target.value)} : e.target.value)}
type={type} type={type}
spellCheck={spellCheck}
autoComplete="off" autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined} autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending || loading} readOnly={readOnly || pending || loading}

View File

@ -307,12 +307,16 @@ export default function PhotoLarge({
<li>{photo.isoFormatted}</li> <li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li> <li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul> </ul>
{showSimulation && photo.filmSimulation && {(
(showSimulation && photo.filmSimulation)
|| photo.fujifilmRecipe
) &&
<div className="flex items-center gap-2 *:w-auto"> <div className="flex items-center gap-2 *:w-auto">
{showSimulation && photo.filmSimulation &&
<PhotoFilmSimulation <PhotoFilmSimulation
simulation={photo.filmSimulation} simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks} prefetch={prefetchRelatedLinks}
/> />}
{photo.fujifilmRecipe && {photo.fujifilmRecipe &&
<button <button
ref={refRecipeTrigger} ref={refRecipeTrigger}

View File

@ -12,7 +12,7 @@ import { useMemo } from 'react';
export default function UploadPageClient({ export default function UploadPageClient({
blobId, blobId,
photoFormExif, formDataFromExif,
uniqueTags, uniqueTags,
hasAiTextGeneration, hasAiTextGeneration,
textFieldsToAutoGenerate, textFieldsToAutoGenerate,
@ -20,7 +20,7 @@ export default function UploadPageClient({
shouldStripGpsData, shouldStripGpsData,
}: { }: {
blobId?: string blobId?: string
photoFormExif: Partial<PhotoFormData> formDataFromExif: Partial<PhotoFormData>
uniqueTags: Tags uniqueTags: Tags
hasAiTextGeneration?: boolean hasAiTextGeneration?: boolean
textFieldsToAutoGenerate?: AiAutoGeneratedField[], textFieldsToAutoGenerate?: AiAutoGeneratedField[],
@ -41,10 +41,10 @@ export default function UploadPageClient({
}); });
const initialPhotoForm = useMemo(() => ({ const initialPhotoForm = useMemo(() => ({
...photoFormExif, ...formDataFromExif,
// Generate missing dates on client to avoid timezone issues // Generate missing dates on client to avoid timezone issues
...generateTakenAtFields(photoFormExif), ...generateTakenAtFields(formDataFromExif),
}), [photoFormExif]); }), [formDataFromExif]);
return ( return (
<AdminChildPage <AdminChildPage

View File

@ -13,6 +13,7 @@ import {
} from '@/photo/db/query'; } from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db'; import { GetPhotosOptions, areOptionsSensitive } from './db';
import { import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData, PhotoFormData,
convertFormDataToPhotoDbInsert, convertFormDataToPhotoDbInsert,
convertPhotoToFormData, convertPhotoToFormData,
@ -115,7 +116,7 @@ export const addAllUploadsAction = async ({
streamUpdate('Parsing EXIF data'); streamUpdate('Parsing EXIF data');
const { const {
photoFormExif, formDataFromExif,
imageResizedBase64, imageResizedBase64,
shouldStripGpsData, shouldStripGpsData,
fileBytes, fileBytes,
@ -125,7 +126,7 @@ export const addAllUploadsAction = async ({
generateResizedImage: AI_TEXT_GENERATION_ENABLED, generateResizedImage: AI_TEXT_GENERATION_ENABLED,
}); });
if (photoFormExif) { if (formDataFromExif) {
if (AI_TEXT_GENERATION_ENABLED) { if (AI_TEXT_GENERATION_ENABLED) {
streamUpdate('Generating AI text'); streamUpdate('Generating AI text');
} }
@ -141,13 +142,13 @@ export const addAllUploadsAction = async ({
); );
const form: Partial<PhotoFormData> = { const form: Partial<PhotoFormData> = {
...photoFormExif, ...formDataFromExif,
title, title,
caption, caption,
tags: tags || aiTags, tags: tags || aiTags,
semanticDescription, semanticDescription,
takenAt: photoFormExif.takenAt || takenAtLocal, takenAt: formDataFromExif.takenAt || takenAtLocal,
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal, takenAtNaive: formDataFromExif.takenAtNaive || takenAtNaiveLocal,
}; };
streamUpdate('Transferring to photo storage'); streamUpdate('Transferring to photo storage');
@ -302,9 +303,9 @@ export const getExifDataAction = async (
url: string, url: string,
): Promise<Partial<PhotoFormData>> => ): Promise<Partial<PhotoFormData>> =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const { photoFormExif } = await extractImageDataFromBlobPath(url); const { formDataFromExif } = await extractImageDataFromBlobPath(url);
if (photoFormExif) { if (formDataFromExif) {
return photoFormExif; return formDataFromExif;
} else { } else {
return {}; return {};
} }
@ -322,7 +323,7 @@ export const syncPhotoAction = async (photoId: string) =>
if (photo) { if (photo) {
const { const {
photoFormExif, formDataFromExif,
imageResizedBase64, imageResizedBase64,
shouldStripGpsData, shouldStripGpsData,
fileBytes, fileBytes,
@ -333,7 +334,7 @@ export const syncPhotoAction = async (photoId: string) =>
}); });
let urlToDelete: string | undefined; let urlToDelete: string | undefined;
if (photoFormExif) { if (formDataFromExif) {
if (photo.url.includes(photo.id) || shouldStripGpsData) { if (photo.url.includes(photo.id) || shouldStripGpsData) {
// Anonymize storage url on update if necessary by // Anonymize storage url on update if necessary by
// re-running image upload transfer logic // re-running image upload transfer logic
@ -359,11 +360,18 @@ export const syncPhotoAction = async (photoId: string) =>
AI_TEXT_AUTO_GENERATED_FIELDS, AI_TEXT_AUTO_GENERATED_FIELDS,
); );
const formDataFromPhoto = convertPhotoToFormData(photo);
// Don't overwrite manually configured fujifilm meta with null data
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => {
if (!formDataFromExif[field] && formDataFromPhoto[field]) {
delete formDataFromExif[field];
}
});
const photoFormDbInsert = convertFormDataToPhotoDbInsert({ const photoFormDbInsert = convertFormDataToPhotoDbInsert({
...convertPhotoToFormData(photo), ...formDataFromPhoto,
...photoFormExif, ...formDataFromExif,
// Don't overwrite manually configured film simulations
...photo.filmSimulation && { filmSimulation: photo.filmSimulation },
...!BLUR_ENABLED && { blurData: undefined }, ...!BLUR_ENABLED && { blurData: undefined },
...!photo.title && { title: atTitle }, ...!photo.title && { title: atTitle },
...!photo.caption && { caption: aiCaption }, ...!photo.caption && { caption: aiCaption },

View File

@ -305,6 +305,7 @@ export default function PhotoForm({
readOnly, readOnly,
validate, validate,
validateStringMaxLength, validateStringMaxLength,
spellCheck,
capitalize, capitalize,
hideIfEmpty, hideIfEmpty,
shouldHide, shouldHide,
@ -346,6 +347,7 @@ export default function PhotoForm({
tagOptions={tagOptions} tagOptions={tagOptions}
required={required} required={required}
readOnly={readOnly} readOnly={readOnly}
spellCheck={spellCheck}
capitalize={capitalize} capitalize={capitalize}
placeholder={loadingMessage && !formData[key] placeholder={loadingMessage && !formData[key]
? loadingMessage ? loadingMessage

View File

@ -50,6 +50,7 @@ type FormMeta = {
readOnly?: boolean readOnly?: boolean
validate?: (value?: string) => string | undefined validate?: (value?: string) => string | undefined
validateStringMaxLength?: number validateStringMaxLength?: number
spellCheck?: boolean
capitalize?: boolean capitalize?: boolean
hide?: boolean hide?: boolean
hideIfEmpty?: boolean hideIfEmpty?: boolean
@ -59,6 +60,7 @@ type FormMeta = {
selectOptions?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[] tagOptions?: AnnotatedTag[]
shouldNotOverwriteWithNullDataOnSync?: boolean
}; };
const STRING_MAX_LENGTH_SHORT = 255; const STRING_MAX_LENGTH_SHORT = 255;
@ -107,12 +109,26 @@ const FORM_METADATA = (
selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS, selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
selectOptionsDefaultLabel: 'Unknown', selectOptionsDefaultLabel: 'Unknown',
shouldHide: ({ make }) => make !== MAKE_FUJIFILM, shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
}, },
fujifilmRecipe: { fujifilmRecipe: {
type: 'textarea', type: 'textarea',
label: 'fujifilm recipe', label: 'fujifilm recipe',
spellCheck: false,
capitalize: false,
validate: value => {
let validationMessage = undefined;
if (value) {
try {
JSON.parse(value);
} catch {
validationMessage = 'Invalid JSON';
}
}
return validationMessage;
},
shouldHide: ({ make }) => make !== MAKE_FUJIFILM, shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
readOnly: true, shouldNotOverwriteWithNullDataOnSync: true,
}, },
focalLength: { label: 'focal length' }, focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
@ -138,6 +154,11 @@ const FORM_METADATA = (
hidden: { label: 'hidden', type: 'checkbox' }, hidden: { label: 'hidden', type: 'checkbox' },
}); });
export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC =
Object.entries(FORM_METADATA())
.filter(([_, meta]) => meta.shouldNotOverwriteWithNullDataOnSync)
.map(([key]) => key as keyof PhotoFormData);
export const FORM_METADATA_ENTRIES = ( export const FORM_METADATA_ENTRIES = (
...args: Parameters<typeof FORM_METADATA> ...args: Parameters<typeof FORM_METADATA>
) => ) =>
@ -175,9 +196,8 @@ export const formHasTextContent = ({
// CREATE FORM DATA: FROM PHOTO // CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = ( export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
photo: Photo, console.log('convertPhotoToFormData', photo);
): PhotoFormData => {
const valueForKey = (key: keyof Photo, value: any) => { const valueForKey = (key: keyof Photo, value: any) => {
switch (key) { switch (key) {
case 'tags': case 'tags':

View File

@ -31,7 +31,7 @@ export const extractImageDataFromBlobPath = async (
}, },
): Promise<{ ): Promise<{
blobId?: string blobId?: string
photoFormExif?: Partial<PhotoFormData> formDataFromExif?: Partial<PhotoFormData>
imageResizedBase64?: string imageResizedBase64?: string
shouldStripGpsData?: boolean shouldStripGpsData?: boolean
fileBytes?: ArrayBuffer fileBytes?: ArrayBuffer
@ -108,7 +108,7 @@ export const extractImageDataFromBlobPath = async (
return { return {
blobId, blobId,
...exifData && { ...exifData && {
photoFormExif: { formDataFromExif: {
...includeInitialPhotoFields && { ...includeInitialPhotoFields && {
hidden: 'false', hidden: 'false',
favorite: 'false', favorite: 'false',