Merge pull request #32 from sambecker/favs

Introduce 'Favs'
This commit is contained in:
Sam Becker 2024-01-02 23:17:45 -05:00 committed by GitHub
commit bf34d54fb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 249 additions and 85 deletions

View File

@ -11,6 +11,7 @@
"exif", "exif",
"exifr", "exifr",
"exiftool", "exiftool",
"favs",
"ghijklmnopqrstuv", "ghijklmnopqrstuv",
"hgetall", "hgetall",
"hset", "hset",

View File

@ -1,10 +1,4 @@
import { import { getPhotosCached } from '@/cache';
getPhotosCached,
getPhotosCountCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/cache';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
@ -17,7 +11,7 @@ import {
getPaginationForSearchParams, getPaginationForSearchParams,
} from '@/site/pagination'; } from '@/site/pagination';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar'; import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { SHOW_FILM_SIMULATIONS } from '@/site/config'; import { getPhotoSidebarDataCached } from '@/photo/data';
export const runtime = 'edge'; export const runtime = 'edge';
@ -37,10 +31,7 @@ export default async function GridPage({ searchParams }: PaginationParams) {
simulations, simulations,
] = await Promise.all([ ] = await Promise.all([
getPhotosCached({ limit }), getPhotosCached({ limit }),
getPhotosCountCached(), ...getPhotoSidebarDataCached(),
getUniqueTagsCached(),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
]); ]);
const showMorePath = photosCount > photos.length const showMorePath = photosCount > photos.length

View File

