OG image hovers (#268)

* Create og tooltip component

* Refactor og image handling

* Introduce category hover configuration

* Add og hovers to all categories

* Move category labels to client

* Disable og tooltips in headers

* Prevent og tooltips on accessory/loader hovers
This commit is contained in:
Sam Becker 2025-06-19 18:19:59 -05:00 committed by GitHub
parent 5808444095
commit e1af77d40c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 528 additions and 289 deletions

View File

@ -28,6 +28,7 @@
"Hasselblad",
"headlessui",
"hgetall",
"Hoverable",
"hset",
"IIIA",
"ILCE",

View File

@ -146,6 +146,7 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
- `NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS = 1` shows images when hovering over category links like cameras and lenses (⚠️ setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES = 1` strongly recommended for responsive hover interactions)
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal

View File

@ -17,7 +17,7 @@ export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
'image',
getUniqueFocalLengths,
focalLengths => focalLengths
.map(({ focal }) => ({ focal: formatFocalLength(focal)! })),
.map(({ focal }) => ({ focal: formatFocalLength(focal) })),
);
export async function GET(

View File

@ -92,6 +92,7 @@ export default function AdminAppConfigurationClient({
collapseSidebarCategories,
showKeyboardShortcutTooltips,
showExifInfo,
showCategoryImageHover,
showZoomControls,
showTakenAtTimeHidden,
showSocial,
@ -647,6 +648,25 @@ export default function AdminAppConfigurationClient({
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Show category image hovers"
status={showCategoryImageHover}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to show images when hovering
over category links like cameras and lenses:
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])}
</div>
<div>
Static optimization strongly recommended
for responsive hover interactions:
{/* eslint-disable-next-line max-len */}
{renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Show zoom controls"
status={showZoomControls}

View File

@ -240,7 +240,6 @@ export const IMAGE_QUALITY =
export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
// VISUAL
export const DEFAULT_THEME =
@ -278,6 +277,8 @@ export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const SHOW_CATEGORY_IMAGE_HOVERS =
process.env.NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS === '1';
export const SHOW_ZOOM_CONTROLS =
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
export const SHOW_TAKEN_AT_TIME =
@ -403,6 +404,7 @@ export const APP_CONFIGURATION = {
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
showExifInfo: SHOW_EXIF_DATA,
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
showZoomControls: SHOW_ZOOM_CONTROLS,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,

View File

@ -67,6 +67,7 @@ export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`;
// Modifiers
const EDIT = 'edit';
const IMAGE = 'image';
export const PARAM_UPLOAD_TITLE = 'title';
// Special characters
@ -152,26 +153,51 @@ export const pathForPhoto = ({
return `${prefix}/${getPhotoId(photo)}`;
};
export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`;
export const pathForCamera = ({ make, model }: Camera) =>
`${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`;
export const pathForFilm = (film: string) =>
`${PREFIX_FILM}/${film}`;
export const pathForLens = ({ make, model }: Lens) =>
make
? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}`
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
export const pathForFocalLength = (focal: number) =>
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`;
export const pathForRecipe = (recipe: string) =>
`${PREFIX_RECIPE}/${recipe}`;
export const pathForFilm = (film: string) =>
`${PREFIX_FILM}/${film}`;
export const pathForFocalLength = (focal: number) =>
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
// Image paths
const pathForImage = (path: string) =>
`${path}/${IMAGE}`;
export const pathForPhotoImage = (photo: PhotoOrPhotoId) =>
pathForImage(pathForPhoto({ photo }));
export const pathForCameraImage = (camera: Camera) =>
pathForImage(pathForCamera(camera));
export const pathForLensImage = (lens: Lens) =>
pathForImage(pathForLens(lens));
export const pathForTagImage = (tag: string) =>
pathForImage(pathForTag(tag));
export const pathForRecipeImage = (recipe: string) =>
pathForImage(pathForRecipe(recipe));
export const pathForFilmImage = (film: string) =>
pathForImage(pathForFilm(film));
export const pathForFocalLengthImage = (focal: number) =>
pathForImage(pathForFocalLength(focal));
// Absolute paths
export const ABSOLUTE_PATH_FOR_FEED_JSON =
`${getBaseUrl()}${PATH_FEED_JSON}`;
@ -188,58 +214,49 @@ export const absolutePathForPhoto = (
) =>
`${getBaseUrl(share)}${pathForPhoto(params)}`;
export const absolutePathForTag = (tag: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForTag(tag)}`;
export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
`${getBaseUrl(share)}${pathForCamera(camera)}`;
export const absolutePathForLens= (lens: Lens, share?: boolean) =>
`${getBaseUrl(share)}${pathForLens(lens)}`;
export const absolutePathForFilm = (film: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForFilm(film)}`;
export const absolutePathForTag = (tag: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForTag(tag)}`;
export const absolutePathForRecipe = (recipe: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForRecipe(recipe)}`;
export const absolutePathForFilm = (film: string, share?: boolean) =>
`${getBaseUrl(share)}${pathForFilm(film)}`;
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
`${getBaseUrl(share)}${pathForFocalLength(focal)}`;
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
`${absolutePathForPhoto({ photo })}/image`;
export const absolutePathForTagImage = (tag: string) =>
`${absolutePathForTag(tag)}/image`;
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
export const absolutePathForCameraImage= (camera: Camera) =>
`${absolutePathForCamera(camera)}/image`;
`${getBaseUrl()}${pathForCameraImage(camera)}`;
export const absolutePathForLensImage= (lens: Lens) =>
`${absolutePathForLens(lens)}/image`;
`${getBaseUrl()}${pathForLensImage(lens)}`;
export const absolutePathForFilmImage = (film: string) =>
`${absolutePathForFilm(film)}/image`;
export const absolutePathForTagImage = (tag: string) =>
`${getBaseUrl()}${pathForTagImage(tag)}`;
export const absolutePathForRecipeImage = (recipe: string) =>
`${absolutePathForRecipe(recipe)}/image`;
`${getBaseUrl()}${pathForRecipeImage(recipe)}`;
export const absolutePathForFocalLengthImage =
(focal: number) =>
`${absolutePathForFocalLength(focal)}/image`;
export const absolutePathForFilmImage = (film: string) =>
`${getBaseUrl()}${pathForFilmImage(film)}`;
export const absolutePathForFocalLengthImage = (focal: number) =>
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
// p/[photoId]
export const isPathPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(pathname);
// tag/[tag]
export const isPathTag = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);;
// tag/[tag]/[photoId]
export const isPathTagPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname);
// shot-on/[make]/[model]
export const isPathCamera = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
@ -248,6 +265,22 @@ export const isPathCamera = (pathname = '') =>
export const isPathCameraPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname);
// tag/[tag]
export const isPathTag = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);
// tag/[tag]/[photoId]
export const isPathTagPhoto = (pathname = '') =>
new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname);
// recipe/[recipe]
export const isPathRecipe = (pathname = '') =>
new RegExp(`^${PREFIX_RECIPE}/[^/]+/?$`).test(pathname);
// recipe/[recipe]/[photoId]
export const isPathRecipePhoto = (pathname = '') =>
new RegExp(`^${PREFIX_RECIPE}/[^/]+/[^/]+/?$`).test(pathname);
// film/[film]
export const isPathFilm = (pathname = '') =>
new RegExp(`^${PREFIX_FILM}/[^/]+/?$`).test(pathname);
@ -313,20 +346,20 @@ export const getPathComponents = (pathname = ''): {
} & PhotoSetCategory => {
const photoIdFromPhoto = pathname.match(
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
const photoIdFromTag = pathname.match(
new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1];
const photoIdFromCamera = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1];
const cameraMake = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1];
const cameraModel = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1];
const photoIdFromTag = pathname.match(
new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1];
const photoIdFromFilm = pathname.match(
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
const photoIdFromFocalLength = pathname.match(
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
const tag = pathname.match(
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
const cameraMake = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1];
const cameraModel = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1];
const film = pathname.match(
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
const focalString = pathname.match(

View File

@ -21,12 +21,17 @@ export default async function CameraHeader({
count?: number
dateRange?: PhotoDateRange
}) {
const camera = cameraFromPhoto(photos[0], cameraProp);
const appText = await getAppText();
const camera = cameraFromPhoto(photos[0], cameraProp);
return (
<PhotoHeader
camera={camera}
entity={<PhotoCamera {...{ camera }} contrast="high" />}
entity={<PhotoCamera
{...{ camera }}
contrast="high"
showTooltip={false}
/>}
entityDescription={
descriptionForCameraPhotos(
photos,

View File

@ -1,8 +1,8 @@
'use client';
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForCameraImage, pathForCamera } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { pathForCamera, pathForCameraImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta';
import { useAppText } from '@/i18n/state/client';
@ -40,7 +40,7 @@ export default function CameraOGTile({
dateRange,
),
path: pathForCamera(camera),
pathImageAbsolute: absolutePathForCameraImage(camera),
pathImage: pathForCameraImage(camera),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -1,11 +1,15 @@
'use client';
import { AiFillApple } from 'react-icons/ai';
import { pathForCamera } from '@/app/paths';
import { pathForCamera, pathForCameraImage } from '@/app/paths';
import { Camera, formatCameraText } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import IconCamera from '@/components/icons/IconCamera';
import { isCameraApple } from '@/platforms/apple';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoCamera({
camera,
@ -17,6 +21,7 @@ export default function PhotoCamera({
hideAppleIcon?: boolean
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
const isApple = isCameraApple(camera);
const showAppleIcon = !hideAppleIcon && isApple;
@ -24,7 +29,10 @@ export default function PhotoCamera({
<EntityLink
{...props}
label={formatCameraText(camera)}
href={pathForCamera(camera)}
path={pathForCamera(camera)}
tooltipImagePath={pathForCameraImage(camera)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={showAppleIcon
? <AiFillApple
title="Apple"

View File

@ -366,7 +366,7 @@ export default function CommandKClient({
heading: appText.category.focalLengthPlural,
accessory: <IconFocalLength className="text-[14px]" />,
items: focalLengths.map(({ focal, count }) => ({
label: formatFocalLength(focal)!,
label: formatFocalLength(focal),
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFocalLength(focal),

View File

@ -1,136 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { BiError } from 'react-icons/bi';
import Spinner from '@/components/Spinner';
import { IMAGE_OG_DIMENSION } from '../image-response';
import useVisible from '@/utility/useVisible';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function OGTile({
title,
description,
path,
pathImageAbsolute,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
onVisible,
}: {
title: string
description: string
path: string
pathImageAbsolute: string
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
onVisible?: () => void
}) {
const ref = useRef<HTMLAnchorElement>(null);
const [loadingStateInternal, setLoadingStateInternal] =
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
useVisible({ ref, onVisible });
return (
<Link
ref={ref}
href={path}
className={clsx(
'group',
'block w-full rounded-md overflow-hidden',
'border-medium shadow-xs',
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
)}
>
<div
className="relative"
style={{ aspectRatio }}
>
{loadingState === 'loading' &&
<div className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-10',
'flex items-center justify-center',
)}>
<Spinner size={40} />
</div>}
{loadingState === 'failed' &&
<div className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-11',
'flex items-center justify-center',
'text-red-400',
)}>
<BiError size={32} />
</div>}
{(loadingState === 'loading' || loadingState === 'loaded') &&
<img
alt={title}
className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-0',
'w-full',
loadingState === 'loading' && 'opacity-0',
'transition-opacity',
)}
src={pathImageAbsolute}
width={width}
height={height}
onLoad={() => {
if (onLoad) {
onLoad();
} else {
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) {
setTimeout(() => {
setLoadingStateInternal('loading');
}, retryTime);
}
}}
/>}
</div>
<div className={clsx(
'h-full flex flex-col gap-0.5 p-3',
'font-sans leading-tight',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 dark:group-active:bg-gray-900/50',
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900/70',
'border-t border-gray-200 dark:border-gray-800',
)}>
<div className="text-gray-800 dark:text-white font-medium">
{title}
</div>
<div className="text-medium">
{description}
</div>
</div>
</Link>
);
};

View File

@ -0,0 +1,102 @@
'use client';
import { useEffect, useState } from 'react';
import { clsx } from 'clsx/lite';
import Spinner from '@/components/Spinner';
import { IMAGE_OG_DIMENSION } from '@/image-response';
import { TbPhotoQuestion } from 'react-icons/tb';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function OGLoaderImage({
title,
path,
loadingState: loadingStateExternal,
onLoad,
onFail,
retryTime,
className,
}: {
title: string
path: string
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
retryTime?: number
className?: string
}) {
const [loadingStateInternal, setLoadingStateInternal] =
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
return (
<div
className={clsx(
'relative',
className,
)}
style={{ aspectRatio }}
>
{loadingState === 'loading' &&
<div className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-10',
'flex items-center justify-center',
)}>
<Spinner size={40} />
</div>}
{loadingState === 'failed' &&
<div className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-11',
'flex items-center justify-center',
'text-dim',
)}>
<TbPhotoQuestion size={28} />
</div>}
{(loadingState === 'loading' || loadingState === 'loaded') &&
<img
alt={title}
className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-0',
'w-full',
loadingState === 'loading' && 'opacity-0',
'transition-opacity',
)}
src={path}
width={width}
height={height}
onLoad={() => {
if (onLoad) {
onLoad();
} else {
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) {
setTimeout(() => {
setLoadingStateInternal('loading');
}, retryTime);
}
}}
/>}
</div>
);
};

View File

@ -0,0 +1,57 @@
'use client';
import { ComponentProps, useRef } from 'react';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import useVisible from '@/utility/useVisible';
import OGLoaderImage from './OGLoaderImage';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function OGTile({
path,
pathImage,
description,
riseOnHover,
onVisible,
...props
}: {
description: string
pathImage: string
riseOnHover?: boolean
onVisible?: () => void
} & ComponentProps<typeof OGLoaderImage>) {
const ref = useRef<HTMLAnchorElement>(null);
useVisible({ ref, onVisible });
return (
<Link
ref={ref}
href={path}
className={clsx(
'group',
'block w-full rounded-md overflow-hidden',
'border-medium shadow-xs',
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
)}
>
<OGLoaderImage {...{ ...props, path: pathImage }} />
<div className={clsx(
'h-full flex flex-col gap-0.5 p-3',
'font-sans leading-tight',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 dark:group-active:bg-gray-900/50',
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900/70',
'border-t border-gray-200 dark:border-gray-800',
)}>
<div className="text-gray-800 dark:text-white font-medium">
{props.title}
</div>
<div className="text-medium">
{description}
</div>
</div>
</Link>
);
};

View File

@ -0,0 +1,46 @@
import { ComponentProps, ReactNode } from 'react';
import TooltipPrimitive from '../primitives/TooltipPrimitive';
import OGLoaderImage from './OGLoaderImage';
import { IMAGE_OG_DIMENSION } from '@/image-response';
import clsx from 'clsx/lite';
export default function OGTooltip({
children,
caption,
...props
}: {
children :ReactNode
caption?: ReactNode
} & ComponentProps<typeof OGLoaderImage>) {
const { aspectRatio } = IMAGE_OG_DIMENSION;
return (
<TooltipPrimitive
className="max-w-none p-1!"
classNameTrigger="max-w-full"
disableHoverableContent
content={<div
className="relative"
style={{ width: 300, aspectRatio }}
>
<OGLoaderImage
{...props}
className={clsx(
'overflow-hidden rounded-[0.25rem]',
'outline-medium bg-dim',
)}
/>
{caption && <div className={clsx(
'absolute left-3 bottom-3',
'px-1.5 py-0.5 rounded-md',
'text-white/90 bg-black/40 backdrop-blur-lg',
'outline-medium shadow-sm',
'uppercase text-xs',
)}>
{caption}
</div>}
</div>}
>
{children}
</TooltipPrimitive>
);
}

View File

@ -7,12 +7,15 @@ import { clsx } from 'clsx/lite';
import LinkWithStatus from '../LinkWithStatus';
import Spinner from '../Spinner';
import ResponsiveText from './ResponsiveText';
import OGTooltip from '../og/OGTooltip';
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
export interface EntityLinkExternalProps {
ref?: RefObject<HTMLSpanElement | null>
type?: LabeledIconType
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
showTooltip?: boolean
uppercase?: boolean
prefetch?: boolean
className?: string
@ -23,11 +26,15 @@ export default function EntityLink({
icon,
label,
labelSmall,
labelComplex,
iconWide,
type,
badged,
contrast = 'medium',
href = '', // Make link optional for debugging purposes
showTooltip = SHOW_CATEGORY_IMAGE_HOVERS,
path = '', // Make link optional for debugging purposes
tooltipImagePath,
tooltipCaption,
prefetch,
title,
action,
@ -39,10 +46,13 @@ export default function EntityLink({
debug,
}: {
icon: ReactNode
label: ReactNode
label: string
labelSmall?: ReactNode
labelComplex?: ReactNode
iconWide?: boolean
href?: string
path?: string
tooltipImagePath?: string
tooltipCaption?: ReactNode
prefetch?: boolean
title?: string
action?: ReactNode
@ -68,30 +78,25 @@ export default function EntityLink({
}
};
const showHoverEntity =
!isLoading &&
hoverEntity !== undefined &&
!showTooltip;
const renderLabel =
<ResponsiveText shortText={labelSmall}>
{label}
{labelComplex || label}
</ResponsiveText>;
return (
<span
ref={ref}
className={clsx(
'inline-flex items-center gap-2',
'max-w-full overflow-hidden select-none',
// Underline link text when action is hovered
'[&:has(.action:hover)_.text-content]:underline',
className,
)}
>
const renderLink =
<LinkWithStatus
href={href}
href={path}
className={clsx(
'peer',
'inline-flex items-center gap-2 max-w-full truncate',
classForContrast(),
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
href && !badged && 'active:text-medium!',
path && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
path && !badged && 'active:text-medium!',
)}
isLoading={isLoading}
setIsLoading={setIsLoading}
@ -99,7 +104,6 @@ export default function EntityLink({
<LabeledIcon {...{
icon,
iconWide,
href,
prefetch,
title,
type,
@ -126,12 +130,33 @@ export default function EntityLink({
{renderLabel}
</span>}
</LabeledIcon>
</LinkWithStatus>
</LinkWithStatus>;
return (
<span
ref={ref}
className={clsx(
'inline-flex items-center gap-2',
'max-w-full overflow-hidden select-none',
// Underline link text when action is hovered
'[&:has(.action:hover)_.text-content]:underline',
className,
)}
>
{showTooltip && tooltipImagePath
? <OGTooltip
title={label}
path={tooltipImagePath}
caption={tooltipCaption}
>
{renderLink}
</OGTooltip>
: renderLink}
{action &&
<span className="action">
{action}
</span>}
{!isLoading && hoverEntity !== undefined &&
{showHoverEntity &&
<span className="hidden peer-hover:inline text-dim">
{hoverEntity}
</span>}

View File

@ -20,6 +20,8 @@ export default function TooltipPrimitive({
color,
keyCommand,
keyCommandModifier,
disableHoverableContent,
debug,
}: {
content?: ReactNode
children: ReactNode
@ -32,6 +34,8 @@ export default function TooltipPrimitive({
color?: ComponentProps<typeof MenuSurface>['color']
keyCommand?: string
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
disableHoverableContent?: boolean
debug?: boolean
}) {
const refTrigger = useRef<HTMLButtonElement>(null);
const refContent = useRef<HTMLDivElement>(null);
@ -74,7 +78,10 @@ export default function TooltipPrimitive({
return (
<Tooltip.Provider {...{ delayDuration, skipDelayDuration }}>
<Tooltip.Root open={includeButton ? isOpen : undefined}>
<Tooltip.Root
open={(includeButton ? isOpen : undefined) || debug}
disableHoverableContent={disableHoverableContent}
>
<Tooltip.Trigger asChild>
{includeButton
? <button

View File

@ -43,6 +43,7 @@ export default function FilmHeader({
toggleRecipeOverlay={recipeProps
? () => setRecipeModalProps?.(recipeProps)
: undefined}
showTooltip={false}
/>}
entityDescription={descriptionForFilmPhotos(
photos,

View File

@ -2,10 +2,10 @@
import { Photo, PhotoDateRange } from '@/photo';
import {
absolutePathForFilmImage,
pathForFilm,
pathForFilmImage,
} from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { descriptionForFilmPhotos, titleForFilm } from '.';
import { useAppText } from '@/i18n/state/client';
@ -37,7 +37,7 @@ export default function FilmOGTile({
description:
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
path: pathForFilm(film),
pathImageAbsolute: absolutePathForFilmImage(film),
pathImage: pathForFilmImage(film),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -1,5 +1,7 @@
'use client';
import PhotoFilmIcon from './PhotoFilmIcon';
import { pathForFilm } from '@/app/paths';
import { pathForFilm, pathForFilmImage } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
@ -8,6 +10,8 @@ import { labelForFilm } from '.';
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
import { ComponentProps } from 'react';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoFilm({
film,
@ -23,6 +27,7 @@ export default function PhotoFilm({
countOnHover?: number
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
const appText = useAppText();
const { small, medium, large } = labelForFilm(film);
return (
@ -30,7 +35,10 @@ export default function PhotoFilm({
{...props}
label={medium}
labelSmall={small}
href={pathForFilm(film)}
path={pathForFilm(film)}
tooltipImagePath={pathForFilmImage(film)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={<PhotoFilmIcon
film={film}
className={clsx(

View File

@ -24,7 +24,11 @@ export default async function FocalLengthHeader({
return (
<PhotoHeader
focal={focal}
entity={<PhotoFocalLength focal={focal} contrast="high" />}
entity={<PhotoFocalLength
focal={focal}
contrast="high"
showTooltip={false}
/>}
entityDescription={descriptionForFocalLengthPhotos(
photos,
appText,

View File

@ -2,10 +2,10 @@
import { Photo, PhotoDateRange } from '@/photo';
import {
absolutePathForFocalLengthImage,
pathForFocalLength,
pathForFocalLengthImage,
} from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
import { useAppText } from '@/i18n/state/client';
@ -42,7 +42,7 @@ export default function FocalLengthOGTile({
dateRange,
),
path: pathForFocalLength(focal),
pathImageAbsolute: absolutePathForFocalLengthImage(focal),
pathImage: pathForFocalLengthImage(focal),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -2,7 +2,7 @@ import { absolutePathForFocalLength } from '@/app/paths';
import { PhotoSetAttributes } from '../category';
import ShareModal from '@/share/ShareModal';
import FocalLengthOGTile from './FocalLengthOGTile';
import { formatFocalLengthSafe, shareTextFocalLength } from '.';
import { formatFocalLength, shareTextFocalLength } from '.';
import { useAppText } from '@/i18n/state/client';
export default function FocalLengthShareModal({
@ -17,7 +17,7 @@ export default function FocalLengthShareModal({
return (
<ShareModal
pathShare={absolutePathForFocalLength(focal, true)}
navigatorTitle={formatFocalLengthSafe(focal)}
navigatorTitle={formatFocalLength(focal)}
socialText={shareTextFocalLength(focal, appText)}
>
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />

View File

@ -1,9 +1,13 @@
import { pathForFocalLength } from '@/app/paths';
'use client';
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import { formatFocalLength } from '.';
import IconFocalLength from '@/components/icons/IconFocalLength';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoFocalLength({
focal,
@ -13,11 +17,16 @@ export default function PhotoFocalLength({
focal: number
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatFocalLength(focal)}
href={pathForFocalLength(focal)}
path={pathForFocalLength(focal)}
tooltipImagePath={pathForFocalLengthImage(focal)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={<IconFocalLength className="translate-y-[-1px]" />}
hoverEntity={countOnHover}
/>

View File

@ -20,11 +20,7 @@ export const getFocalLengthFromString = (focalString?: string) => {
return focal ? parseInt(focal, 10) : 0;
};
export const formatFocalLength = (focal?: number) => focal
? formatFocalLengthSafe(focal)
: undefined;
export const formatFocalLengthSafe = (focal = 0) =>
export const formatFocalLength = (focal = 0) =>
`${focal}mm`;
export const titleForFocalLength = (
@ -33,7 +29,7 @@ export const titleForFocalLength = (
appText: AppTextState,
explicitCount?: number,
) => [
appText.category.focalLengthTitle(formatFocalLengthSafe(focal)),
appText.category.focalLengthTitle(formatFocalLength(focal)),
photoQuantityText(explicitCount ?? photos.length, appText),
].join(' ');
@ -41,7 +37,7 @@ export const shareTextFocalLength = (
focal: number,
appText: AppTextState,
) =>
appText.category.focalLengthShare(formatFocalLengthSafe(focal));
appText.category.focalLengthShare(formatFocalLength(focal));
export const descriptionForFocalLengthPhotos = (
photos: Photo[],

View File

@ -26,7 +26,11 @@ export default async function LensHeader({
return (
<PhotoHeader
lens={lens}
entity={<PhotoLens {...{ lens }} contrast="high" />}
entity={<PhotoLens
{...{ lens }}
contrast="high"
showTooltip={false}
/>}
entityDescription={
descriptionForLensPhotos(
photos,

View File

@ -1,6 +1,6 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { pathForLens, pathForLensImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { Lens } from '.';
import { titleForLens, descriptionForLensPhotos } from './meta';
import { useAppText } from '@/i18n/state/client';
@ -38,7 +38,7 @@ export default function LensOGTile({
dateRange,
),
path: pathForLens(lens),
pathImageAbsolute: absolutePathForLensImage(lens),
pathImage: pathForLensImage(lens),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -1,9 +1,13 @@
import { pathForLens } from '@/app/paths';
'use client';
import { pathForLens, pathForLensImage } from '@/app/paths';
import { Lens, formatLensText } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import IconLens from '@/components/icons/IconLens';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoLens({
lens,
@ -15,11 +19,16 @@ export default function PhotoLens({
countOnHover?: number
shortText?: boolean
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatLensText(lens, shortText ? 'short' : 'medium')}
href={pathForLens(lens)}
path={pathForLens(lens)}
tooltipImagePath={pathForLensImage(lens)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={<IconLens
size={14}
className="translate-x-[-0.5px]"

View File

@ -6,8 +6,8 @@ import {
titleForPhoto,
} from '@/photo';
import { PhotoSetCategory } from '../category';
import { absolutePathForPhotoImage, pathForPhoto } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
export default function PhotoOGTile({
photo,
@ -32,7 +32,7 @@ export default function PhotoOGTile({
title: titleForPhoto(photo),
description: descriptionForPhoto(photo),
path: pathForPhoto({ photo, ...categories }),
pathImageAbsolute: absolutePathForPhotoImage(photo),
pathImage: pathForPhotoImage(photo),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Photo } from '@/photo';
import PhotoOGTile from './PhotoOGTile';
import { OGLoadingState } from '@/components/OGTile';
import { OGLoadingState } from '@/components/og/OGTile';
const DEFAULT_MAX_CONCURRENCY = 3;

View File

@ -123,9 +123,13 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
...photoDb,
tags: photoDb.tags ?? [],
focalLengthFormatted:
formatFocalLength(photoDb.focalLength),
photoDb.focalLength !== undefined
? formatFocalLength(photoDb.focalLength)
: undefined,
focalLengthIn35MmFormatFormatted:
formatFocalLength(photoDb.focalLengthIn35MmFormat),
photoDb.focalLengthIn35MmFormat !== undefined
? formatFocalLength(photoDb.focalLengthIn35MmFormat)
: undefined,
fNumberFormatted:
formatAperture(photoDb.fNumber),
isoFormatted:

View File

@ -1,4 +1,6 @@
import { pathForRecipe } from '@/app/paths';
'use client';
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
@ -7,6 +9,8 @@ import clsx from 'clsx/lite';
import { ComponentProps } from 'react';
import IconRecipe from '@/components/icons/IconRecipe';
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoRecipe({
ref,
@ -20,13 +24,18 @@ export default function PhotoRecipe({
countOnHover?: number
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
ref={ref}
title="Recipe"
label={formatRecipe(recipe)}
href={pathForRecipe(recipe)}
path={pathForRecipe(recipe)}
tooltipImagePath={pathForRecipeImage(recipe)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={<IconRecipe
size={16}
className={clsx(

View File

@ -35,6 +35,7 @@ export default function RecipeHeader({
entity={<PhotoRecipe
recipe={recipe}
contrast="high"
showTooltip={false}
isShowingRecipeOverlay={Boolean(recipeModalProps)}
toggleRecipeOverlay={recipeProps
? () => setRecipeModalProps?.(recipeProps)

View File

@ -1,6 +1,6 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { descriptionForRecipePhotos, titleForRecipe } from '.';
import { useAppText } from '@/i18n/state/client';
@ -37,7 +37,7 @@ export default function RecipeOGTile({
dateRange,
),
path: pathForRecipe(recipe),
pathImageAbsolute: absolutePathForRecipeImage(recipe),
pathImage: pathForRecipeImage(recipe),
loadingState: loadingStateExternal,
onLoad,
onFail,

View File

@ -1,9 +1,11 @@
import { TAG_FAVS } from '.';
import { pathForTag } from '@/app/paths';
import { pathForTag, pathForTagImage } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import IconFavs from '@/components/icons/IconFavs';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function FavsTag({
type,
@ -15,19 +17,24 @@ export default function FavsTag({
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
label={badged
? <span className="inline-flex gap-1 items-center">
label={TAG_FAVS}
labelComplex={badged &&
<span className="inline-flex gap-1 items-center">
{TAG_FAVS}
<IconFavs
size={10}
className="translate-y-[-0.5px]"
highlight
/>
</span>
: TAG_FAVS}
href={pathForTag(TAG_FAVS)}
</span>}
path={pathForTag(TAG_FAVS)}
tooltipImagePath={pathForTagImage(TAG_FAVS)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={!badged &&
<IconFavs
size={13}

View File

@ -17,16 +17,16 @@ export default function HiddenTag({
} & EntityLinkExternalProps) {
return (
<EntityLink
label={badged
? <span className="inline-flex items-center gap-1">
label={TAG_HIDDEN}
labelComplex={badged &&
<span className="inline-flex items-center gap-1">
{TAG_HIDDEN}
<IconHidden
size={13}
className="translate-y-[-0.5px]"
/>
</span>
: TAG_HIDDEN}
href={pathForTag(TAG_HIDDEN)}
</span>}
path={pathForTag(TAG_HIDDEN)}
icon={!badged && <IconHidden size={16} />}
type={type}
className={className}

View File

@ -1,9 +1,13 @@
import { pathForTag } from '@/app/paths';
'use client';
import { pathForTag, pathForTagImage } from '@/app/paths';
import { formatTag } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import IconTag from '@/components/icons/IconTag';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoTag({
tag,
@ -13,11 +17,16 @@ export default function PhotoTag({
tag: string
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatTag(tag)}
href={pathForTag(tag)}
path={pathForTag(tag)}
tooltipImagePath={pathForTagImage(tag)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
hoverEntity={countOnHover}
/>

View File

@ -26,8 +26,15 @@ export default async function TagHeader({
<PhotoHeader
tag={tag}
entity={isTagFavs(tag)
? <FavsTag contrast="high" />
: <PhotoTag tag={tag} contrast="high" />}
? <FavsTag
contrast="high"
showTooltip={false}
/>
: <PhotoTag
tag={tag}
contrast="high"
showTooltip={false}
/>}
entityVerb={appText.category.taggedPhotos}
entityDescription={descriptionForTaggedPhotos(
photos,

View File

@ -1,8 +1,8 @@
'use client';
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForTagImage, pathForTag } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { pathForTag, pathForTagImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
import { descriptionForTaggedPhotos, titleForTag } from '.';
import { useAppText } from '@/i18n/state/client';
@ -39,7 +39,7 @@ export default function TagOGTile({
dateRange,
),
path: pathForTag(tag),
pathImageAbsolute: absolutePathForTagImage(tag),
pathImage: pathForTagImage(tag),
loadingState: loadingStateExternal,
onLoad,
onFail,