Flesh out favs visualization, incorporate into photo form

This commit is contained in:
Sam Becker 2024-01-01 01:28:29 -05:00
parent 4c3c2a73ef
commit 0d3155fc7a
10 changed files with 110 additions and 25 deletions

View File

@ -37,7 +37,7 @@ export default function EntityLink({
</>;
return (
<span className="group inline-flex items-center gap-2 overflow-hidden">
<span className="group inline-flex items-center gap-2">
<Link
href={href}
title={title}

View File

@ -9,6 +9,7 @@ export default function FieldSetWithStatus({
id,
label,
note,
error,
value,
onChange,
selectOptions,
@ -24,6 +25,7 @@ export default function FieldSetWithStatus({
id: string
label: string
note?: string
error?: string
value: string
onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[]
@ -45,10 +47,14 @@ export default function FieldSetWithStatus({
htmlFor={id}
>
{label}
{note &&
{note && !error &&
<span className="text-gray-400 dark:text-gray-600">
({note})
</span>}
{error &&
<span className="text-error">
{error}
</span>}
{required &&
<span className="text-gray-400 dark:text-gray-600">
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}
/>}
</div>

View File

@ -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<Partial<PhotoFormData>>(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 (
<div className="space-y-8 max-w-[38rem]">
<div className="flex gap-2">
@ -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
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
disabled={!isFormValid(formData)}
>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>

View File

@ -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)}
</Link>
{tagsToShow.length > 0 &&
<PhotoTags tags={tagsToShow} />}
{tags.length > 0 &&
<PhotoTags tags={tags} />}
</div>
{showCamera && photoHasCameraData(photo) &&
<div className="space-y-0.5">

View File

@ -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<keyof PhotoDbInsert, string>;
type VirtualFields = 'favorite';
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
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<keyof PhotoFormData, FormMeta> = {
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<keyof PhotoFormData, FormMeta> = {
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<PhotoFormData>
): Partial<Record<keyof PhotoFormData, string>> =>
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<PhotoFormData>) =>
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

View File

@ -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({
}}
/>
<ImageCaption {...{ width, height, fontFamily }}>
<FaTag
size={height * .067}
style={{ transform: `translateY(${height * .02}px)` }}
/>
{isTagFavs(tag)
? <FaStar
size={height * .074}
style={{
transform: `translateY(${height * .01}px)`,
// Fix horizontal distortion in icon size
width: height * .08,
}}
/>
: <FaTag
size={height * .067}
style={{ transform: `translateY(${height * .02}px)` }}
/>}
<span>{tag.toUpperCase()}</span>
</ImageCaption>
</ImageContainer>

View File

@ -64,6 +64,10 @@
@apply
rounded-md
}
input.error, select.error {
@apply
border-red-500 dark:border-red-400
}
button, .button {
@apply
cursor-pointer

View File

@ -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 &&
<FaStar
size={12}
className="text-amber-500 translate-y-[4px]"
className={clsx(
'text-amber-500',
'translate-x-[-1px] translate-y-[3.5px]',
)}
/>}
type={type}
hoverEntity={countOnHover}

View File

@ -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({
<div className="-space-y-0.5">
{tags.map(tag =>
<div key={tag}>
<PhotoTag tag={tag} />
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag tag={tag} />}
</div>)}
</div>
);

View File

@ -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;