Flesh out favs visualization, incorporate into photo form
This commit is contained in:
parent
4c3c2a73ef
commit
0d3155fc7a
@ -37,7 +37,7 @@ export default function EntityLink({
|
|||||||
</>;
|
</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="group inline-flex items-center gap-2 overflow-hidden">
|
<span className="group inline-flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export default function FieldSetWithStatus({
|
|||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
note,
|
note,
|
||||||
|
error,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
selectOptions,
|
selectOptions,
|
||||||
@ -24,6 +25,7 @@ export default function FieldSetWithStatus({
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
note?: string
|
note?: string
|
||||||
|
error?: string
|
||||||
value: string
|
value: string
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
selectOptions?: { value: string, label: string }[]
|
selectOptions?: { value: string, label: string }[]
|
||||||
@ -45,10 +47,14 @@ export default function FieldSetWithStatus({
|
|||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{note &&
|
{note && !error &&
|
||||||
<span className="text-gray-400 dark:text-gray-600">
|
<span className="text-gray-400 dark:text-gray-600">
|
||||||
({note})
|
({note})
|
||||||
</span>}
|
</span>}
|
||||||
|
{error &&
|
||||||
|
<span className="text-error">
|
||||||
|
{error}
|
||||||
|
</span>}
|
||||||
{required &&
|
{required &&
|
||||||
<span className="text-gray-400 dark:text-gray-600">
|
<span className="text-gray-400 dark:text-gray-600">
|
||||||
Required
|
Required
|
||||||
@ -93,7 +99,10 @@ export default function FieldSetWithStatus({
|
|||||||
type={type}
|
type={type}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
readOnly={readOnly || pending}
|
readOnly={readOnly || pending}
|
||||||
className={clsx(type === 'text' && 'w-full')}
|
className={clsx(
|
||||||
|
type === 'text' && 'w-full',
|
||||||
|
error && 'error',
|
||||||
|
)}
|
||||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
FORM_METADATA_ENTRIES,
|
FORM_METADATA_ENTRIES,
|
||||||
PhotoFormData,
|
PhotoFormData,
|
||||||
|
getInitialErrors,
|
||||||
|
isFormValid,
|
||||||
} from './form';
|
} from './form';
|
||||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
import NextImage from 'next/image';
|
import NextImage from 'next/image';
|
||||||
@ -35,6 +37,8 @@ export default function PhotoForm({
|
|||||||
}) {
|
}) {
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
useState<Partial<PhotoFormData>>(initialPhotoForm);
|
||||||
|
const [formErrors, setFormErrors] =
|
||||||
|
useState(getInitialErrors(initialPhotoForm));
|
||||||
|
|
||||||
// Update form when EXIF data
|
// Update form when EXIF data
|
||||||
// is refreshed by parent
|
// 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 (
|
return (
|
||||||
<div className="space-y-8 max-w-[38rem]">
|
<div className="space-y-8 max-w-[38rem]">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -143,6 +144,7 @@ export default function PhotoForm({
|
|||||||
options,
|
options,
|
||||||
optionsDefaultLabel,
|
optionsDefaultLabel,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
validate,
|
||||||
capitalize,
|
capitalize,
|
||||||
hideIfEmpty,
|
hideIfEmpty,
|
||||||
hideBasedOnCamera,
|
hideBasedOnCamera,
|
||||||
@ -158,8 +160,14 @@ export default function PhotoForm({
|
|||||||
id={key}
|
id={key}
|
||||||
label={label}
|
label={label}
|
||||||
note={note}
|
note={note}
|
||||||
|
error={formErrors[key]}
|
||||||
value={formData[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}
|
selectOptions={options}
|
||||||
selectOptionsDefaultLabel={optionsDefaultLabel}
|
selectOptionsDefaultLabel={optionsDefaultLabel}
|
||||||
required={required}
|
required={required}
|
||||||
@ -179,7 +187,7 @@ export default function PhotoForm({
|
|||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<SubmitButtonWithStatus
|
<SubmitButtonWithStatus
|
||||||
disabled={!isFormValid}
|
disabled={!isFormValid(formData)}
|
||||||
>
|
>
|
||||||
{type === 'create' ? 'Create' : 'Update'}
|
{type === 'create' ? 'Create' : 'Update'}
|
||||||
</SubmitButtonWithStatus>
|
</SubmitButtonWithStatus>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import ShareButton from '@/components/ShareButton';
|
|||||||
import PhotoCamera from '../camera/PhotoCamera';
|
import PhotoCamera from '../camera/PhotoCamera';
|
||||||
import { cameraFromPhoto } from '@/camera';
|
import { cameraFromPhoto } from '@/camera';
|
||||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||||
|
import { sortTags } from '@/tag';
|
||||||
|
|
||||||
export default function PhotoLarge({
|
export default function PhotoLarge({
|
||||||
photo,
|
photo,
|
||||||
@ -33,7 +34,7 @@ export default function PhotoLarge({
|
|||||||
shouldShareSimulation?: boolean
|
shouldShareSimulation?: boolean
|
||||||
shouldScrollOnShare?: boolean
|
shouldScrollOnShare?: boolean
|
||||||
}) {
|
}) {
|
||||||
const tagsToShow = photo.tags.filter(t => t !== primaryTag);
|
const tags = sortTags(photo.tags, primaryTag);
|
||||||
|
|
||||||
const camera = cameraFromPhoto(photo);
|
const camera = cameraFromPhoto(photo);
|
||||||
|
|
||||||
@ -77,8 +78,8 @@ export default function PhotoLarge({
|
|||||||
>
|
>
|
||||||
{titleForPhoto(photo)}
|
{titleForPhoto(photo)}
|
||||||
</Link>
|
</Link>
|
||||||
{tagsToShow.length > 0 &&
|
{tags.length > 0 &&
|
||||||
<PhotoTags tags={tagsToShow} />}
|
<PhotoTags tags={tags} />}
|
||||||
</div>
|
</div>
|
||||||
{showCamera && photoHasCameraData(photo) &&
|
{showCamera && photoHasCameraData(photo) &&
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@ -14,14 +14,19 @@ import {
|
|||||||
} from '@/vendors/fujifilm';
|
} from '@/vendors/fujifilm';
|
||||||
import { FilmSimulation } from '@/simulation';
|
import { FilmSimulation } from '@/simulation';
|
||||||
import { GEO_PRIVACY_ENABLED } from '@/site/config';
|
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 = {
|
type FormMeta = {
|
||||||
label: string
|
label: string
|
||||||
note?: string
|
note?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
virtual?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
validate?: (value?: string) => string | undefined
|
||||||
capitalize?: boolean
|
capitalize?: boolean
|
||||||
hideIfEmpty?: boolean
|
hideIfEmpty?: boolean
|
||||||
hideTemporarily?: boolean
|
hideTemporarily?: boolean
|
||||||
@ -34,7 +39,13 @@ type FormMeta = {
|
|||||||
|
|
||||||
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
||||||
title: { label: 'title', capitalize: true },
|
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 },
|
id: { label: 'id', readOnly: true, hideIfEmpty: true },
|
||||||
blurData: {
|
blurData: {
|
||||||
label: 'blur data',
|
label: 'blur data',
|
||||||
@ -65,6 +76,7 @@ const FORM_METADATA: Record<keyof PhotoFormData, FormMeta> = {
|
|||||||
takenAt: { label: 'taken at' },
|
takenAt: { label: 'taken at' },
|
||||||
takenAtNaive: { label: 'taken at (naive)' },
|
takenAtNaive: { label: 'taken at (naive)' },
|
||||||
priorityOrder: { label: 'priority order' },
|
priorityOrder: { label: 'priority order' },
|
||||||
|
favorite: { label: 'favorite', checkbox: true, virtual: true },
|
||||||
hidden: { label: 'hidden', checkbox: true },
|
hidden: { label: 'hidden', checkbox: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,13 +84,31 @@ export const FORM_METADATA_ENTRIES =
|
|||||||
(Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][])
|
(Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][])
|
||||||
.filter(([_, meta]) => !meta.hideTemporarily);
|
.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 = (
|
export const convertPhotoToFormData = (
|
||||||
photo: Photo,
|
photo: Photo,
|
||||||
): PhotoFormData => {
|
): PhotoFormData => {
|
||||||
const valueForKey = (key: keyof Photo, value: any) => {
|
const valueForKey = (key: keyof Photo, value: any) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'tags':
|
case 'tags':
|
||||||
return value?.join ? value.join(', ') : value;
|
return (value ?? [])
|
||||||
|
.filter((tag: string) => tag !== TAG_FAVS)
|
||||||
|
.join(', ');
|
||||||
case 'takenAt':
|
case 'takenAt':
|
||||||
return value?.toISOString ? value.toISOString() : value;
|
return value?.toISOString ? value.toISOString() : value;
|
||||||
case 'hidden':
|
case 'hidden':
|
||||||
@ -92,7 +122,9 @@ export const convertPhotoToFormData = (
|
|||||||
return Object.entries(photo).reduce((photoForm, [key, value]) => ({
|
return Object.entries(photo).reduce((photoForm, [key, value]) => ({
|
||||||
...photoForm,
|
...photoForm,
|
||||||
[key]: valueForKey(key as keyof Photo, value),
|
[key]: valueForKey(key as keyof Photo, value),
|
||||||
}), {} as PhotoFormData);
|
}), {
|
||||||
|
favorite: photo.tags.includes(TAG_FAVS) ? 'true' : 'false',
|
||||||
|
} as PhotoFormData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const convertExifToFormData = (
|
export const convertExifToFormData = (
|
||||||
@ -131,6 +163,11 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
const photoForm = formData instanceof FormData
|
const photoForm = formData instanceof FormData
|
||||||
? Object.fromEntries(formData) as PhotoFormData
|
? Object.fromEntries(formData) as PhotoFormData
|
||||||
: formData;
|
: formData;
|
||||||
|
|
||||||
|
const tags = convertStringToArray(photoForm.tags) ?? [];
|
||||||
|
if (photoForm.favorite === 'true') {
|
||||||
|
tags.push(TAG_FAVS);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse FormData:
|
// Parse FormData:
|
||||||
// - remove server action ID
|
// - remove server action ID
|
||||||
@ -138,7 +175,8 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
Object.keys(photoForm).forEach(key => {
|
Object.keys(photoForm).forEach(key => {
|
||||||
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
|
||||||
) {
|
) {
|
||||||
delete (photoForm as any)[key];
|
delete (photoForm as any)[key];
|
||||||
}
|
}
|
||||||
@ -148,7 +186,7 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
||||||
...(generateId && !photoForm.id) && { id: generateNanoid() },
|
...(generateId && !photoForm.id) && { id: generateNanoid() },
|
||||||
// Convert form strings to arrays
|
// Convert form strings to arrays
|
||||||
tags: convertStringToArray(photoForm.tags),
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
// Convert form strings to numbers
|
// Convert form strings to numbers
|
||||||
aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6),
|
aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6),
|
||||||
focalLength: photoForm.focalLength
|
focalLength: photoForm.focalLength
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Photo } from '..';
|
import { Photo } from '..';
|
||||||
import { FaTag } from 'react-icons/fa';
|
import { FaStar, FaTag } from 'react-icons/fa';
|
||||||
import ImageCaption from './components/ImageCaption';
|
import ImageCaption from './components/ImageCaption';
|
||||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||||
import ImageContainer from './components/ImageContainer';
|
import ImageContainer from './components/ImageContainer';
|
||||||
import { NextImageSize } from '@/services/next-image';
|
import { NextImageSize } from '@/services/next-image';
|
||||||
|
import { isTagFavs } from '@/tag';
|
||||||
|
|
||||||
export default function TagImageResponse({
|
export default function TagImageResponse({
|
||||||
tag,
|
tag,
|
||||||
@ -32,10 +33,19 @@ export default function TagImageResponse({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{ width, height, fontFamily }}>
|
||||||
<FaTag
|
{isTagFavs(tag)
|
||||||
size={height * .067}
|
? <FaStar
|
||||||
style={{ transform: `translateY(${height * .02}px)` }}
|
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>
|
<span>{tag.toUpperCase()}</span>
|
||||||
</ImageCaption>
|
</ImageCaption>
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
|
|||||||
@ -64,6 +64,10 @@
|
|||||||
@apply
|
@apply
|
||||||
rounded-md
|
rounded-md
|
||||||
}
|
}
|
||||||
|
input.error, select.error {
|
||||||
|
@apply
|
||||||
|
border-red-500 dark:border-red-400
|
||||||
|
}
|
||||||
button, .button {
|
button, .button {
|
||||||
@apply
|
@apply
|
||||||
cursor-pointer
|
cursor-pointer
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { FaStar } from 'react-icons/fa';
|
|||||||
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
|
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
|
||||||
import { TAG_FAVS } from '.';
|
import { TAG_FAVS } from '.';
|
||||||
import { pathForTag } from '@/site/paths';
|
import { pathForTag } from '@/site/paths';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export default function FavsTag({
|
export default function FavsTag({
|
||||||
type,
|
type,
|
||||||
@ -27,7 +28,10 @@ export default function FavsTag({
|
|||||||
icon={!badged &&
|
icon={!badged &&
|
||||||
<FaStar
|
<FaStar
|
||||||
size={12}
|
size={12}
|
||||||
className="text-amber-500 translate-y-[4px]"
|
className={clsx(
|
||||||
|
'text-amber-500',
|
||||||
|
'translate-x-[-1px] translate-y-[3.5px]',
|
||||||
|
)}
|
||||||
/>}
|
/>}
|
||||||
type={type}
|
type={type}
|
||||||
hoverEntity={countOnHover}
|
hoverEntity={countOnHover}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import PhotoTag from '@/tag/PhotoTag';
|
import PhotoTag from '@/tag/PhotoTag';
|
||||||
|
import { isTagFavs } from '.';
|
||||||
|
import FavsTag from './FavsTag';
|
||||||
|
|
||||||
export default function PhotoTags({
|
export default function PhotoTags({
|
||||||
tags,
|
tags,
|
||||||
@ -9,7 +11,9 @@ export default function PhotoTags({
|
|||||||
<div className="-space-y-0.5">
|
<div className="-space-y-0.5">
|
||||||
{tags.map(tag =>
|
{tags.map(tag =>
|
||||||
<div key={tag}>
|
<div key={tag}>
|
||||||
<PhotoTag tag={tag} />
|
{isTagFavs(tag)
|
||||||
|
? <FavsTag />
|
||||||
|
: <PhotoTag tag={tag} />}
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,6 +26,13 @@ export const titleForTag = (
|
|||||||
photoQuantityText(explicitCount ?? photos.length),
|
photoQuantityText(explicitCount ?? photos.length),
|
||||||
].join(' ');
|
].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 = (
|
export const descriptionForTaggedPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
@ -53,4 +60,4 @@ export const generateMetaForTag = (
|
|||||||
images: absolutePathForTagImage(tag),
|
images: absolutePathForTagImage(tag),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const isTagFavs = (tag: string) => tag === TAG_FAVS;
|
export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user