@ -1,20 +1,16 @@
import { import { getPhotosCached } from '@/cache';
getPhotosCached,
getPhotosCountCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/cache';
import InfoBlock from '@/components/InfoBlock'; import InfoBlock from '@/components/InfoBlock';
import RedirectOnDesktop from '@/components/RedirectOnDesktop'; import RedirectOnDesktop from '@/components/RedirectOnDesktop';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos } from '@/photo'; import { generateOgImageMetaForPhotos } from '@/photo';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar'; import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarDataCached } from '@/photo/data';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/photo/image-response'; import { MAX_PHOTOS_TO_SHOW_OG } from '@/photo/image-response';
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
import { PATH_GRID } from '@/site/paths'; import { PATH_GRID } from '@/site/paths';
import { Metadata } from 'next'; import { Metadata } from 'next';
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG }); const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG });
return generateOgImageMetaForPhotos(photos); return generateOgImageMetaForPhotos(photos);
@ -26,12 +22,7 @@ export default async function SetsPage() {
tags, tags,
cameras, cameras,
simulations, simulations,
] = await Promise.all([ ] = await Promise.all(getPhotoSidebarDataCached());
getPhotosCountCached(),
getUniqueTagsCached(),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
]);
return ( return (
<SiteGrid <SiteGrid

View File

@ -10,7 +10,7 @@ export default function PhotoCamera({
hideAppleIcon, hideAppleIcon,
type = 'icon-first', type = 'icon-first',
badged, badged,
dim, contrast,
countOnHover, countOnHover,
}: { }: {
camera: Camera camera: Camera
@ -45,7 +45,7 @@ export default function PhotoCamera({
/>} />}
type={showAppleIcon && isCameraApple ? 'icon-first' : type} type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged} badged={badged}
dim={dim} contrast={contrast}
hoverEntity={countOnHover} hoverEntity={countOnHover}
/> />
); );

View File

@ -2,30 +2,39 @@ import { clsx } from 'clsx';
export default function Badge({ export default function Badge({
children, children,
type = 'primary', type = 'large',
highContrast,
uppercase, uppercase,
interactive, interactive,
}: { }: {
children: React.ReactNode children: React.ReactNode
type?: 'primary' | 'secondary' | 'text-only' type?: 'large' | 'small' | 'text-only'
highContrast?: boolean
uppercase?: boolean uppercase?: boolean
interactive?: boolean interactive?: boolean
}) { }) {
const stylesForType = () => { const stylesForType = () => {
switch (type) { switch (type) {
case 'primary': return clsx( case 'large':
'px-1.5 py-[0.3rem] rounded-md', return clsx(
'bg-gray-100/80 dark:bg-gray-900/80', 'px-1.5 py-[0.3rem] rounded-md',
'border border-gray-200/60 dark:border-gray-800/75' 'bg-gray-100/80 dark:bg-gray-900/80',
); 'border border-gray-200/60 dark:border-gray-800/75'
case 'secondary': return clsx( );
'px-[0.3rem] py-1 rounded-[0.25rem]', case 'small':
'bg-gray-300/30 dark:bg-gray-700/50', return clsx(
'text-medium', 'px-[0.3rem] py-1 rounded-[0.25rem]',
'font-medium text-[0.7rem]', 'text-[0.7rem] font-medium',
interactive && 'hover:text-gray-900 dark:hover:text-gray-100', highContrast
interactive && 'active:bg-gray-200 dark:active:bg-gray-700/60', ? 'text-invert bg-main'
); : 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
interactive && highContrast
? 'hover:opacity-70'
: 'hover:text-gray-900 dark:hover:text-gray-100',
interactive && highContrast
? 'active:opacity-90'
: 'active:bg-gray-200 dark:active:bg-gray-700/60',
);
} }
}; };
return ( return (

View File

@ -6,7 +6,7 @@ import { clsx } from 'clsx';
export interface EntityLinkExternalProps { export interface EntityLinkExternalProps {
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only' type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
badged?: boolean badged?: boolean
dim?: boolean contrast?: 'low' | 'medium' | 'high'
} }
export default function EntityLink({ export default function EntityLink({
@ -17,8 +17,8 @@ export default function EntityLink({
title, title,
type = 'icon-first', type = 'icon-first',
badged, badged,
contrast,
hoverEntity, hoverEntity,
dim,
}: { }: {
label: ReactNode label: ReactNode
labelSmall?: ReactNode labelSmall?: ReactNode
@ -37,20 +37,25 @@ 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}
className={clsx( className={clsx(
'inline-flex gap-[0.23rem]', 'inline-flex gap-[0.23rem]',
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100', !badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
dim && 'text-dim', contrast === 'low' && 'text-dim',
)} )}
> >
{type !== 'icon-only' && <> {type !== 'icon-only' && <>
{badged {badged
? <span className="h-6 inline-flex items-center"> ? <span className="h-6 inline-flex items-center">
<Badge type="secondary" uppercase interactive> <Badge
type="small"
highContrast={contrast === 'high'}
uppercase
interactive
>
{renderLabel()} {renderLabel()}
</Badge> </Badge>
</span> </span>
@ -61,9 +66,11 @@ export default function EntityLink({
{icon && type !== 'text-only' && {icon && type !== 'text-only' &&
<span className={clsx( <span className={clsx(
'flex-shrink-0', 'flex-shrink-0',
'text-dim inline-flex min-w-[0.9rem]', 'inline-flex min-w-[0.9rem]',
contrast === 'low' ? 'text-dim' : 'text-main',
type === 'icon-first' && 'order-first', type === 'icon-first' && 'order-first',
badged && 'translate-y-[4px]', badged && 'translate-y-[4px]',
hoverEntity !== undefined && 'group-hover:hidden',
)}> )}>
{icon} {icon}
</span>} </span>}

View File

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

View File

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

View File

@ -5,12 +5,11 @@ import PhotoTag from '@/tag/PhotoTag';
import { FaTag } from 'react-icons/fa'; import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io'; import { IoMdCamera } from 'react-icons/io';
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.'; import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
import { Tags } from '@/tag'; import { TAG_FAVS, Tags } from '@/tag';
import PhotoFilmSimulation from import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
'@/simulation/PhotoFilmSimulation'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import PhotoFilmSimulationIcon from
'@/simulation/PhotoFilmSimulationIcon';
import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation'; import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation';
import FavsTag from '../tag/FavsTag';
export default function PhotoGridSidebar({ export default function PhotoGridSidebar({
tags, tags,
@ -32,8 +31,14 @@ export default function PhotoGridSidebar({
{tags.length > 0 && <HeaderList {tags.length > 0 && <HeaderList
title='Tags' title='Tags'
icon={<FaTag size={12} className="text-icon" />} icon={<FaTag size={12} className="text-icon" />}
items={tags.map(({ tag, count }) => items={tags.map(({ tag, count }) => tag === TAG_FAVS
<PhotoTag ? <FavsTag
key={TAG_FAVS}
countOnHover={count}
type="icon-last"
badged
/>
: <PhotoTag
key={tag} key={tag}
tag={tag} tag={tag}
type="text-only" type="text-only"

View File

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

17
src/photo/data.ts Normal file
View File

@ -0,0 +1,17 @@
import {
getPhotosCountCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/cache';
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
import { TAG_FAVS } from '@/tag';
export const getPhotoSidebarDataCached = () => [
getPhotosCountCached(),
getUniqueTagsCached().then(tags =>
tags.filter(({ tag }) => tag === TAG_FAVS).concat(
tags.filter(({ tag }) => tag !== TAG_FAVS))),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
] as const;

View File

@ -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, doesTagsStringIncludeFavs } 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 => doesTagsStringIncludeFavs(tags)
? `'${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,33 @@ 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)
);
// CREATE FORM DATA: FROM PHOTO
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,9 +124,13 @@ 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);
}; };
// CREATE FORM DATA: FROM EXIF
export const convertExifToFormData = ( export const convertExifToFormData = (
data: ExifData, data: ExifData,
filmSimulation?: FilmSimulation, filmSimulation?: FilmSimulation,
@ -124,6 +160,8 @@ export const convertExifToFormData = (
: undefined, : undefined,
}); });
// PREPARE FORM FOR DB INSERT
export const convertFormDataToPhotoDbInsert = ( export const convertFormDataToPhotoDbInsert = (
formData: FormData | PhotoFormData, formData: FormData | PhotoFormData,
generateId?: boolean, generateId?: boolean,
@ -131,6 +169,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 +181,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 +192,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

View File

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

View File

@ -8,7 +8,7 @@ export default function PhotoFilmSimulation({
simulation, simulation,
type = 'icon-last', type = 'icon-last',
badged = true, badged = true,
dim, contrast,
countOnHover, countOnHover,
}: { }: {
simulation: FilmSimulation simulation: FilmSimulation
@ -28,7 +28,7 @@ export default function PhotoFilmSimulation({
title={`Film Simulation: ${large}`} title={`Film Simulation: ${large}`}
type={type} type={type}
badged={badged} badged={badged}
dim={dim} contrast={contrast}
hoverEntity={countOnHover} hoverEntity={countOnHover}
/> />
); );

View File

@ -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
@ -132,4 +136,8 @@
@apply @apply
text-red-500 dark:text-red-400 text-red-500 dark:text-red-400
} }
.bg-main {
@apply
bg-gray-900 dark:bg-gray-100
}
} }

42
src/tag/FavsTag.tsx Normal file
View File

@ -0,0 +1,42 @@
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,
badged,
contrast,
countOnHover,
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
return (
<EntityLink
label={
badged
? <span className="inline-flex gap-1">
{TAG_FAVS}
<FaStar
size={10}
className="text-amber-500"
/>
</span>
: TAG_FAVS}
href={pathForTag(TAG_FAVS)}
icon={!badged &&
<FaStar
size={12}
className={clsx(
'text-amber-500',
'translate-x-[-1px] translate-y-[3.5px]',
)}
/>}
type={type}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}
/>
);
}

View File

@ -7,7 +7,7 @@ export default function PhotoTag({
tag, tag,
type, type,
badged, badged,
dim, contrast,
countOnHover, countOnHover,
}: { }: {
tag: string tag: string
@ -23,7 +23,7 @@ export default function PhotoTag({
/>} />}
type={type} type={type}
badged={badged} badged={badged}
dim={dim} contrast={contrast}
hoverEntity={countOnHover} hoverEntity={countOnHover}
/> />
); );

View File

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

View File

@ -1,8 +1,9 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import PhotoTag from './PhotoTag'; import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos } from '.'; import { descriptionForTaggedPhotos, isTagFavs } from '.';
import { pathForTagShare } from '@/site/paths'; import { pathForTagShare } from '@/site/paths';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoSetHeader from '@/photo/PhotoSetHeader';
import FavsTag from './FavsTag';
export default function TagHeader({ export default function TagHeader({
tag, tag,
@ -19,7 +20,9 @@ export default function TagHeader({
}) { }) {
return ( return (
<PhotoSetHeader <PhotoSetHeader
entity={<PhotoTag tag={tag} />} entity={isTagFavs(tag)
? <FavsTag />
: <PhotoTag tag={tag} />}
entityVerb="Tagged" entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
photos={photos} photos={photos}

View File

@ -5,7 +5,9 @@ import {
photoQuantityText, photoQuantityText,
} from '@/photo'; } from '@/photo';
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths'; import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
import { capitalizeWords } from '@/utility/string'; import { capitalizeWords, convertStringToArray } from '@/utility/string';
export const TAG_FAVS = 'favs';
export type Tags = { export type Tags = {
tag: string tag: string
@ -15,6 +17,9 @@ export type Tags = {
export const formatTag = (tag?: string) => export const formatTag = (tag?: string) =>
capitalizeWords(tag?.replaceAll('-', ' ')); capitalizeWords(tag?.replaceAll('-', ' '));
export const doesTagsStringIncludeFavs = (tags?: string) =>
convertStringToArray(tags)?.some(tag => isTagFavs(tag));
export const titleForTag = ( export const titleForTag = (
tag: string, tag: string,
photos:Photo[], photos:Photo[],
@ -24,6 +29,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,
@ -50,3 +62,5 @@ export const generateMetaForTag = (
descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange), descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange),
images: absolutePathForTagImage(tag), images: absolutePathForTagImage(tag),
}); });
export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;