From 929769eb482727d837f46166ac242cd0bf810d3f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 2 Feb 2024 13:59:04 -0600 Subject: [PATCH 01/12] Create initial UI for choosing tags --- src/app/admin/photos/[photoId]/edit/page.tsx | 6 +- src/app/admin/uploads/[uploadPath]/page.tsx | 10 ++- src/components/Badge.tsx | 2 +- src/components/CommaSeparatedInput.tsx | 86 ++++++++++++++++++++ src/components/FieldSetWithStatus.tsx | 60 +++++++++----- src/photo/PhotoEditPageClient.tsx | 5 +- src/photo/{ => form}/PhotoForm.tsx | 15 +++- src/photo/{form.ts => form/index.ts} | 14 +++- src/site/globals.css | 11 ++- src/tag/index.ts | 3 + 10 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 src/components/CommaSeparatedInput.tsx rename src/photo/{ => form}/PhotoForm.tsx (93%) rename src/photo/{form.ts => form/index.ts} (96%) diff --git a/src/app/admin/photos/[photoId]/edit/page.tsx b/src/app/admin/photos/[photoId]/edit/page.tsx index 5c51f491..27762339 100644 --- a/src/app/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/admin/photos/[photoId]/edit/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { getPhotoNoStore } from '@/cache'; +import { getPhotoNoStore, getUniqueTagsCached } from '@/cache'; import { PATH_ADMIN } from '@/site/paths'; import PhotoEditPageClient from '@/photo/PhotoEditPageClient'; @@ -12,7 +12,9 @@ export default async function PhotoEditPage({ if (!photo) { redirect(PATH_ADMIN); } + const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); + return ( - + ); }; diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index 7c031239..f2354591 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -1,8 +1,9 @@ -import PhotoForm from '@/photo/PhotoForm'; +import PhotoForm from '@/photo/form/PhotoForm'; import AdminChildPage from '@/components/AdminChildPage'; import { PATH_ADMIN, PATH_ADMIN_UPLOADS } from '@/site/paths'; import { extractExifDataFromBlobPath } from '@/photo/server'; import { redirect } from 'next/navigation'; +import { getUniqueTagsCached } from '@/cache'; interface Params { params: { uploadPath: string } @@ -14,6 +15,8 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { photoFormExif, } = await extractExifDataFromBlobPath(uploadPath); + const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); + if (!photoFormExif) { redirect(PATH_ADMIN); } return ( @@ -22,7 +25,10 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { backLabel="Uploads" breadcrumb={blobId} > - + ); }; diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index d1c3c369..67461974 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -26,7 +26,7 @@ export default function Badge({ 'px-[0.3rem] py-1 rounded-[0.25rem]', 'text-[0.7rem] font-medium', highContrast - ? 'text-invert bg-primary' + ? 'text-invert bg-invert' : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', interactive && highContrast ? 'hover:opacity-70' diff --git a/src/components/CommaSeparatedInput.tsx b/src/components/CommaSeparatedInput.tsx new file mode 100644 index 00000000..808cc186 --- /dev/null +++ b/src/components/CommaSeparatedInput.tsx @@ -0,0 +1,86 @@ +import { convertStringToArray } from '@/utility/string'; +import { Combobox } from '@headlessui/react'; +import { clsx } from 'clsx/lite'; +import { BiExpandVertical } from 'react-icons/bi'; +import { FaCheck } from 'react-icons/fa'; + +export default function CommaSeparatedInput({ + onChange, + id, + name, + value, + type, + autoCapitalize, + readOnly, + options: optionsRaw = [], +}: { + value?: string + onChange?: (value: string) => void + options?: string[] +} & Omit, 'onChange'>) { + const items = (convertStringToArray(value) ?? []) + .map(tag => tag.trim()) + .filter(Boolean); + + const options = items + .filter(item => !optionsRaw.includes(item)) + .concat(optionsRaw); + + return ( +
+ onChange?.(e.join(','))} + multiple + > +
+ onChange?.(e.target.value)} + displayValue={(tags: string[]) => tags.join(', ')} + {...{ + id, + name, + type, + autoCapitalize, + readOnly, + }} + /> + {options && + + + } +
+ {options && + + {options.map((tag) => ( + clsx( + 'p-1 rounded-[0.2rem] !hover:cursor', + focus && 'text-invert bg-invert', + )} + > + {({ selected }) =>
+ + {tag} + + {selected && + } +
} +
+ ))} +
} +
+
+ ); +} diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index c984720c..71df72b3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -4,6 +4,8 @@ import { LegacyRef } from 'react'; import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; +import { FieldSetType } from '@/photo/form'; +import CommaSeparatedInput from '@/components/CommaSeparatedInput'; export default function FieldSetWithStatus({ id, @@ -14,6 +16,7 @@ export default function FieldSetWithStatus({ onChange, selectOptions, selectOptionsDefaultLabel, + commaSeparatedOptions, placeholder, loading, required, @@ -30,12 +33,13 @@ export default function FieldSetWithStatus({ onChange?: (value: string) => void selectOptions?: { value: string, label: string }[] selectOptionsDefaultLabel?: string + commaSeparatedOptions?: string[] placeholder?: string loading?: boolean required?: boolean readOnly?: boolean capitalize?: boolean - type?: 'text' | 'email' | 'password' | 'checkbox' + type?: FieldSetType inputRef?: LegacyRef }) { const { pending } = useFormStatus(); @@ -86,25 +90,41 @@ export default function FieldSetWithStatus({ {optionLabel} )} - : onChange?.(type === 'checkbox' - ? e.target.value === 'true' ? 'false' : 'true' - : e.target.value)} - type={type} - autoComplete="off" - readOnly={readOnly || pending} - className={clsx( - type === 'text' && 'w-full', - error && 'error', - )} - autoCapitalize={!capitalize ? 'off' : undefined} - />} + : commaSeparatedOptions + ? + : onChange?.(type === 'checkbox' + ? e.target.value === 'true' ? 'false' : 'true' + : e.target.value)} + type={type} + autoComplete="off" + autoCapitalize={!capitalize ? 'off' : undefined} + readOnly={readOnly || pending} + className={clsx( + type === 'text' && 'w-full', + error && 'error', + )} + />} ); }; diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index 65d4d471..0bf00b99 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -5,7 +5,7 @@ import { Photo } from '.'; import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { PhotoFormData, convertPhotoToFormData } from './form'; -import PhotoForm from './PhotoForm'; +import PhotoForm from './form/PhotoForm'; import { useFormState } from 'react-dom'; import { areSimpleObjectsEqual } from '@/utility/object'; import IconGrSync from '@/site/IconGrSync'; @@ -13,8 +13,10 @@ import { getExifDataAction } from './actions'; export default function PhotoEditPageClient({ photo, + uniqueTags, }: { photo: Photo + uniqueTags?: string[] }) { const seedExifData = { url: photo.url }; @@ -51,6 +53,7 @@ export default function PhotoEditPageClient({ updatedExifData={hasExifDataBeenFound ? updatedExifData : undefined} + uniqueTags={uniqueTags} /> ); diff --git a/src/photo/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx similarity index 93% rename from src/photo/PhotoForm.tsx rename to src/photo/form/PhotoForm.tsx index a25fdc0b..9101f210 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -7,9 +7,9 @@ import { convertFormKeysToLabels, getInitialErrors, isFormValid, -} from './form'; +} from '.'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; -import { createPhotoAction, updatePhotoAction } from './actions'; +import { createPhotoAction, updatePhotoAction } from '../actions'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { clsx } from 'clsx/lite'; @@ -23,6 +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'; const THUMBNAIL_SIZE = 300; @@ -30,11 +31,13 @@ export default function PhotoForm({ initialPhotoForm, updatedExifData, type = 'create', + uniqueTags, debugBlur, }: { initialPhotoForm: Partial updatedExifData?: Partial type?: 'create' | 'edit' + uniqueTags?: string[] debugBlur?: boolean }) { const [formData, setFormData] = @@ -140,6 +143,7 @@ export default function PhotoForm({
blur()} className="space-y-6" > {FORM_METADATA_ENTRIES.map(([key, { @@ -154,7 +158,7 @@ export default function PhotoForm({ hideIfEmpty, hideBasedOnCamera, loadingMessage, - checkbox, + type, }]) => ( (!hideIfEmpty || formData[key]) && @@ -175,6 +179,9 @@ export default function PhotoForm({ }} selectOptions={options} selectOptionsDefaultLabel={optionsDefaultLabel} + commaSeparatedOptions={key === 'tags' + ? sortTagsWithoutFavs(uniqueTags ?? []) + : undefined} required={required} readOnly={readOnly} capitalize={capitalize} @@ -182,7 +189,7 @@ export default function PhotoForm({ ? loadingMessage : undefined} loading={loadingMessage && !formData[key] ? true : false} - type={checkbox ? 'checkbox' : undefined} + type={type} />)}
; +export type FieldSetType = + 'text' | + 'email' | + 'password' | + 'checkbox'; + type FormMeta = { label: string note?: string @@ -32,7 +38,7 @@ type FormMeta = { hideIfEmpty?: boolean hideBasedOnCamera?: (make?: string, mode?: string) => boolean loadingMessage?: string - checkbox?: boolean + type?: FieldSetType options?: { value: string, label: string }[] optionsDefaultLabel?: string }; @@ -77,8 +83,8 @@ const FORM_METADATA: Record = { takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, priorityOrder: { label: 'priority order' }, - favorite: { label: 'favorite', checkbox: true, virtual: true }, - hidden: { label: 'hidden', checkbox: true }, + favorite: { label: 'favorite', type: 'checkbox', virtual: true }, + hidden: { label: 'hidden', type: 'checkbox' }, }; export const FORM_METADATA_ENTRIES = diff --git a/src/site/globals.css b/src/site/globals.css index 528d5fe8..1b8325c6 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -7,8 +7,8 @@ body { @apply text-main + bg-main font-mono text-sm md:text-base - bg-white dark:bg-black } /* Forms */ label { @@ -17,12 +17,13 @@ text-medium tracking-wider } + .control, button, .button, input[type=text], input[type=email], input[type=password], select { @apply px-2.5 py-2 border rounded-md - bg-white dark:bg-black + bg-main border-gray-200 dark:border-gray-700 font-mono text-base leading-tight min-h-[2.4rem] @@ -142,12 +143,16 @@ text-red-500 dark:text-red-400 } /* Common Utilities: Background */ + .bg-main { + @apply + bg-white dark:bg-black + } .bg-content { @apply bg-white border-gray-200 dark:bg-black dark:border-gray-800 } - .bg-primary { + .bg-invert { @apply bg-gray-900 dark:bg-gray-100 } diff --git a/src/tag/index.ts b/src/tag/index.ts index ceb71026..03cf7699 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -43,6 +43,9 @@ export const sortTagsObject = ( .filter(({ tag }) => tag!== tagToHide) .sort(({ tag: a }, { tag: b }) => isTagFavs(a) ? -1 : a.localeCompare(b)); +export const sortTagsWithoutFavs = (tags: string[]) => + sortTags(tags, TAG_FAVS); + export const descriptionForTaggedPhotos = ( photos: Photo[], dateBased?: boolean, From e571161aca4b34f9b4957ff1c0c76464d3659782 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 3 Feb 2024 13:26:43 -0600 Subject: [PATCH 02/12] Add explicit "create new tag" menu option --- src/components/CommaSeparatedInput.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/CommaSeparatedInput.tsx b/src/components/CommaSeparatedInput.tsx index 808cc186..752430bf 100644 --- a/src/components/CommaSeparatedInput.tsx +++ b/src/components/CommaSeparatedInput.tsx @@ -18,6 +18,9 @@ export default function CommaSeparatedInput({ onChange?: (value: string) => void options?: string[] } & Omit, 'onChange'>) { + const lastTerm = value?.split(',').slice(-1)?.[0].trim(); + const hasLastTerm = lastTerm && !optionsRaw.includes(lastTerm); + const items = (convertStringToArray(value) ?? []) .map(tag => tag.trim()) .filter(Boolean); @@ -57,10 +60,16 @@ export default function CommaSeparatedInput({ /> }
- {options && + {(options || hasLastTerm) && + {hasLastTerm && + + Create {`"${lastTerm}"`} + } {options.map((tag) => ( Date: Sat, 3 Feb 2024 14:03:07 -0600 Subject: [PATCH 03/12] Remove last term, tweak check mark --- src/components/CommaSeparatedInput.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/CommaSeparatedInput.tsx b/src/components/CommaSeparatedInput.tsx index 752430bf..bab7ee57 100644 --- a/src/components/CommaSeparatedInput.tsx +++ b/src/components/CommaSeparatedInput.tsx @@ -18,9 +18,6 @@ export default function CommaSeparatedInput({ onChange?: (value: string) => void options?: string[] } & Omit, 'onChange'>) { - const lastTerm = value?.split(',').slice(-1)?.[0].trim(); - const hasLastTerm = lastTerm && !optionsRaw.includes(lastTerm); - const items = (convertStringToArray(value) ?? []) .map(tag => tag.trim()) .filter(Boolean); @@ -60,31 +57,29 @@ export default function CommaSeparatedInput({ /> } - {(options || hasLastTerm) && + {options && - {hasLastTerm && - - Create {`"${lastTerm}"`} - } {options.map((tag) => ( clsx( 'p-1 rounded-[0.2rem] !hover:cursor', - focus && 'text-invert bg-invert', + focus && 'bg-gray-100 dark:bg-gray-900', )} > {({ selected }) =>
+ + {selected && + } + {tag} - {selected && - } +
}
))} From f4913db81e0bac25b8813fa71a6965a3e6ead25b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 3 Feb 2024 23:49:08 -0600 Subject: [PATCH 04/12] Switch to new tag component --- src/components/CommaSeparatedInput.tsx | 90 ----------- src/components/FieldSetWithStatus.tsx | 22 ++- src/components/TagInput.tsx | 210 +++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 103 deletions(-) delete mode 100644 src/components/CommaSeparatedInput.tsx create mode 100644 src/components/TagInput.tsx diff --git a/src/components/CommaSeparatedInput.tsx b/src/components/CommaSeparatedInput.tsx deleted file mode 100644 index bab7ee57..00000000 --- a/src/components/CommaSeparatedInput.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { convertStringToArray } from '@/utility/string'; -import { Combobox } from '@headlessui/react'; -import { clsx } from 'clsx/lite'; -import { BiExpandVertical } from 'react-icons/bi'; -import { FaCheck } from 'react-icons/fa'; - -export default function CommaSeparatedInput({ - onChange, - id, - name, - value, - type, - autoCapitalize, - readOnly, - options: optionsRaw = [], -}: { - value?: string - onChange?: (value: string) => void - options?: string[] -} & Omit, 'onChange'>) { - const items = (convertStringToArray(value) ?? []) - .map(tag => tag.trim()) - .filter(Boolean); - - const options = items - .filter(item => !optionsRaw.includes(item)) - .concat(optionsRaw); - - return ( -
- onChange?.(e.join(','))} - multiple - > -
- onChange?.(e.target.value)} - displayValue={(tags: string[]) => tags.join(', ')} - {...{ - id, - name, - type, - autoCapitalize, - readOnly, - }} - /> - {options && - - - } -
- {options && - - {options.map((tag) => ( - clsx( - 'p-1 rounded-[0.2rem] !hover:cursor', - focus && 'bg-gray-100 dark:bg-gray-900', - )} - > - {({ selected }) =>
- - {selected && - } - - - {tag} - - -
} -
- ))} -
} -
-
- ); -} diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index 71df72b3..cc5cf811 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -5,7 +5,8 @@ import { useFormStatus } from 'react-dom'; import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; import { FieldSetType } from '@/photo/form'; -import CommaSeparatedInput from '@/components/CommaSeparatedInput'; +import TagInput from './TagInput'; +import { convertStringToArray } from '@/utility/string'; export default function FieldSetWithStatus({ id, @@ -91,20 +92,15 @@ export default function FieldSetWithStatus({ )} : commaSeparatedOptions - ? { + onChange?.(value.join(', ')); + console.log(value.join(', ')); + }} readOnly={readOnly || pending} - className={clsx( - type === 'text' && 'w-full', - error && 'error', - )} /> : void + readOnly?: boolean +}) { + const containerRef = useRef(null); + const inputRef = useRef(null); + const optionsRef = useRef(null); + + const [hasFocus, setHasFocus] = useState(false); + const [inputText, setInputText] = useState(''); + const [selectedOptionIndex, setSelectedOptionIndex] = useState(); + + const inputTextFormatted = inputText.toLocaleLowerCase().trim(); + const isInputTextNew = + inputTextFormatted && + !selectedOptions.includes(inputTextFormatted); + + let optionsFiltered = options + .filter(option => + !selectedOptions.includes(option) && + ( + !inputTextFormatted || + option.includes(inputTextFormatted) + )); + + if (isInputTextNew) { + optionsFiltered = [ + `${CREATE_LABEL}"${inputTextFormatted}"`, + ...optionsFiltered, + ]; + } + + const addOption = useCallback((option: string) => { + onChange?.([ + ...selectedOptions, + option.startsWith(CREATE_LABEL) + ? option.slice(CREATE_LABEL.length + 1, -1) + : option, + ] + .filter(Boolean) + .map(option => option.toLocaleLowerCase().trim())); + setSelectedOptionIndex(undefined); + }, [onChange, selectedOptions]); + + useEffect(() => { + if (!hasFocus) { setSelectedOptionIndex(undefined); } + }, [hasFocus]); + + useEffect(() => { + if (selectedOptionIndex !== undefined) { + const ref = optionsRef.current; + const options = ref?.querySelectorAll('div'); + const option = options?.[selectedOptionIndex] as HTMLElement | undefined; + console.log({options, option: option?.innerHTML}); + option?.focus(); + } + }, [selectedOptionIndex]); + + useEffect(() => { + const ref = containerRef.current; + const listener = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + case 'ArrowDown': + case 'ArrowUp': + case 'Escape': + e.stopImmediatePropagation(); + e.preventDefault(); + } + switch (e.key) { + case 'Enter': + addOption(optionsFiltered[selectedOptionIndex ?? 0]); + inputRef.current?.focus(); + setInputText(''); + break; + case 'ArrowDown': + setSelectedOptionIndex(i => { + if (i === undefined || i >= optionsFiltered.length - 1) { + return 0; + } else { + return i + 1; + } + }); + break; + case 'ArrowUp': + setSelectedOptionIndex(i => { + if (i === undefined || i === 0) { + return optionsFiltered.length - 1; + } else { + return i - 1; + } + }); + break; + case 'Backspace': + if (inputText === '') { + onChange?.(selectedOptions.slice(0, -1)); + // setHasFocus(false); + } + break; + case 'Escape': + setHasFocus(false); + break; + } + }; + ref?.addEventListener(KEYDOWN_KEY, listener); + return () => ref?.removeEventListener(KEYDOWN_KEY, listener); + }, [ + inputText, + onChange, + hasFocus, + selectedOptions, + selectedOptionIndex, + optionsFiltered, + addOption, + ]); + + return ( +
setHasFocus(true)} + onBlur={e => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setHasFocus(false); + setSelectedOptionIndex(undefined); + } + }} + > +
+ {selectedOptions + .filter(Boolean) + .map(option => + + onChange?.(selectedOptions.filter(o => o !== option))} + > + {option} + )} + setInputText(e.target.value)} + autoComplete="off" + autoCapitalize="off" + readOnly={readOnly} + /> +
+
+ {hasFocus && optionsFiltered.length > 0 && +
+ {optionsFiltered.map((option, index) => +
{ + addOption(option); + inputRef.current?.focus(); + setInputText(''); + // setHasFocus(false); + }} + > + {option} +
)} +
} +
+
+ ); +} From 84481ea6cfdb1efd07a8aca5f037293c696b3549 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 3 Feb 2024 23:51:57 -0600 Subject: [PATCH 05/12] Remove logging --- src/components/TagInput.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 66173c68..7b9c091f 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -65,7 +65,6 @@ export default function TagInput({ const ref = optionsRef.current; const options = ref?.querySelectorAll('div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; - console.log({options, option: option?.innerHTML}); option?.focus(); } }, [selectedOptionIndex]); @@ -198,7 +197,6 @@ export default function TagInput({ addOption(option); inputRef.current?.focus(); setInputText(''); - // setHasFocus(false); }} > {option} From b77c186ae9585ad95677b18aeff7348ab7c8eb30 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 14:34:17 -0600 Subject: [PATCH 06/12] Refine TagInput behavior --- src/components/FieldSetWithStatus.tsx | 16 ++-- src/components/ImageBlurFallback.tsx | 2 +- src/components/MoreMenu.tsx | 2 +- src/components/TagInput.tsx | 115 ++++++++++++++++---------- src/photo/form/PhotoForm.tsx | 4 +- src/photo/form/index.ts | 1 - src/site/globals.css | 2 +- src/tag/FavsTag.tsx | 2 +- 8 files changed, 84 insertions(+), 60 deletions(-) diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index cc5cf811..a680704c 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -6,7 +6,6 @@ import Spinner from './Spinner'; import { clsx } from 'clsx/lite'; import { FieldSetType } from '@/photo/form'; import TagInput from './TagInput'; -import { convertStringToArray } from '@/utility/string'; export default function FieldSetWithStatus({ id, @@ -77,6 +76,7 @@ export default function FieldSetWithStatus({ onChange={e => onChange?.(e.target.value)} className={clsx( 'w-full', + clsx(Boolean(error) && 'error'), // Use special class because `select` can't be readonly readOnly || pending && 'disabled-select', )} @@ -92,14 +92,12 @@ export default function FieldSetWithStatus({ )} : commaSeparatedOptions - ? - { - onChange?.(value.join(', ')); - console.log(value.join(', ')); - }} + onChange={onChange} + className={clsx(Boolean(error) && 'error')} readOnly={readOnly || pending} /> : } diff --git a/src/components/ImageBlurFallback.tsx b/src/components/ImageBlurFallback.tsx index d34c1fc1..1b1162e8 100644 --- a/src/components/ImageBlurFallback.tsx +++ b/src/components/ImageBlurFallback.tsx @@ -1,5 +1,5 @@ import { BLUR_ENABLED } from '@/site/config'; -import clsx from 'clsx/lite'; +import { clsx} from 'clsx/lite'; import Image, { ImageProps } from 'next/image'; export default function ImageBlurFallback(props: ImageProps) { diff --git a/src/components/MoreMenu.tsx b/src/components/MoreMenu.tsx index 903cf688..d553d059 100644 --- a/src/components/MoreMenu.tsx +++ b/src/components/MoreMenu.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx/lite'; +import { clsx} from 'clsx/lite'; import Link from 'next/link'; import { Menu } from '@headlessui/react'; import { FiMoreHorizontal } from 'react-icons/fi'; diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 7b9c091f..c2291547 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -1,19 +1,23 @@ -import clsx from 'clsx'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { convertStringToArray, parameterize } from '@/utility/string'; +import { clsx } from 'clsx/lite'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const KEYDOWN_KEY = 'keydown'; - const CREATE_LABEL = 'Create '; export default function TagInput({ + name, + value = '', options = [], - selectedOptions = [], onChange, + className, readOnly, }: { + name: string + value?: string options?: string[] - selectedOptions?: string[] - onChange?: (options: string[]) => void + onChange?: (value: string) => void + className?: string readOnly?: boolean }) { const containerRef = useRef(null); @@ -24,56 +28,64 @@ export default function TagInput({ const [inputText, setInputText] = useState(''); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); - const inputTextFormatted = inputText.toLocaleLowerCase().trim(); - const isInputTextNew = + const selectedOptions = useMemo(() => + convertStringToArray(value) ?? [] + , [value]); + + const inputTextFormatted = parameterize(inputText); + const isInputTextUnique = inputTextFormatted && !selectedOptions.includes(inputTextFormatted); - let optionsFiltered = options + const optionsFiltered = (isInputTextUnique + ? [`${CREATE_LABEL}"${inputTextFormatted}"`] + : []).concat(options .filter(option => !selectedOptions.includes(option) && ( !inputTextFormatted || option.includes(inputTextFormatted) - )); + ))); - if (isInputTextNew) { - optionsFiltered = [ - `${CREATE_LABEL}"${inputTextFormatted}"`, - ...optionsFiltered, - ]; - } - - const addOption = useCallback((option: string) => { - onChange?.([ - ...selectedOptions, - option.startsWith(CREATE_LABEL) - ? option.slice(CREATE_LABEL.length + 1, -1) - : option, - ] - .filter(Boolean) - .map(option => option.toLocaleLowerCase().trim())); - setSelectedOptionIndex(undefined); + const addOption = useCallback((option?: string) => { + if (option && !selectedOptions.includes(option)) { + onChange?.([ + ...selectedOptions, + option.startsWith(CREATE_LABEL) + ? option.slice(CREATE_LABEL.length + 1, -1) + : option, + ] + .filter(Boolean) + .map(option => option.toLocaleLowerCase().trim()).join(',')); + setSelectedOptionIndex(undefined); + } }, [onChange, selectedOptions]); + const removeOption = useCallback((option: string) => { + onChange?.(selectedOptions.filter(o => o !== option).join(',')); + }, [onChange, selectedOptions]); + + // Reset selected option index when focus is lost useEffect(() => { if (!hasFocus) { setSelectedOptionIndex(undefined); } }, [hasFocus]); + // Focus option in the DOM when selected index changes useEffect(() => { if (selectedOptionIndex !== undefined) { - const ref = optionsRef.current; - const options = ref?.querySelectorAll('div'); + const options = optionsRef.current?.querySelectorAll('div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; option?.focus(); } }, [selectedOptionIndex]); + // Setup keyboard listener useEffect(() => { const ref = containerRef.current; const listener = (e: KeyboardEvent) => { + // Keys which always trap focus switch (e.key) { - case 'Enter': + case ',': case 'ArrowDown': case 'ArrowUp': case 'Escape': @@ -82,10 +94,20 @@ export default function TagInput({ } switch (e.key) { case 'Enter': + // Only trap focus if there are options to select + // otherwise allow form to submit + if (optionsFiltered.length > 0) { + e.stopImmediatePropagation(); + e.preventDefault(); + } addOption(optionsFiltered[selectedOptionIndex ?? 0]); inputRef.current?.focus(); setInputText(''); break; + case ',': + addOption(inputText); + setInputText(''); + break; case 'ArrowDown': setSelectedOptionIndex(i => { if (i === undefined || i >= optionsFiltered.length - 1) { @@ -105,9 +127,8 @@ export default function TagInput({ }); break; case 'Backspace': - if (inputText === '') { - onChange?.(selectedOptions.slice(0, -1)); - // setHasFocus(false); + if (inputText === '' && selectedOptions.length > 0) { + removeOption(selectedOptions[selectedOptions.length - 1]); } break; case 'Escape': @@ -119,7 +140,7 @@ export default function TagInput({ return () => ref?.removeEventListener(KEYDOWN_KEY, listener); }, [ inputText, - onChange, + removeOption, hasFocus, selectedOptions, selectedOptionIndex, @@ -139,7 +160,12 @@ export default function TagInput({ } }} > -
+
{selectedOptions .filter(Boolean) .map(option => @@ -150,10 +176,10 @@ export default function TagInput({ 'whitespace-nowrap', 'px-1.5 py-0.5', 'bg-gray-100 dark:bg-gray-800', + 'active:bg-gray-50 dark:active:bg-gray-900', 'rounded-sm', )} - onClick={() => - onChange?.(selectedOptions.filter(o => o !== option))} + onClick={() => removeOption(option)} > {option} )} @@ -161,7 +187,7 @@ export default function TagInput({ ref={inputRef} type="text" className={clsx( - 'grow !min-w-0 !p-0', + 'grow !min-w-0 !p-0 text-lg', '!border-none !ring-transparent', )} value={inputText} @@ -170,29 +196,32 @@ export default function TagInput({ autoCapitalize="off" readOnly={readOnly} /> +
{hasFocus && optionsFiltered.length > 0 &&
{optionsFiltered.map((option, index) =>
{ addOption(option); inputRef.current?.focus(); diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 9101f210..61b78a2d 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -198,9 +198,7 @@ export default function PhotoForm({ > Cancel - + {type === 'create' ? 'Create' : 'Update'}
diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 33adf52e..d9c2a762 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -47,7 +47,6 @@ const FORM_METADATA: Record = { title: { label: 'title', capitalize: true }, tags: { label: 'tags', - note: 'comma-separated values', validate: tags => doesTagsStringIncludeFavs(tags) ? `'${TAG_FAVS}' is a reserved tag` : undefined, diff --git a/src/site/globals.css b/src/site/globals.css index 1b8325c6..bf2ff669 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -65,7 +65,7 @@ @apply rounded-md } - input.error, select.error { + .error { @apply border-red-500 dark:border-red-400 } diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index 3590f015..5496a276 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -2,7 +2,7 @@ import { FaStar } from 'react-icons/fa'; import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink'; import { TAG_FAVS } from '.'; import { pathForTag } from '@/site/paths'; -import clsx from 'clsx'; +import { clsx } from 'clsx/lite'; export default function FavsTag({ type, From e40b91f0288a9cbb8b1b8daa0b5805ec23c86003 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 17:58:27 -0600 Subject: [PATCH 07/12] Refine custom tag input behavior --- src/components/TagInput.tsx | 83 ++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index c2291547..417ebdd9 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -56,7 +56,8 @@ export default function TagInput({ : option, ] .filter(Boolean) - .map(option => option.toLocaleLowerCase().trim()).join(',')); + .map(parameterize) + .join(',')); setSelectedOptionIndex(undefined); } }, [onChange, selectedOptions]); @@ -110,7 +111,9 @@ export default function TagInput({ break; case 'ArrowDown': setSelectedOptionIndex(i => { - if (i === undefined || i >= optionsFiltered.length - 1) { + if (i === undefined) { + return 1; + } else if (i >= optionsFiltered.length - 1) { return 0; } else { return i + 1; @@ -120,7 +123,8 @@ export default function TagInput({ case 'ArrowUp': setSelectedOptionIndex(i => { if (i === undefined || i === 0) { - return optionsFiltered.length - 1; + inputRef.current?.focus(); + return undefined; } else { return i - 1; } @@ -162,7 +166,8 @@ export default function TagInput({ >
@@ -187,9 +192,10 @@ export default function TagInput({ ref={inputRef} type="text" className={clsx( - 'grow !min-w-0 !p-0 text-lg', + 'grow !min-w-0 !p-0 -my-2 text-xl', '!border-none !ring-transparent', )} + size={12} value={inputText} onChange={e => setInputText(e.target.value)} autoComplete="off" @@ -198,39 +204,40 @@ export default function TagInput({ />
-
- {hasFocus && optionsFiltered.length > 0 && -
- {optionsFiltered.map((option, index) => -
{ - addOption(option); - inputRef.current?.focus(); - setInputText(''); - }} - > - {option} -
)} -
} +
+
0) && 'hidden', + 'control absolute top-0 mt-4 w-full z-10 !px-1.5 !py-1.5', + 'max-h-[7.5rem] overflow-y-auto', + 'flex flex-col gap-y-1', + 'text-xl shadow-lg dark:shadow-xl', + )} + > + {optionsFiltered.map((option, index) => +
{ + addOption(option); + inputRef.current?.focus(); + setInputText(''); + }} + > + {option} +
)} +
); From ef13d52506a6da423ee1c16f5a8a0cdba107f9a5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 18:07:12 -0600 Subject: [PATCH 08/12] Don't offer to create tag when it exists --- src/components/TagInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 417ebdd9..7ce12036 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -35,6 +35,7 @@ export default function TagInput({ const inputTextFormatted = parameterize(inputText); const isInputTextUnique = inputTextFormatted && + !options.includes(inputTextFormatted) && !selectedOptions.includes(inputTextFormatted); const optionsFiltered = (isInputTextUnique From 559a5c71825af0191505725927f15c489fa6ff72 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 18:09:52 -0600 Subject: [PATCH 09/12] Refine behavior on mobile --- src/components/TagInput.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 7ce12036..c84002b6 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -59,12 +59,13 @@ export default function TagInput({ .filter(Boolean) .map(parameterize) .join(',')); - setSelectedOptionIndex(undefined); } + setSelectedOptionIndex(undefined); }, [onChange, selectedOptions]); const removeOption = useCallback((option: string) => { onChange?.(selectedOptions.filter(o => o !== option).join(',')); + setSelectedOptionIndex(undefined); }, [onChange, selectedOptions]); // Reset selected option index when focus is lost @@ -178,7 +179,7 @@ export default function TagInput({ setInputText(e.target.value)} autoComplete="off" @@ -221,7 +222,7 @@ export default function TagInput({ key={option} tabIndex={0} className={clsx( - 'cursor-pointer', + 'cursor-pointer select-none', 'px-1 py-1 rounded-sm', index === 0 && selectedOptionIndex === undefined && 'bg-gray-100 dark:bg-gray-800', From 499cf6b4e5613455cfc1db8a86042b1dcab95454 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 18:25:57 -0600 Subject: [PATCH 10/12] Prevent stale tag menu option highlights on mobile --- src/components/TagInput.tsx | 9 ++++++--- tailwind.config.js | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index c84002b6..ce0b4201 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -61,11 +61,13 @@ export default function TagInput({ .join(',')); } setSelectedOptionIndex(undefined); + inputRef.current?.focus(); }, [onChange, selectedOptions]); const removeOption = useCallback((option: string) => { onChange?.(selectedOptions.filter(o => o !== option).join(',')); setSelectedOptionIndex(undefined); + inputRef.current?.focus(); }, [onChange, selectedOptions]); // Reset selected option index when focus is lost @@ -75,10 +77,12 @@ export default function TagInput({ // Focus option in the DOM when selected index changes useEffect(() => { + const options = optionsRef.current?.querySelectorAll('div'); if (selectedOptionIndex !== undefined) { - const options = optionsRef.current?.querySelectorAll('div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; option?.focus(); + } else { + inputRef.current?.focus(); } }, [selectedOptionIndex]); @@ -104,7 +108,6 @@ export default function TagInput({ e.preventDefault(); } addOption(optionsFiltered[selectedOptionIndex ?? 0]); - inputRef.current?.focus(); setInputText(''); break; case ',': @@ -138,6 +141,7 @@ export default function TagInput({ } break; case 'Escape': + inputRef.current?.blur(); setHasFocus(false); break; } @@ -233,7 +237,6 @@ export default function TagInput({ )} onClick={() => { addOption(option); - inputRef.current?.focus(); setInputText(''); }} > diff --git a/tailwind.config.js b/tailwind.config.js index f701b311..61b59613 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -37,6 +37,9 @@ module.exports = { }, }, }, + future: { + hoverOnlyWhenSupported: true, + }, plugins: [ require('@tailwindcss/forms'), ], From b45355c951c17749b02c1508874501a4af1d63b8 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 18:38:39 -0600 Subject: [PATCH 11/12] Fix input tag auto-focus behavior --- src/components/TagInput.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index ce0b4201..f3f34039 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -77,12 +77,10 @@ export default function TagInput({ // Focus option in the DOM when selected index changes useEffect(() => { - const options = optionsRef.current?.querySelectorAll('div'); if (selectedOptionIndex !== undefined) { + const options = optionsRef.current?.querySelectorAll('div'); const option = options?.[selectedOptionIndex] as HTMLElement | undefined; option?.focus(); - } else { - inputRef.current?.focus(); } }, [selectedOptionIndex]); @@ -216,7 +214,7 @@ export default function TagInput({ className={clsx( !(hasFocus && optionsFiltered.length > 0) && 'hidden', 'control absolute top-0 mt-4 w-full z-10 !px-1.5 !py-1.5', - 'max-h-[7.5rem] overflow-y-auto', + 'max-h-[8rem] overflow-y-auto', 'flex flex-col gap-y-1', 'text-xl shadow-lg dark:shadow-xl', )} From 376c72ff30e97a0d73f4223867317cc07cc220a1 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 4 Feb 2024 18:51:01 -0600 Subject: [PATCH 12/12] Fix safari layout shift --- src/components/TagInput.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index f3f34039..b4a69eaa 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -65,7 +65,8 @@ export default function TagInput({ }, [onChange, selectedOptions]); const removeOption = useCallback((option: string) => { - onChange?.(selectedOptions.filter(o => o !== option).join(',')); + onChange?.(selectedOptions.filter(o => + o !== parameterize(option)).join(',')); setSelectedOptionIndex(undefined); inputRef.current?.focus(); }, [onChange, selectedOptions]); @@ -159,7 +160,7 @@ export default function TagInput({ return (
setHasFocus(true)} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) { @@ -170,7 +171,7 @@ export default function TagInput({ >
-
+
0) && 'hidden', - 'control absolute top-0 mt-4 w-full z-10 !px-1.5 !py-1.5', + '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',