From 0d3155fc7a82bfed5e1f4d9eb567e1a6417aaa0e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 1 Jan 2024 01:28:29 -0500 Subject: [PATCH] Flesh out favs visualization, incorporate into photo form --- src/components/EntityLink.tsx | 2 +- src/components/FieldSetWithStatus.tsx | 13 ++++- src/photo/PhotoForm.tsx | 18 +++++-- src/photo/PhotoLarge.tsx | 7 +-- src/photo/form.ts | 50 ++++++++++++++++--- src/photo/image-response/TagImageResponse.tsx | 20 ++++++-- src/site/globals.css | 4 ++ src/tag/FavsTag.tsx | 6 ++- src/tag/PhotoTags.tsx | 6 ++- src/tag/index.ts | 9 +++- 10 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/components/EntityLink.tsx b/src/components/EntityLink.tsx index 220d20fa..fc4028f0 100644 --- a/src/components/EntityLink.tsx +++ b/src/components/EntityLink.tsx @@ -37,7 +37,7 @@ export default function EntityLink({ ; return ( - + void selectOptions?: { value: string, label: string }[] @@ -45,10 +47,14 @@ export default function FieldSetWithStatus({ htmlFor={id} > {label} - {note && + {note && !error && ({note}) } + {error && + + {error} + } {required && Required @@ -93,7 +99,10 @@ export default function FieldSetWithStatus({ type={type} autoComplete="off" readOnly={readOnly || pending} - className={clsx(type === 'text' && 'w-full')} + className={clsx( + type === 'text' && 'w-full', + error && 'error', + )} autoCapitalize={!capitalize ? 'off' : undefined} />} diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index d11e23ae..340600b4 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, + getInitialErrors, + isFormValid, } from './form'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import NextImage from 'next/image'; @@ -35,6 +37,8 @@ export default function PhotoForm({ }) { const [formData, setFormData] = useState>(initialPhotoForm); + const [formErrors, setFormErrors] = + useState(getInitialErrors(initialPhotoForm)); // Update form when EXIF data // is refreshed by parent @@ -97,9 +101,6 @@ export default function PhotoForm({ })); }, []); - const isFormValid = FORM_METADATA_ENTRIES.every(([key, { required }]) => - !required || Boolean(formData[key])); - return (
@@ -143,6 +144,7 @@ export default function PhotoForm({ options, optionsDefaultLabel, readOnly, + validate, capitalize, hideIfEmpty, hideBasedOnCamera, @@ -158,8 +160,14 @@ export default function PhotoForm({ id={key} label={label} note={note} + error={formErrors[key]} value={formData[key] ?? ''} - onChange={value => setFormData({ ...formData, [key]: value })} + onChange={value => { + setFormData({ ...formData, [key]: value }); + if (validate) { + setFormErrors({ ...formErrors, [key]: validate(value) }); + } + }} selectOptions={options} selectOptionsDefaultLabel={optionsDefaultLabel} required={required} @@ -179,7 +187,7 @@ export default function PhotoForm({ Cancel {type === 'create' ? 'Create' : 'Update'} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 9854d814..138ccc33 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -9,6 +9,7 @@ import ShareButton from '@/components/ShareButton'; import PhotoCamera from '../camera/PhotoCamera'; import { cameraFromPhoto } from '@/camera'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import { sortTags } from '@/tag'; export default function PhotoLarge({ photo, @@ -33,7 +34,7 @@ export default function PhotoLarge({ shouldShareSimulation?: boolean shouldScrollOnShare?: boolean }) { - const tagsToShow = photo.tags.filter(t => t !== primaryTag); + const tags = sortTags(photo.tags, primaryTag); const camera = cameraFromPhoto(photo); @@ -77,8 +78,8 @@ export default function PhotoLarge({ > {titleForPhoto(photo)} - {tagsToShow.length > 0 && - } + {tags.length > 0 && + }
{showCamera && photoHasCameraData(photo) &&
diff --git a/src/photo/form.ts b/src/photo/form.ts index 57103c49..864d2bce 100644 --- a/src/photo/form.ts +++ b/src/photo/form.ts @@ -14,14 +14,19 @@ import { } from '@/vendors/fujifilm'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/site/config'; +import { TAG_FAVS } from '@/tag'; -export type PhotoFormData = Record; +type VirtualFields = 'favorite'; + +export type PhotoFormData = Record; type FormMeta = { label: string note?: string required?: boolean + virtual?: boolean readOnly?: boolean + validate?: (value?: string) => string | undefined capitalize?: boolean hideIfEmpty?: boolean hideTemporarily?: boolean @@ -34,7 +39,13 @@ type FormMeta = { const FORM_METADATA: Record = { title: { label: 'title', capitalize: true }, - tags: { label: 'tags', note: 'comma-separated values' }, + tags: { + label: 'tags', + note: 'comma-separated values', + validate: tags => tags?.toLowerCase().includes(TAG_FAVS) + ? `'${TAG_FAVS}' is a reserved tag` + : undefined, + }, id: { label: 'id', readOnly: true, hideIfEmpty: true }, blurData: { label: 'blur data', @@ -65,6 +76,7 @@ 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 }, }; @@ -72,13 +84,31 @@ export const FORM_METADATA_ENTRIES = (Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][]) .filter(([_, meta]) => !meta.hideTemporarily); +export const getInitialErrors = ( + formData: Partial +): Partial> => + Object.keys(formData).reduce((acc, key) => ({ + ...acc, + [key]: FORM_METADATA_ENTRIES.find(([k]) => k === key)?.[1] + .validate?.(formData[key as keyof PhotoFormData]), + }), {}); + +export const isFormValid = (formData: Partial) => + FORM_METADATA_ENTRIES.every( + ([key, { required, validate }]) => + (!required || Boolean(formData[key])) && + (validate?.(formData[key]) === undefined) + ); + export const convertPhotoToFormData = ( photo: Photo, ): PhotoFormData => { const valueForKey = (key: keyof Photo, value: any) => { switch (key) { case 'tags': - return value?.join ? value.join(', ') : value; + return (value ?? []) + .filter((tag: string) => tag !== TAG_FAVS) + .join(', '); case 'takenAt': return value?.toISOString ? value.toISOString() : value; case 'hidden': @@ -92,7 +122,9 @@ export const convertPhotoToFormData = ( return Object.entries(photo).reduce((photoForm, [key, value]) => ({ ...photoForm, [key]: valueForKey(key as keyof Photo, value), - }), {} as PhotoFormData); + }), { + favorite: photo.tags.includes(TAG_FAVS) ? 'true' : 'false', + } as PhotoFormData); }; export const convertExifToFormData = ( @@ -131,6 +163,11 @@ export const convertFormDataToPhotoDbInsert = ( const photoForm = formData instanceof FormData ? Object.fromEntries(formData) as PhotoFormData : formData; + + const tags = convertStringToArray(photoForm.tags) ?? []; + if (photoForm.favorite === 'true') { + tags.push(TAG_FAVS); + } // Parse FormData: // - remove server action ID @@ -138,7 +175,8 @@ export const convertFormDataToPhotoDbInsert = ( Object.keys(photoForm).forEach(key => { if ( key.startsWith('$ACTION_ID_') || - (photoForm as any)[key] === '' + (photoForm as any)[key] === '' || + FORM_METADATA[key as keyof PhotoFormData]?.virtual ) { delete (photoForm as any)[key]; } @@ -148,7 +186,7 @@ export const convertFormDataToPhotoDbInsert = ( ...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }), ...(generateId && !photoForm.id) && { id: generateNanoid() }, // Convert form strings to arrays - tags: convertStringToArray(photoForm.tags), + tags: tags.length > 0 ? tags : undefined, // Convert form strings to numbers aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6), focalLength: photoForm.focalLength diff --git a/src/photo/image-response/TagImageResponse.tsx b/src/photo/image-response/TagImageResponse.tsx index 2f5f8c56..038d6fd0 100644 --- a/src/photo/image-response/TagImageResponse.tsx +++ b/src/photo/image-response/TagImageResponse.tsx @@ -1,9 +1,10 @@ import { Photo } from '..'; -import { FaTag } from 'react-icons/fa'; +import { FaStar, FaTag } from 'react-icons/fa'; import ImageCaption from './components/ImageCaption'; import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImageContainer from './components/ImageContainer'; import { NextImageSize } from '@/services/next-image'; +import { isTagFavs } from '@/tag'; export default function TagImageResponse({ tag, @@ -32,10 +33,19 @@ export default function TagImageResponse({ }} /> - + {isTagFavs(tag) + ? + : } {tag.toUpperCase()} diff --git a/src/site/globals.css b/src/site/globals.css index dc8ba265..6a71046c 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -64,6 +64,10 @@ @apply rounded-md } + input.error, select.error { + @apply + border-red-500 dark:border-red-400 + } button, .button { @apply cursor-pointer diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index fc92975a..3590f015 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -2,6 +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'; export default function FavsTag({ type, @@ -27,7 +28,10 @@ export default function FavsTag({ icon={!badged && } type={type} hoverEntity={countOnHover} diff --git a/src/tag/PhotoTags.tsx b/src/tag/PhotoTags.tsx index 79d2a212..4804dc17 100644 --- a/src/tag/PhotoTags.tsx +++ b/src/tag/PhotoTags.tsx @@ -1,4 +1,6 @@ import PhotoTag from '@/tag/PhotoTag'; +import { isTagFavs } from '.'; +import FavsTag from './FavsTag'; export default function PhotoTags({ tags, @@ -9,7 +11,9 @@ export default function PhotoTags({
{tags.map(tag =>
- + {isTagFavs(tag) + ? + : }
)}
); diff --git a/src/tag/index.ts b/src/tag/index.ts index 1b773b93..8bc7c2da 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -26,6 +26,13 @@ export const titleForTag = ( photoQuantityText(explicitCount ?? photos.length), ].join(' '); +export const sortTags = ( + tags: string[], + tagToHide?: string, +) => tags + .filter(t => t !== tagToHide) + .sort((a, b) => isTagFavs(a) ? -1 : a.localeCompare(b)); + export const descriptionForTaggedPhotos = ( photos: Photo[], dateBased?: boolean, @@ -53,4 +60,4 @@ export const generateMetaForTag = ( images: absolutePathForTagImage(tag), }); -export const isTagFavs = (tag: string) => tag === TAG_FAVS; +export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;