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/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index c984720c..a680704c 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 TagInput from './TagInput'; 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(); @@ -72,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', )} @@ -86,25 +91,34 @@ 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', + Boolean(error) && 'error', + )} + />} ); }; 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 new file mode 100644 index 00000000..b4a69eaa --- /dev/null +++ b/src/components/TagInput.tsx @@ -0,0 +1,248 @@ +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 = [], + onChange, + className, + readOnly, +}: { + name: string + value?: string + options?: string[] + onChange?: (value: string) => void + className?: string + 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 selectedOptions = useMemo(() => + convertStringToArray(value) ?? [] + , [value]); + + const inputTextFormatted = parameterize(inputText); + const isInputTextUnique = + inputTextFormatted && + !options.includes(inputTextFormatted) && + !selectedOptions.includes(inputTextFormatted); + + const optionsFiltered = (isInputTextUnique + ? [`${CREATE_LABEL}"${inputTextFormatted}"`] + : []).concat(options + .filter(option => + !selectedOptions.includes(option) && + ( + !inputTextFormatted || + option.includes(inputTextFormatted) + ))); + + 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(parameterize) + .join(',')); + } + setSelectedOptionIndex(undefined); + inputRef.current?.focus(); + }, [onChange, selectedOptions]); + + const removeOption = useCallback((option: string) => { + onChange?.(selectedOptions.filter(o => + o !== parameterize(option)).join(',')); + setSelectedOptionIndex(undefined); + inputRef.current?.focus(); + }, [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 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 ',': + case 'ArrowDown': + case 'ArrowUp': + case 'Escape': + e.stopImmediatePropagation(); + e.preventDefault(); + } + 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]); + setInputText(''); + break; + case ',': + addOption(inputText); + setInputText(''); + break; + case 'ArrowDown': + setSelectedOptionIndex(i => { + if (i === undefined) { + return 1; + } else if (i >= optionsFiltered.length - 1) { + return 0; + } else { + return i + 1; + } + }); + break; + case 'ArrowUp': + setSelectedOptionIndex(i => { + if (i === undefined || i === 0) { + inputRef.current?.focus(); + return undefined; + } else { + return i - 1; + } + }); + break; + case 'Backspace': + if (inputText === '' && selectedOptions.length > 0) { + removeOption(selectedOptions[selectedOptions.length - 1]); + } + break; + case 'Escape': + inputRef.current?.blur(); + setHasFocus(false); + break; + } + }; + ref?.addEventListener(KEYDOWN_KEY, listener); + return () => ref?.removeEventListener(KEYDOWN_KEY, listener); + }, [ + inputText, + removeOption, + 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 => + removeOption(option)} + > + {option} + )} + setInputText(e.target.value)} + autoComplete="off" + autoCapitalize="off" + readOnly={readOnly} + /> + +
+
+
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} +
)} +
+
+
+ ); +} 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 92% rename from src/photo/PhotoForm.tsx rename to src/photo/form/PhotoForm.tsx index a25fdc0b..61b78a2d 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} />)}
Cancel - + {type === 'create' ? 'Create' : 'Update'}
diff --git a/src/photo/form.ts b/src/photo/form/index.ts similarity index 96% rename from src/photo/form.ts rename to src/photo/form/index.ts index ea7219b4..d9c2a762 100644 --- a/src/photo/form.ts +++ b/src/photo/form/index.ts @@ -1,5 +1,5 @@ import type { ExifData } from 'ts-exif-parser'; -import { Photo, PhotoDbInsert, PhotoExif } from '.'; +import { Photo, PhotoDbInsert, PhotoExif } from '..'; import { convertTimestampToNaivePostgresString, convertTimestampWithOffsetToPostgresString, @@ -20,6 +20,12 @@ type VirtualFields = 'favorite'; export type PhotoFormData = Record; +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 }; @@ -41,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, @@ -77,8 +82,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..bf2ff669 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] @@ -64,7 +65,7 @@ @apply rounded-md } - input.error, select.error { + .error { @apply border-red-500 dark:border-red-400 } @@ -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/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, 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, 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'), ],