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 @@ -24,10 +28,14 @@ export default function TagInput({ const inputRef = useRef(null); const optionsRef = useRef(null); - const [hasFocus, setHasFocus] = useState(false); + const [shouldShowMenu, setShouldShowMenu] = useState(false); const [inputText, setInputText] = useState(''); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); + const optionValues = useMemo(() => + options.map(({ value }) => value) + , [options]); + const selectedOptions = useMemo(() => convertStringToArray(value) ?? [] , [value]); @@ -35,25 +43,36 @@ 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); + setSelectedOptionIndex(undefined); + if (shouldBlurInput) { + inputRef.current?.blur(); + } + }, []); const addOption = useCallback((option?: string) => { if (option && !selectedOptions.includes(option)) { onChange?.([ ...selectedOptions, option.startsWith(CREATE_LABEL) - ? option.slice(CREATE_LABEL.length + 1, -1) + ? option.slice(CREATE_LABEL.length, -1) : option, ] .filter(Boolean) @@ -71,15 +90,17 @@ export default function TagInput({ inputRef.current?.focus(); }, [onChange, selectedOptions]); - // Reset selected option index when focus is lost + // Show options when input text changes useEffect(() => { - if (!hasFocus) { setSelectedOptionIndex(undefined); } - }, [hasFocus]); + if (inputText) { + setShouldShowMenu(true); + } + }, [inputText]); // Focus option in the DOM when selected index changes useEffect(() => { if (selectedOptionIndex !== undefined) { - const options = optionsRef.current?.querySelectorAll('div'); + const options = optionsRef.current?.querySelectorAll(':scope > div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; option?.focus(); } @@ -88,6 +109,7 @@ export default function TagInput({ // Setup keyboard listener useEffect(() => { const ref = containerRef.current; + const listener = (e: KeyboardEvent) => { // Keys which always trap focus switch (e.key) { @@ -102,12 +124,12 @@ export default function TagInput({ case 'Enter': // Only trap focus if there are options to select // otherwise allow form to submit - if (optionsFiltered.length > 0) { + if (shouldShowMenu && optionsFiltered.length > 0) { e.stopImmediatePropagation(); e.preventDefault(); + addOption(optionsFiltered[selectedOptionIndex ?? 0].value); + setInputText(''); } - addOption(optionsFiltered[selectedOptionIndex ?? 0]); - setInputText(''); break; case ',': addOption(inputText); @@ -126,7 +148,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 { @@ -137,56 +164,76 @@ export default function TagInput({ case 'Backspace': if (inputText === '' && selectedOptions.length > 0) { removeOption(selectedOptions[selectedOptions.length - 1]); + hideMenu(); } break; case 'Escape': - inputRef.current?.blur(); - setHasFocus(false); + hideMenu(true); break; } }; - ref?.addEventListener(KEYDOWN_KEY, listener); - return () => ref?.removeEventListener(KEYDOWN_KEY, listener); + + ref?.addEventListener(KEY_KEYDOWN, listener); + + return () => ref?.removeEventListener(KEY_KEYDOWN, listener); }, [ inputText, removeOption, - hasFocus, + hideMenu, selectedOptions, selectedOptionIndex, optionsFiltered, addOption, + shouldShowMenu, ]); return (
setHasFocus(true)} + className="flex flex-col w-full group" + onFocus={() => setShouldShowMenu(true)} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) { - setHasFocus(false); - setSelectedOptionIndex(undefined); + hideMenu(); } }} > -
+
+ {selectedOptions.length === 0 + ? 'No tags selected' + : selectedOptions.join(', ') + + ` tag${selectedOptions.length !== 1 ? 's' : ''} selected`} +
+
{selectedOptions .filter(Boolean) .map(option => removeOption(option)} @@ -206,43 +253,64 @@ export default function TagInput({ autoComplete="off" autoCapitalize="off" readOnly={readOnly} + onFocus={() => setSelectedOptionIndex(undefined)} + aria-autocomplete="list" + aria-expanded={shouldShowMenu} + aria-haspopup="true" + aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined} + role="combobox" />
-
-
0) && 'hidden', - 'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5', - 'max-h-[8rem] overflow-y-auto', - 'flex flex-col gap-y-1', - 'text-xl shadow-lg dark:shadow-xl', - )} - > - {optionsFiltered.map((option, index) => -
{ - addOption(option); - setInputText(''); - }} - > - {option} -
)} -
-
+ {shouldShowMenu && optionsFiltered.length > 0 && +
+
+ {optionsFiltered.map(({ value, annotation }, index) => +
{ + addOption(value); + setInputText(''); + }} + onFocus={() => setSelectedOptionIndex(index)} + > + + {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,