From 929769eb482727d837f46166ac242cd0bf810d3f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 2 Feb 2024 13:59:04 -0600 Subject: [PATCH] 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,