Display tag counts in photo form

This commit is contained in:
Sam Becker 2024-02-06 17:46:43 -06:00
parent e5efc3614d
commit 1da28079e6
8 changed files with 136 additions and 92 deletions

View File

@ -12,7 +12,7 @@ export default async function PhotoEditPage({
if (!photo) { redirect(PATH_ADMIN); } if (!photo) { redirect(PATH_ADMIN); }
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); const uniqueTags = await getUniqueTagsCached();
return ( return (
<PhotoEditPageClient {...{ photo, uniqueTags }} /> <PhotoEditPageClient {...{ photo, uniqueTags }} />

View File

@ -15,7 +15,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
photoFormExif, photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath); } = await extractExifDataFromBlobPath(uploadPath);
const uniqueTags = (await getUniqueTagsCached()).map(tag => tag.tag); const uniqueTags = await getUniqueTagsCached();
if (!photoFormExif) { redirect(PATH_ADMIN); } if (!photoFormExif) { redirect(PATH_ADMIN); }

View File

@ -4,7 +4,7 @@ import { LegacyRef } from 'react';
import { useFormStatus } from 'react-dom'; import { useFormStatus } from 'react-dom';
import Spinner from './Spinner'; import Spinner from './Spinner';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { FieldSetType } from '@/photo/form'; import { FieldSetType, AnnotatedTag } from '@/photo/form';
import TagInput from './TagInput'; import TagInput from './TagInput';
export default function FieldSetWithStatus({ export default function FieldSetWithStatus({
@ -16,7 +16,7 @@ export default function FieldSetWithStatus({
onChange, onChange,
selectOptions, selectOptions,
selectOptionsDefaultLabel, selectOptionsDefaultLabel,
commaSeparatedOptions, tagOptions,
placeholder, placeholder,
loading, loading,
required, required,
@ -33,7 +33,7 @@ export default function FieldSetWithStatus({
onChange?: (value: string) => void onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
commaSeparatedOptions?: string[] tagOptions?: AnnotatedTag []
placeholder?: string placeholder?: string
loading?: boolean loading?: boolean
required?: boolean required?: boolean
@ -91,11 +91,11 @@ export default function FieldSetWithStatus({
{optionLabel} {optionLabel}
</option>)} </option>)}
</select> </select>
: commaSeparatedOptions : tagOptions
? <TagInput ? <TagInput
name={id} name={id}
value={value} value={value}
options={commaSeparatedOptions} options={tagOptions}
onChange={onChange} onChange={onChange}
className={clsx(Boolean(error) && 'error')} className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending} readOnly={readOnly || pending}

View File

@ -1,3 +1,4 @@
import { AnnotatedTag } from '@/photo/form';
import { convertStringToArray, parameterize } from '@/utility/string'; import { convertStringToArray, parameterize } from '@/utility/string';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@ -15,7 +16,7 @@ export default function TagInput({
}: { }: {
name: string name: string
value?: string value?: string
options?: string[] options?: AnnotatedTag[]
onChange?: (value: string) => void onChange?: (value: string) => void
className?: string className?: string
readOnly?: boolean readOnly?: boolean
@ -28,6 +29,10 @@ export default function TagInput({
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>(); const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>();
const optionValues = useMemo(() =>
options.map(({ value }) => value)
, [options]);
const selectedOptions = useMemo(() => const selectedOptions = useMemo(() =>
convertStringToArray(value) ?? [] convertStringToArray(value) ?? []
, [value]); , [value]);
@ -35,18 +40,21 @@ export default function TagInput({
const inputTextFormatted = parameterize(inputText); const inputTextFormatted = parameterize(inputText);
const isInputTextUnique = const isInputTextUnique =
inputTextFormatted && inputTextFormatted &&
!options.includes(inputTextFormatted) && !optionValues.includes(inputTextFormatted) &&
!selectedOptions.includes(inputTextFormatted); !selectedOptions.includes(inputTextFormatted);
const optionsFiltered = (isInputTextUnique const optionsFiltered = useMemo<AnnotatedTag[]>(() =>
? [`${CREATE_LABEL} "${inputTextFormatted}"`] (isInputTextUnique
: []).concat(options ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
.filter(option => : []
!selectedOptions.includes(option) && ).concat(options
( .filter(({ value }) =>
!inputTextFormatted || !selectedOptions.includes(value) &&
option.includes(inputTextFormatted) (
))); !inputTextFormatted ||
value.includes(inputTextFormatted)
)))
, [inputTextFormatted, isInputTextUnique, options, selectedOptions]);
const hideMenu = useCallback((shouldBlurInput?: boolean) => { const hideMenu = useCallback((shouldBlurInput?: boolean) => {
setShouldShowMenu(false); setShouldShowMenu(false);
@ -117,7 +125,7 @@ export default function TagInput({
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault(); e.preventDefault();
} }
addOption(optionsFiltered[selectedOptionIndex ?? 0]); addOption(optionsFiltered[selectedOptionIndex ?? 0].value);
setInputText(''); setInputText('');
break; break;
case ',': case ',':
@ -137,7 +145,12 @@ export default function TagInput({
break; break;
case 'ArrowUp': case 'ArrowUp':
setSelectedOptionIndex(i => { 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(); inputRef.current?.focus();
return undefined; return undefined;
} else { } else {
@ -197,8 +210,8 @@ export default function TagInput({
'cursor-pointer select-none', 'cursor-pointer select-none',
'whitespace-nowrap', 'whitespace-nowrap',
'px-1.5 py-0.5', 'px-1.5 py-0.5',
'bg-gray-100 dark:bg-gray-800', 'bg-gray-200/60 dark:bg-gray-800',
'active:bg-gray-50 dark:active:bg-gray-900', 'active:bg-gray-200 dark:active:bg-gray-900',
'rounded-sm', 'rounded-sm',
)} )}
onClick={() => removeOption(option)} onClick={() => removeOption(option)}
@ -233,27 +246,40 @@ export default function TagInput({
'text-xl shadow-lg dark:shadow-xl', 'text-xl shadow-lg dark:shadow-xl',
)} )}
> >
{optionsFiltered.map((option, index) => {optionsFiltered.map(({ value, annotation }, index) =>
<div <div
key={option} key={value}
tabIndex={0} tabIndex={0}
className={clsx( className={clsx(
'group flex items-center gap-1',
'cursor-pointer select-none', 'cursor-pointer select-none',
'px-1 py-1 rounded-sm', 'px-1.5 py-1 rounded-sm',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'hover:bg-gray-100 dark:hover:bg-gray-800', 'hover:bg-gray-100 dark:hover:bg-gray-800',
'active:bg-gray-50 dark:active:bg-gray-900', 'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800', 'focus:bg-gray-100 dark:focus:bg-gray-800',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'outline-none', 'outline-none',
)} )}
onClick={() => { onClick={() => {
addOption(option); addOption(value);
setInputText(''); setInputText('');
}} }}
onFocus={() => setSelectedOptionIndex(index)} onFocus={() => setSelectedOptionIndex(index)}
> >
{option} <span className="grow min-w-0 truncate">
{value}
</span>
{annotation &&
<span className={clsx(
'whitespace-nowrap text-dim text-sm',
'group-focus:inline-block group-hover:inline-block',
index === 0 && selectedOptionIndex === undefined
? 'inline-block'
: 'hidden',
)}>
{annotation}
</span>}
</div>)} </div>)}
</div> </div>
</div> </div>

View File

@ -10,13 +10,14 @@ import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object'; import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync'; import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions'; import { getExifDataAction } from './actions';
import { Tags } from '@/tag';
export default function PhotoEditPageClient({ export default function PhotoEditPageClient({
photo, photo,
uniqueTags, uniqueTags,
}: { }: {
photo: Photo photo: Photo
uniqueTags?: string[] uniqueTags?: Tags
}) { }) {
const seedExifData = { url: photo.url }; const seedExifData = { url: photo.url };

View File

@ -5,7 +5,7 @@ import {
FORM_METADATA_ENTRIES, FORM_METADATA_ENTRIES,
PhotoFormData, PhotoFormData,
convertFormKeysToLabels, convertFormKeysToLabels,
getInitialErrors, getFormErrors,
isFormValid, isFormValid,
} from '.'; } from '.';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldSetWithStatus from '@/components/FieldSetWithStatus';
@ -23,7 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size'; import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback'; import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config'; import { BLUR_ENABLED } from '@/site/config';
import { sortTagsWithoutFavs } from '@/tag'; import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
const THUMBNAIL_SIZE = 300; const THUMBNAIL_SIZE = 300;
@ -37,13 +37,13 @@ export default function PhotoForm({
initialPhotoForm: Partial<PhotoFormData> initialPhotoForm: Partial<PhotoFormData>
updatedExifData?: Partial<PhotoFormData> updatedExifData?: Partial<PhotoFormData>
type?: 'create' | 'edit' type?: 'create' | 'edit'
uniqueTags?: string[] uniqueTags?: Tags
debugBlur?: boolean debugBlur?: boolean
}) { }) {
const [formData, setFormData] = const [formData, setFormData] =
useState<Partial<PhotoFormData>>(initialPhotoForm); useState<Partial<PhotoFormData>>(initialPhotoForm);
const [formErrors, setFormErrors] = const [formErrors, setFormErrors] =
useState(getInitialErrors(initialPhotoForm)); useState(getFormErrors(initialPhotoForm));
// Update form when EXIF data // Update form when EXIF data
// is refreshed by parent // is refreshed by parent
@ -146,51 +146,57 @@ export default function PhotoForm({
onSubmit={() => blur()} onSubmit={() => blur()}
className="space-y-6" className="space-y-6"
> >
{FORM_METADATA_ENTRIES.map(([key, { {FORM_METADATA_ENTRIES(
label, sortTagsObjectWithoutFavs(uniqueTags ?? [])
note, .map(({ tag, count }) => ({
required, value: tag,
options, annotation: `× ${count}`,
optionsDefaultLabel, }))
readOnly, )
validate, .map(([key, {
capitalize, label,
hideIfEmpty, note,
hideBasedOnCamera, required,
loadingMessage, selectOptions,
type, selectOptionsDefaultLabel,
}]) => tagOptions,
( readOnly,
(!hideIfEmpty || formData[key]) && validate,
!hideBasedOnCamera?.(formData.make) capitalize,
) && hideIfEmpty,
<FieldSetWithStatus hideBasedOnCamera,
key={key} loadingMessage,
id={key} type,
label={label} }]) =>
note={note} (
error={formErrors[key]} (!hideIfEmpty || formData[key]) &&
value={formData[key] ?? ''} !hideBasedOnCamera?.(formData.make)
onChange={value => { ) &&
setFormData({ ...formData, [key]: value }); <FieldSetWithStatus
if (validate) { key={key}
setFormErrors({ ...formErrors, [key]: validate(value) }); id={key}
} label={label}
}} note={note}
selectOptions={options} error={formErrors[key]}
selectOptionsDefaultLabel={optionsDefaultLabel} value={formData[key] ?? ''}
commaSeparatedOptions={key === 'tags' onChange={value => {
? sortTagsWithoutFavs(uniqueTags ?? []) setFormData({ ...formData, [key]: value });
: undefined} if (validate) {
required={required} setFormErrors({ ...formErrors, [key]: validate(value) });
readOnly={readOnly} }
capitalize={capitalize} }}
placeholder={loadingMessage && !formData[key] selectOptions={selectOptions}
? loadingMessage selectOptionsDefaultLabel={selectOptionsDefaultLabel}
: undefined} tagOptions={tagOptions}
loading={loadingMessage && !formData[key] ? true : false} required={required}
type={type} readOnly={readOnly}
/>)} capitalize={capitalize}
placeholder={loadingMessage && !formData[key]
? loadingMessage
: undefined}
loading={loadingMessage && !formData[key] ? true : false}
type={type}
/>)}
<div className="flex gap-3"> <div className="flex gap-3">
<Link <Link
className="button" className="button"

View File

@ -26,6 +26,8 @@ export type FieldSetType =
'password' | 'password' |
'checkbox'; 'checkbox';
export type AnnotatedTag = { value: string, annotation?: string };
type FormMeta = { type FormMeta = {
label: string label: string
note?: string note?: string
@ -39,14 +41,18 @@ type FormMeta = {
hideBasedOnCamera?: (make?: string, mode?: string) => boolean hideBasedOnCamera?: (make?: string, mode?: string) => boolean
loadingMessage?: string loadingMessage?: string
type?: FieldSetType type?: FieldSetType
options?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
optionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
}; };
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = { const FORM_METADATA = (
tagOptions?: AnnotatedTag[]
): Record<keyof PhotoFormData, FormMeta> => ({
title: { label: 'title', capitalize: true }, title: { label: 'title', capitalize: true },
tags: { tags: {
label: 'tags', label: 'tags',
tagOptions,
validate: tags => doesTagsStringIncludeFavs(tags) validate: tags => doesTagsStringIncludeFavs(tags)
? `'${TAG_FAVS}' is a reserved tag` ? `'${TAG_FAVS}' is a reserved tag`
: undefined, : undefined,
@ -66,8 +72,8 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
model: { label: 'camera model' }, model: { label: 'camera model' },
filmSimulation: { filmSimulation: {
label: 'fujifilm simulation', label: 'fujifilm simulation',
options: FILM_SIMULATION_FORM_INPUT_OPTIONS, selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
optionsDefaultLabel: 'Unknown', selectOptionsDefaultLabel: 'Unknown',
hideBasedOnCamera: make => make !== MAKE_FUJIFILM, hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
}, },
focalLength: { label: 'focal length' }, focalLength: { label: 'focal length' },
@ -84,26 +90,28 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
priorityOrder: { label: 'priority order' }, priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', virtual: true }, favorite: { label: 'favorite', type: 'checkbox', virtual: true },
hidden: { label: 'hidden', type: 'checkbox' }, hidden: { label: 'hidden', type: 'checkbox' },
}; });
export const FORM_METADATA_ENTRIES = export const FORM_METADATA_ENTRIES = (
(Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][]) ...args: Parameters<typeof FORM_METADATA>
) =>
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][])
.filter(([_, meta]) => !meta.hide); .filter(([_, meta]) => !meta.hide);
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) => 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<PhotoFormData> formData: Partial<PhotoFormData>
): Partial<Record<keyof PhotoFormData, string>> => ): Partial<Record<keyof PhotoFormData, string>> =>
Object.keys(formData).reduce((acc, key) => ({ Object.keys(formData).reduce((acc, key) => ({
...acc, ...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]), .validate?.(formData[key as keyof PhotoFormData]),
}), {}); }), {});
export const isFormValid = (formData: Partial<PhotoFormData>) => export const isFormValid = (formData: Partial<PhotoFormData>) =>
FORM_METADATA_ENTRIES.every( FORM_METADATA_ENTRIES().every(
([key, { required, validate }]) => ([key, { required, validate }]) =>
(!required || Boolean(formData[key])) && (!required || Boolean(formData[key])) &&
(validate?.(formData[key]) === undefined) (validate?.(formData[key]) === undefined)
@ -191,7 +199,7 @@ export const convertFormDataToPhotoDbInsert = (
if ( if (
key.startsWith('$ACTION_ID_') || key.startsWith('$ACTION_ID_') ||
(photoForm as any)[key] === '' || (photoForm as any)[key] === '' ||
FORM_METADATA[key as keyof PhotoFormData]?.virtual FORM_METADATA()[key as keyof PhotoFormData]?.virtual
) { ) {
delete (photoForm as any)[key]; delete (photoForm as any)[key];
} }

View File

@ -46,6 +46,9 @@ export const sortTagsObject = (
export const sortTagsWithoutFavs = (tags: string[]) => export const sortTagsWithoutFavs = (tags: string[]) =>
sortTags(tags, TAG_FAVS); sortTags(tags, TAG_FAVS);
export const sortTagsObjectWithoutFavs = (tags: Tags) =>
sortTagsObject(tags, TAG_FAVS);
export const descriptionForTaggedPhotos = ( export const descriptionForTaggedPhotos = (
photos: Photo[], photos: Photo[],
dateBased?: boolean, dateBased?: boolean,