Vercel/src/photo/form/index.ts
2026-02-01 19:05:58 -06:00

502 lines
14 KiB
TypeScript

import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert } from '..';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
validationMessageNaivePostgresDateString,
validationMessagePostgresDateString,
} from '@/utility/date';
import { roundToNumber } from '@/utility/number';
import { convertStringToArray, parameterize } from '@/utility/string';
import { generateNanoid } from '@/utility/nanoid';
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { ReactNode } from 'react';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
import { COLOR_SORT_ENABLED } from '@/app/config';
type VirtualFields =
'albums' |
'visibility' |
'favorite' |
'applyRecipeTitleGlobally' |
'shouldStripGpsData';
export type FormFields = keyof PhotoDbInsert | VirtualFields;
export type PhotoFormData = Record<FormFields, string>
export type FieldSetType =
'text' |
'email' |
'password' |
'checkbox' |
'textarea' |
'hidden';
export type AnnotatedTag = {
value: string,
label?: string,
icon?: ReactNode
annotation?: string,
annotationAria?: string,
};
export type FormMeta = {
section: string
label: string
note?: string
noteShort?: string
required?: boolean
excludeFromInsert?: boolean
readOnly?: boolean
hideModificationStatus?: boolean
validate?: (value?: string) => string | undefined
validateStringMaxLength?: number
spellCheck?: boolean
capitalize?: boolean
hideIfEmpty?: boolean
shouldHide?: (
formData: Partial<PhotoFormData>,
changedFormKeys?: (keyof PhotoFormData)[],
) => boolean
loadingMessage?: string
type?: FieldSetType
selectOptions?: SelectMenuOptionType[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
tagOptionsLimit?: number
tagOptionsLimitValidationMessage?: string
tagOptionsShouldParameterize?: boolean
shouldNotOverwriteWithNullDataOnSync?: boolean
isJson?: boolean
staticValue?: string
};
const STRING_MAX_LENGTH_SHORT = 255;
const STRING_MAX_LENGTH_LONG = 1000;
const FORM_METADATA = (
tagOptions?: AnnotatedTag[],
recipeOptions?: AnnotatedTag[],
filmOptions?: AnnotatedTag[],
aiTextGeneration?: boolean,
shouldStripGpsData?: boolean,
): Record<keyof PhotoFormData, FormMeta> => ({
title: {
section: 'text',
label: 'title',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
shouldNotOverwriteWithNullDataOnSync: true,
},
caption: {
section: 'text',
label: 'caption',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: ({ title, caption }) =>
!aiTextGeneration && (!title && !caption),
},
tags: {
section: 'text',
label: 'tags',
tagOptions,
validate: getValidationMessageForTags,
},
semanticDescription: {
section: 'text',
type: 'textarea',
label: 'semantic description (not visible)',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: () => !aiTextGeneration,
},
albums: {
section: 'text',
label: 'albums',
excludeFromInsert: true,
},
visibility: {
section: 'text',
type: 'text',
label: 'visibility',
excludeFromInsert: true,
},
excludeFromFeeds: {
section: 'text',
label: 'exclude from feeds',
type: 'hidden',
},
hidden: {
section: 'text',
label: 'hidden',
type: 'hidden',
},
favorite: {
section: 'text',
label: 'favorite',
type: 'checkbox',
excludeFromInsert: true,
},
make: {
section: 'exif',
label: 'camera make',
},
model: {
section: 'exif',
label: 'camera model',
},
film: {
section: 'exif',
label: 'film',
note: 'Intended for Fujifilm / Nikon / analog scans',
noteShort: 'Fujifilm / Nikon / analog scans',
tagOptions: filmOptions,
tagOptionsLimit: 1,
shouldNotOverwriteWithNullDataOnSync: true,
},
recipeTitle: {
section: 'exif',
label: 'recipe title',
tagOptions: recipeOptions,
tagOptionsLimit: 1,
spellCheck: false,
capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
},
applyRecipeTitleGlobally: {
section: 'exif',
label: 'apply recipe title globally',
type: 'checkbox',
excludeFromInsert: true,
hideModificationStatus: true,
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
!(
make === MAKE_FUJIFILM &&
recipeData &&
recipeTitle &&
changedFormKeys?.includes('recipeTitle')
),
},
recipeData: {
section: 'exif',
type: 'textarea',
label: 'recipe data',
spellCheck: false,
capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
isJson: true,
validate: value => {
let validationMessage = undefined;
if (value) {
try {
JSON.parse(value);
} catch {
validationMessage = 'Invalid JSON';
}
}
return validationMessage;
},
},
focalLength: {
section: 'exif',
label: 'focal length',
},
focalLengthIn35MmFormat: {
section: 'exif',
label: 'focal length 35mm-equivalent',
},
lensMake: { section: 'exif', label: 'lens make' },
lensModel: { section: 'exif', label: 'lens model' },
fNumber: { section: 'exif', label: 'aperture' },
iso: { section: 'exif', label: 'ISO' },
exposureTime: { section: 'exif', label: 'exposure time' },
exposureCompensation: { section: 'exif', label: 'exposure compensation' },
locationName: {
section: 'exif',
label: 'location name',
shouldHide: () => true,
},
latitude: { section: 'exif', label: 'latitude' },
longitude: { section: 'exif', label: 'longitude' },
takenAt: {
section: 'exif',
label: 'taken at',
validate: validationMessagePostgresDateString,
},
takenAtNaive: {
section: 'exif',
label: 'taken at (naive)',
validate: validationMessageNaivePostgresDateString,
},
id: {
section: 'storage',
label: 'id',
readOnly: true,
hideIfEmpty: true,
},
url: {
section: 'storage',
label: 'storage url',
readOnly: true,
},
extension: {
section: 'storage',
label: 'extension',
readOnly: true,
},
blurData: {
section: 'storage',
label: 'blur data',
readOnly: true,
},
width: {
section: 'storage',
label: 'width',
readOnly: true,
hideIfEmpty: true,
},
height: {
section: 'storage',
label: 'height',
readOnly: true,
hideIfEmpty: true,
},
aspectRatio: {
section: 'storage',
label: 'aspect ratio',
readOnly: true,
},
priorityOrder: {
section: 'misc',
label: 'priority order',
},
colorData: {
section: 'misc',
type: 'textarea',
label: 'color data',
isJson: true,
shouldHide: () => !COLOR_SORT_ENABLED,
},
colorSort: {
section: 'misc',
label: 'color sort',
shouldHide: () => !COLOR_SORT_ENABLED,
},
shouldStripGpsData: {
section: 'misc',
label: 'strip gps data',
type: 'hidden',
excludeFromInsert: true,
staticValue: shouldStripGpsData ? 'true' : 'false',
},
});
export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA())
.filter(([_, meta]) => meta.isJson)
.map(([key]) => key as keyof PhotoFormData);
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 = (
...args: Parameters<typeof FORM_METADATA>
) =>
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
export const FORM_METADATA_ENTRIES_BY_SECTION = (
...args: Parameters<typeof FORM_METADATA>
) => {
const fields = (Object
.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
return fields.reduce((acc, field) => {
const section = acc.find(s => s.section === field[1].section);
if (section) {
section.fields.push(field);
} else {
acc.push({ section: field[1].section, fields: [field] });
}
return acc;
}, [] as {
section: string
fields: [keyof PhotoFormData, FormMeta][]
}[]);
};
export const FORM_SECTIONS = FORM_METADATA_ENTRIES_BY_SECTION()
.map(section => section.section);
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
keys.map(key => FORM_METADATA()[key].label.toUpperCase());
export const getFormErrors = (
formData: Partial<PhotoFormData>,
): Partial<Record<keyof PhotoFormData, string>> =>
Object.keys(formData).reduce((acc, key) => ({
...acc,
[key]: FORM_METADATA_ENTRIES().find(([k]) => k === key)?.[1]
.validate?.(formData[key as keyof PhotoFormData]),
}), {});
export const isFormValid = (formData: Partial<PhotoFormData>) =>
FORM_METADATA_ENTRIES().every(
([key, { required, validate, validateStringMaxLength }]) =>
(!required || Boolean(formData[key])) &&
(!validate?.(formData[key])) &&
// eslint-disable-next-line max-len
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength),
);
export const formHasExistingAiTextContent = ({
title,
caption,
tags,
semanticDescription,
}: Partial<PhotoFormData> = {}) =>
Boolean(title || caption || tags || semanticDescription);
// CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
const valueForKey = (key: keyof Photo, value: any) => {
switch (key) {
case 'tags':
return (value ?? [])
.filter((tag: string) => tag !== TAG_FAVS)
.join(', ');
case 'takenAt':
return value?.toISOString ? value.toISOString() : value;
case 'hidden':
return value ? 'true' : 'false';
case 'recipeData':
return JSON.stringify(value);
case 'colorData':
return JSON.stringify(value);
default:
return value !== undefined && value !== null
? value.toString()
: undefined;
}
};
return Object.entries(photo).reduce((photoForm, [key, value]) => ({
...photoForm,
[key]: valueForKey(key as keyof Photo, value),
}), {
favorite: photo.tags.includes(TAG_FAVS) ? 'true' : 'false',
} as PhotoFormData);
};
// PREPARE FORM FOR DB INSERT
export const convertFormDataToPhotoDbInsert = (
formData: FormData | Partial<PhotoFormData>,
): PhotoDbInsert => {
const photoForm = formData instanceof FormData
? Object.fromEntries(formData) as PhotoFormData
: formData;
// Capture tags before 'favorite' is excluded from insert
const tags = convertStringToArray(photoForm.tags) ?? [];
if (photoForm.favorite === 'true') {
tags.push(TAG_FAVS);
}
// Parse FormData:
// - remove server action ID
// - remove empty strings
// - remove fields excluded from insert
// - trim strings
Object.keys(photoForm).forEach(key => {
const meta = FORM_METADATA()[key as keyof PhotoFormData];
if (
key.startsWith('$ACTION_ID_') ||
(photoForm as any)[key] === '' ||
meta?.excludeFromInsert
) {
delete (photoForm as any)[key];
} else if (typeof (photoForm as any)[key] === 'string') {
(photoForm as any)[key] = (photoForm as any)[key].trim();
}
});
return {
...(photoForm as PhotoFormData & {
film?: FujifilmSimulation
recipeData?: FujifilmRecipe
}),
...!photoForm.id && { id: generateNanoid() },
// Delete array field when empty
tags: tags.length > 0 ? tags : undefined,
...photoForm.recipeTitle && {
recipeTitle: parameterize(photoForm.recipeTitle),
},
width: photoForm.width
? parseInt(photoForm.width)
: undefined,
height: photoForm.height
? parseInt(photoForm.height)
: undefined,
// Convert form strings to numbers
aspectRatio: photoForm.aspectRatio
? roundToNumber(parseFloat(photoForm.aspectRatio), 6)
: DEFAULT_ASPECT_RATIO,
focalLength: photoForm.focalLength
? parseInt(photoForm.focalLength)
: undefined,
focalLengthIn35MmFormat: photoForm.focalLengthIn35MmFormat
? parseInt(photoForm.focalLengthIn35MmFormat)
: undefined,
fNumber: photoForm.fNumber
? parseFloat(photoForm.fNumber)
: undefined,
latitude: photoForm.latitude
? parseFloat(photoForm.latitude)
: undefined,
longitude: photoForm.longitude
? parseFloat(photoForm.longitude)
: undefined,
iso: photoForm.iso
? parseInt(photoForm.iso)
: undefined,
exposureTime: photoForm.exposureTime
? parseFloat(photoForm.exposureTime)
: undefined,
exposureCompensation: photoForm.exposureCompensation
? parseFloat(photoForm.exposureCompensation)
: undefined,
colorSort: photoForm.colorSort
? parseInt(photoForm.colorSort)
: undefined,
priorityOrder: photoForm.priorityOrder
? parseFloat(photoForm.priorityOrder)
: undefined,
excludeFromFeeds: photoForm.excludeFromFeeds === 'true',
hidden: photoForm.hidden === 'true',
...generateTakenAtFields(photoForm),
};
};
export const getChangedFormFields = (
original: Partial<PhotoFormData>,
current: Partial<PhotoFormData>,
) => {
return Object
.keys(current)
.filter(key =>
(original[key as keyof PhotoFormData] ?? '') !==
(current[key as keyof PhotoFormData] ?? ''),
) as (keyof PhotoFormData)[];
};
export const generateTakenAtFields = (
form?: Partial<PhotoFormData>,
): { takenAt: string, takenAtNaive: string } => ({
takenAt: form?.takenAt || generateLocalPostgresString(),
takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(),
});