commit
bf34d54fb3
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -11,6 +11,7 @@
|
||||
"exif",
|
||||
"exifr",
|
||||
"exiftool",
|
||||
"favs",
|
||||
"ghijklmnopqrstuv",
|
||||
"hgetall",
|
||||
"hset",
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosCountCached,
|
||||
getUniqueCamerasCached,
|
||||
getUniqueFilmSimulationsCached,
|
||||
getUniqueTagsCached,
|
||||
} from '@/cache';
|
||||
import { getPhotosCached } from '@/cache';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
@ -17,7 +11,7 @@ import {
|
||||
getPaginationForSearchParams,
|
||||
} from '@/site/pagination';
|
||||
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
|
||||
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
|
||||
import { getPhotoSidebarDataCached } from '@/photo/data';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -37,10 +31,7 @@ export default async function GridPage({ searchParams }: PaginationParams) {
|
||||
simulations,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit }),
|
||||
getPhotosCountCached(),
|
||||
getUniqueTagsCached(),
|
||||
getUniqueCamerasCached(),
|
||||
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
|
||||
...getPhotoSidebarDataCached(),
|
||||
]);
|
||||
|
||||
const showMorePath = photosCount > photos.length
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosCountCached,
|
||||
getUniqueCamerasCached,
|
||||
getUniqueFilmSimulationsCached,
|
||||
getUniqueTagsCached,
|
||||
} from '@/cache';
|
||||
import { getPhotosCached } from '@/cache';
|
||||
import InfoBlock from '@/components/InfoBlock';
|
||||
import RedirectOnDesktop from '@/components/RedirectOnDesktop';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { generateOgImageMetaForPhotos } from '@/photo';
|
||||
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
|
||||
import { getPhotoSidebarDataCached } from '@/photo/data';
|
||||
import { MAX_PHOTOS_TO_SHOW_OG } from '@/photo/image-response';
|
||||
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
|
||||
import { PATH_GRID } from '@/site/paths';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const photos = await getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_OG });
|
||||
return generateOgImageMetaForPhotos(photos);
|
||||
@ -26,12 +22,7 @@ export default async function SetsPage() {
|
||||
tags,
|
||||
cameras,
|
||||
simulations,
|
||||
] = await Promise.all([
|
||||
getPhotosCountCached(),
|
||||
getUniqueTagsCached(),
|
||||
getUniqueCamerasCached(),
|
||||
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],
|
||||
]);
|
||||
] = await Promise.all(getPhotoSidebarDataCached());
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
|
||||
@ -10,7 +10,7 @@ export default function PhotoCamera({
|
||||
hideAppleIcon,
|
||||
type = 'icon-first',
|
||||
badged,
|
||||
dim,
|
||||
contrast,
|
||||
countOnHover,
|
||||
}: {
|
||||
camera: Camera
|
||||
@ -45,7 +45,7 @@ export default function PhotoCamera({
|
||||
/>}
|
||||
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
|
||||
badged={badged}
|
||||
dim={dim}
|
||||
contrast={contrast}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -2,30 +2,39 @@ import { clsx } from 'clsx';
|
||||
|
||||
export default function Badge({
|
||||
children,
|
||||
type = 'primary',
|
||||
type = 'large',
|
||||
highContrast,
|
||||
uppercase,
|
||||
interactive,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
type?: 'primary' | 'secondary' | 'text-only'
|
||||
type?: 'large' | 'small' | 'text-only'
|
||||
highContrast?: boolean
|
||||
uppercase?: boolean
|
||||
interactive?: boolean
|
||||
}) {
|
||||
const stylesForType = () => {
|
||||
switch (type) {
|
||||
case 'primary': return clsx(
|
||||
'px-1.5 py-[0.3rem] rounded-md',
|
||||
'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]',
|
||||
'bg-gray-300/30 dark:bg-gray-700/50',
|
||||
'text-medium',
|
||||
'font-medium text-[0.7rem]',
|
||||
interactive && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
interactive && 'active:bg-gray-200 dark:active:bg-gray-700/60',
|
||||
);
|
||||
case 'large':
|
||||
return clsx(
|
||||
'px-1.5 py-[0.3rem] rounded-md',
|
||||
'bg-gray-100/80 dark:bg-gray-900/80',
|
||||
'border border-gray-200/60 dark:border-gray-800/75'
|
||||
);
|
||||
case 'small':
|
||||
return clsx(
|
||||
'px-[0.3rem] py-1 rounded-[0.25rem]',
|
||||
'text-[0.7rem] font-medium',
|
||||
highContrast
|
||||
? '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 (
|
||||
|
||||
@ -6,7 +6,7 @@ import { clsx } from 'clsx';
|
||||
export interface EntityLinkExternalProps {
|
||||
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
|
||||
badged?: boolean
|
||||
dim?: boolean
|
||||
contrast?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
export default function EntityLink({
|
||||
@ -17,8 +17,8 @@ export default function EntityLink({
|
||||
title,
|
||||
type = 'icon-first',
|
||||
badged,
|
||||
contrast,
|
||||
hoverEntity,
|
||||
dim,
|
||||
}: {
|
||||
label: ReactNode
|
||||
labelSmall?: ReactNode
|
||||
@ -37,20 +37,25 @@ 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}
|
||||
className={clsx(
|
||||
'inline-flex gap-[0.23rem]',
|
||||
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
|
||||
dim && 'text-dim',
|
||||
contrast === 'low' && 'text-dim',
|
||||
)}
|
||||
>
|
||||
{type !== 'icon-only' && <>
|
||||
{badged
|
||||
? <span className="h-6 inline-flex items-center">
|
||||
<Badge type="secondary" uppercase interactive>
|
||||
<Badge
|
||||
type="small"
|
||||
highContrast={contrast === 'high'}
|
||||
uppercase
|
||||
interactive
|
||||
>
|
||||
{renderLabel()}
|
||||
</Badge>
|
||||
</span>
|
||||
@ -61,9 +66,11 @@ export default function EntityLink({
|
||||
{icon && type !== 'text-only' &&
|
||||
<span className={clsx(
|
||||
'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',
|
||||
badged && 'translate-y-[4px]',
|
||||
hoverEntity !== undefined && 'group-hover:hidden',
|
||||
)}>
|
||||
{icon}
|
||||
</span>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -5,12 +5,11 @@ import PhotoTag from '@/tag/PhotoTag';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
|
||||
import { Tags } from '@/tag';
|
||||
import PhotoFilmSimulation from
|
||||
'@/simulation/PhotoFilmSimulation';
|
||||
import PhotoFilmSimulationIcon from
|
||||
'@/simulation/PhotoFilmSimulationIcon';
|
||||
import { TAG_FAVS, Tags } from '@/tag';
|
||||
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation';
|
||||
import FavsTag from '../tag/FavsTag';
|
||||
|
||||
export default function PhotoGridSidebar({
|
||||
tags,
|
||||
@ -32,8 +31,14 @@ export default function PhotoGridSidebar({
|
||||
{tags.length > 0 && <HeaderList
|
||||
title='Tags'
|
||||
icon={<FaTag size={12} className="text-icon" />}
|
||||
items={tags.map(({ tag, count }) =>
|
||||
<PhotoTag
|
||||
items={tags.map(({ tag, count }) => tag === TAG_FAVS
|
||||
? <FavsTag
|
||||
key={TAG_FAVS}
|
||||
countOnHover={count}
|
||||
type="icon-last"
|
||||
badged
|
||||
/>
|
||||
: <PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
type="text-only"
|
||||
|
||||
@ -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">
|
||||
|
||||
17
src/photo/data.ts
Normal file
17
src/photo/data.ts
Normal 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;
|
||||
@ -14,14 +14,19 @@ import {
|
||||
} from '@/vendors/fujifilm';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
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 = {
|
||||
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 => doesTagsStringIncludeFavs(tags)
|
||||
? `'${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,33 @@ 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)
|
||||
);
|
||||
|
||||
// CREATE FORM DATA: FROM PHOTO
|
||||
|
||||
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,9 +124,13 @@ 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);
|
||||
};
|
||||
|
||||
// CREATE FORM DATA: FROM EXIF
|
||||
|
||||
export const convertExifToFormData = (
|
||||
data: ExifData,
|
||||
filmSimulation?: FilmSimulation,
|
||||
@ -124,6 +160,8 @@ export const convertExifToFormData = (
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// PREPARE FORM FOR DB INSERT
|
||||
|
||||
export const convertFormDataToPhotoDbInsert = (
|
||||
formData: FormData | PhotoFormData,
|
||||
generateId?: boolean,
|
||||
@ -131,6 +169,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 +181,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 +192,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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -8,7 +8,7 @@ export default function PhotoFilmSimulation({
|
||||
simulation,
|
||||
type = 'icon-last',
|
||||
badged = true,
|
||||
dim,
|
||||
contrast,
|
||||
countOnHover,
|
||||
}: {
|
||||
simulation: FilmSimulation
|
||||
@ -28,7 +28,7 @@ export default function PhotoFilmSimulation({
|
||||
title={`Film Simulation: ${large}`}
|
||||
type={type}
|
||||
badged={badged}
|
||||
dim={dim}
|
||||
contrast={contrast}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -64,6 +64,10 @@
|
||||
@apply
|
||||
rounded-md
|
||||
}
|
||||
input.error, select.error {
|
||||
@apply
|
||||
border-red-500 dark:border-red-400
|
||||
}
|
||||
button, .button {
|
||||
@apply
|
||||
cursor-pointer
|
||||
@ -132,4 +136,8 @@
|
||||
@apply
|
||||
text-red-500 dark:text-red-400
|
||||
}
|
||||
.bg-main {
|
||||
@apply
|
||||
bg-gray-900 dark:bg-gray-100
|
||||
}
|
||||
}
|
||||
|
||||
42
src/tag/FavsTag.tsx
Normal file
42
src/tag/FavsTag.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ export default function PhotoTag({
|
||||
tag,
|
||||
type,
|
||||
badged,
|
||||
dim,
|
||||
contrast,
|
||||
countOnHover,
|
||||
}: {
|
||||
tag: string
|
||||
@ -23,7 +23,7 @@ export default function PhotoTag({
|
||||
/>}
|
||||
type={type}
|
||||
badged={badged}
|
||||
dim={dim}
|
||||
contrast={contrast}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoTag from './PhotoTag';
|
||||
import { descriptionForTaggedPhotos } from '.';
|
||||
import { descriptionForTaggedPhotos, isTagFavs } from '.';
|
||||
import { pathForTagShare } from '@/site/paths';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import FavsTag from './FavsTag';
|
||||
|
||||
export default function TagHeader({
|
||||
tag,
|
||||
@ -19,7 +20,9 @@ export default function TagHeader({
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
entity={<PhotoTag tag={tag} />}
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag />
|
||||
: <PhotoTag tag={tag} />}
|
||||
entityVerb="Tagged"
|
||||
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
|
||||
photos={photos}
|
||||
|
||||
@ -5,7 +5,9 @@ import {
|
||||
photoQuantityText,
|
||||
} from '@/photo';
|
||||
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 = {
|
||||
tag: string
|
||||
@ -15,6 +17,9 @@ export type Tags = {
|
||||
export const formatTag = (tag?: string) =>
|
||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||
|
||||
export const doesTagsStringIncludeFavs = (tags?: string) =>
|
||||
convertStringToArray(tags)?.some(tag => isTagFavs(tag));
|
||||
|
||||
export const titleForTag = (
|
||||
tag: string,
|
||||
photos:Photo[],
|
||||
@ -24,6 +29,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,
|
||||
@ -50,3 +62,5 @@ export const generateMetaForTag = (
|
||||
descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange),
|
||||
images: absolutePathForTagImage(tag),
|
||||
});
|
||||
|
||||
export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user