From 1da28079e61d5bb14def473526ba1bd85d782de7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 6 Feb 2024 17:46:43 -0600 Subject: [PATCH] Display tag counts in photo form --- src/app/admin/photos/[photoId]/edit/page.tsx | 2 +- src/app/admin/uploads/[uploadPath]/page.tsx | 2 +- src/components/FieldSetWithStatus.tsx | 10 +- src/components/TagInput.tsx | 70 +++++++++---- src/photo/PhotoEditPageClient.tsx | 3 +- src/photo/form/PhotoForm.tsx | 104 ++++++++++--------- src/photo/form/index.ts | 34 +++--- src/tag/index.ts | 3 + 8 files changed, 136 insertions(+), 92 deletions(-) diff --git a/src/app/admin/photos/[photoId]/edit/page.tsx b/src/app/admin/photos/[photoId]/edit/page.tsx index 27762339..7e78b30b 100644 --- a/src/app/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/admin/photos/[photoId]/edit/page.tsx @@ -12,7 +12,7 @@ export default async function PhotoEditPage({ if (!photo) { redirect(PATH_ADMIN); } - const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); + const uniqueTags = await getUniqueTagsCached(); return ( diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index f2354591..17b43b52 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -15,7 +15,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { photoFormExif, } = await extractExifDataFromBlobPath(uploadPath); - const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); + const uniqueTags = await getUniqueTagsCached(); if (!photoFormExif) { redirect(PATH_ADMIN); } diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index a680704c..3f2bd4ab 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -4,7 +4,7 @@ import { LegacyRef } from 'react'; import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; -import { FieldSetType } from '@/photo/form'; +import { FieldSetType, AnnotatedTag } from '@/photo/form'; import TagInput from './TagInput'; export default function FieldSetWithStatus({ @@ -16,7 +16,7 @@ export default function FieldSetWithStatus({ onChange, selectOptions, selectOptionsDefaultLabel, - commaSeparatedOptions, + tagOptions, placeholder, loading, required, @@ -33,7 +33,7 @@ export default function FieldSetWithStatus({ onChange?: (value: string) => void selectOptions?: { value: string, label: string }[] selectOptionsDefaultLabel?: string - commaSeparatedOptions?: string[] + tagOptions?: AnnotatedTag [] placeholder?: string loading?: boolean required?: boolean @@ -91,11 +91,11 @@ export default function FieldSetWithStatus({ {optionLabel} )} - : commaSeparatedOptions + : tagOptions ? void className?: string readOnly?: boolean @@ -28,6 +29,10 @@ export default function TagInput({ const [inputText, setInputText] = useState(''); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); + const optionValues = useMemo(() => + options.map(({ value }) => value) + , [options]); + const selectedOptions = useMemo(() => convertStringToArray(value) ?? [] , [value]); @@ -35,18 +40,21 @@ export default function TagInput({ const inputTextFormatted = parameterize(inputText); const isInputTextUnique = inputTextFormatted && - !options.includes(inputTextFormatted) && + !optionValues.includes(inputTextFormatted) && !selectedOptions.includes(inputTextFormatted); - const optionsFiltered = (isInputTextUnique - ? [`${CREATE_LABEL} "${inputTextFormatted}"`] - : []).concat(options - .filter(option => - !selectedOptions.includes(option) && - ( - !inputTextFormatted || - option.includes(inputTextFormatted) - ))); + const optionsFiltered = useMemo(() => + (isInputTextUnique + ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] + : [] + ).concat(options + .filter(({ value }) => + !selectedOptions.includes(value) && + ( + !inputTextFormatted || + value.includes(inputTextFormatted) + ))) + , [inputTextFormatted, isInputTextUnique, options, selectedOptions]); const hideMenu = useCallback((shouldBlurInput?: boolean) => { setShouldShowMenu(false); @@ -117,7 +125,7 @@ export default function TagInput({ e.stopImmediatePropagation(); e.preventDefault(); } - addOption(optionsFiltered[selectedOptionIndex ?? 0]); + addOption(optionsFiltered[selectedOptionIndex ?? 0].value); setInputText(''); break; case ',': @@ -137,7 +145,12 @@ export default function TagInput({ break; case 'ArrowUp': setSelectedOptionIndex(i => { - if (i === undefined || i === 0) { + if ( + document.activeElement === inputRef.current && + optionsFiltered.length > 0 + ) { + return optionsFiltered.length - 1; + } else if (i === undefined || i === 0) { inputRef.current?.focus(); return undefined; } else { @@ -197,8 +210,8 @@ export default function TagInput({ 'cursor-pointer select-none', 'whitespace-nowrap', 'px-1.5 py-0.5', - 'bg-gray-100 dark:bg-gray-800', - 'active:bg-gray-50 dark:active:bg-gray-900', + 'bg-gray-200/60 dark:bg-gray-800', + 'active:bg-gray-200 dark:active:bg-gray-900', 'rounded-sm', )} onClick={() => removeOption(option)} @@ -233,27 +246,40 @@ export default function TagInput({ 'text-xl shadow-lg dark:shadow-xl', )} > - {optionsFiltered.map((option, index) => + {optionsFiltered.map(({ value, annotation }, index) =>
{ - addOption(option); + addOption(value); setInputText(''); }} onFocus={() => setSelectedOptionIndex(index)} > - {option} + + {value} + + {annotation && + + {annotation} + }
)} diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index 0bf00b99..0969d8c0 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -10,13 +10,14 @@ import { useFormState } from 'react-dom'; import { areSimpleObjectsEqual } from '@/utility/object'; import IconGrSync from '@/site/IconGrSync'; import { getExifDataAction } from './actions'; +import { Tags } from '@/tag'; export default function PhotoEditPageClient({ photo, uniqueTags, }: { photo: Photo - uniqueTags?: string[] + uniqueTags?: Tags }) { const seedExifData = { url: photo.url }; diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 61b78a2d..5d083b72 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -5,7 +5,7 @@ import { FORM_METADATA_ENTRIES, PhotoFormData, convertFormKeysToLabels, - getInitialErrors, + getFormErrors, isFormValid, } from '.'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; @@ -23,7 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast'; import { getDimensionsFromSize } from '@/utility/size'; import ImageBlurFallback from '@/components/ImageBlurFallback'; import { BLUR_ENABLED } from '@/site/config'; -import { sortTagsWithoutFavs } from '@/tag'; +import { Tags, sortTagsObjectWithoutFavs } from '@/tag'; const THUMBNAIL_SIZE = 300; @@ -37,13 +37,13 @@ export default function PhotoForm({ initialPhotoForm: Partial updatedExifData?: Partial type?: 'create' | 'edit' - uniqueTags?: string[] + uniqueTags?: Tags debugBlur?: boolean }) { const [formData, setFormData] = useState>(initialPhotoForm); const [formErrors, setFormErrors] = - useState(getInitialErrors(initialPhotoForm)); + useState(getFormErrors(initialPhotoForm)); // Update form when EXIF data // is refreshed by parent @@ -146,51 +146,57 @@ export default function PhotoForm({ onSubmit={() => blur()} className="space-y-6" > - {FORM_METADATA_ENTRIES.map(([key, { - label, - note, - required, - options, - optionsDefaultLabel, - readOnly, - validate, - capitalize, - hideIfEmpty, - hideBasedOnCamera, - loadingMessage, - type, - }]) => - ( - (!hideIfEmpty || formData[key]) && - !hideBasedOnCamera?.(formData.make) - ) && - { - setFormData({ ...formData, [key]: value }); - if (validate) { - setFormErrors({ ...formErrors, [key]: validate(value) }); - } - }} - selectOptions={options} - selectOptionsDefaultLabel={optionsDefaultLabel} - commaSeparatedOptions={key === 'tags' - ? sortTagsWithoutFavs(uniqueTags ?? []) - : undefined} - required={required} - readOnly={readOnly} - capitalize={capitalize} - placeholder={loadingMessage && !formData[key] - ? loadingMessage - : undefined} - loading={loadingMessage && !formData[key] ? true : false} - type={type} - />)} + {FORM_METADATA_ENTRIES( + sortTagsObjectWithoutFavs(uniqueTags ?? []) + .map(({ tag, count }) => ({ + value: tag, + annotation: `× ${count}`, + })) + ) + .map(([key, { + label, + note, + required, + selectOptions, + selectOptionsDefaultLabel, + tagOptions, + readOnly, + validate, + capitalize, + hideIfEmpty, + hideBasedOnCamera, + loadingMessage, + type, + }]) => + ( + (!hideIfEmpty || formData[key]) && + !hideBasedOnCamera?.(formData.make) + ) && + { + setFormData({ ...formData, [key]: value }); + if (validate) { + setFormErrors({ ...formErrors, [key]: validate(value) }); + } + }} + selectOptions={selectOptions} + selectOptionsDefaultLabel={selectOptionsDefaultLabel} + tagOptions={tagOptions} + required={required} + readOnly={readOnly} + capitalize={capitalize} + placeholder={loadingMessage && !formData[key] + ? loadingMessage + : undefined} + loading={loadingMessage && !formData[key] ? true : false} + type={type} + />)}
boolean loadingMessage?: string type?: FieldSetType - options?: { value: string, label: string }[] - optionsDefaultLabel?: string + selectOptions?: { value: string, label: string }[] + selectOptionsDefaultLabel?: string + tagOptions?: AnnotatedTag[] }; -const FORM_METADATA: Record = { +const FORM_METADATA = ( + tagOptions?: AnnotatedTag[] +): Record => ({ title: { label: 'title', capitalize: true }, tags: { label: 'tags', + tagOptions, validate: tags => doesTagsStringIncludeFavs(tags) ? `'${TAG_FAVS}' is a reserved tag` : undefined, @@ -66,8 +72,8 @@ const FORM_METADATA: Record = { model: { label: 'camera model' }, filmSimulation: { label: 'fujifilm simulation', - options: FILM_SIMULATION_FORM_INPUT_OPTIONS, - optionsDefaultLabel: 'Unknown', + selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS, + selectOptionsDefaultLabel: 'Unknown', hideBasedOnCamera: make => make !== MAKE_FUJIFILM, }, focalLength: { label: 'focal length' }, @@ -84,26 +90,28 @@ const FORM_METADATA: Record = { priorityOrder: { label: 'priority order' }, favorite: { label: 'favorite', type: 'checkbox', virtual: true }, hidden: { label: 'hidden', type: 'checkbox' }, -}; +}); -export const FORM_METADATA_ENTRIES = - (Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][]) +export const FORM_METADATA_ENTRIES = ( + ...args: Parameters +) => + (Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]) .filter(([_, meta]) => !meta.hide); export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) => - keys.map(key => FORM_METADATA[key].label.toUpperCase()); + keys.map(key => FORM_METADATA()[key].label.toUpperCase()); -export const getInitialErrors = ( +export const getFormErrors = ( formData: Partial ): Partial> => Object.keys(formData).reduce((acc, key) => ({ ...acc, - [key]: FORM_METADATA_ENTRIES.find(([k]) => k === key)?.[1] + [key]: FORM_METADATA_ENTRIES().find(([k]) => k === key)?.[1] .validate?.(formData[key as keyof PhotoFormData]), }), {}); export const isFormValid = (formData: Partial) => - FORM_METADATA_ENTRIES.every( + FORM_METADATA_ENTRIES().every( ([key, { required, validate }]) => (!required || Boolean(formData[key])) && (validate?.(formData[key]) === undefined) @@ -191,7 +199,7 @@ export const convertFormDataToPhotoDbInsert = ( if ( key.startsWith('$ACTION_ID_') || (photoForm as any)[key] === '' || - FORM_METADATA[key as keyof PhotoFormData]?.virtual + FORM_METADATA()[key as keyof PhotoFormData]?.virtual ) { delete (photoForm as any)[key]; } diff --git a/src/tag/index.ts b/src/tag/index.ts index 03cf7699..58384d0a 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -46,6 +46,9 @@ export const sortTagsObject = ( export const sortTagsWithoutFavs = (tags: string[]) => sortTags(tags, TAG_FAVS); +export const sortTagsObjectWithoutFavs = (tags: Tags) => + sortTagsObject(tags, TAG_FAVS); + export const descriptionForTaggedPhotos = ( photos: Photo[], dateBased?: boolean,