Vercel/src/components/entity/EntityLink.tsx
Sam Becker b7cb6715b7
Speed up category hovers (#279)
* Extract out ShareHover components
* Refactor hover/category state
* Rename photo query options types
* Restore category count slice of app state
* Streamline entity hover headers
* Standardize swr keys
* Suppress hover counts to years
* Refine entity hover design
* Make image hovers opt out
2025-07-04 12:19:45 -05:00

197 lines
5.2 KiB
TypeScript

'use client';
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
import LabeledIcon, { LabeledIconType } from '../primitives/LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
import LinkWithStatus from '../LinkWithStatus';
import Spinner from '../Spinner';
import ResponsiveText from '../primitives/ResponsiveText';
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
import EntityHover from './EntityHover';
import { getPhotosCachedAction } from '@/photo/actions';
import { PhotoQueryOptions } from '@/photo/db';
import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response';
export interface EntityLinkExternalProps {
ref?: RefObject<HTMLSpanElement | null>
type?: LabeledIconType
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
uppercase?: boolean
prefetch?: boolean
suppressSpinner?: boolean
className?: string
countOnHover?: number
showHover?: boolean
hoverPhotoQueryOptions?: PhotoQueryOptions
}
export default function EntityLink({
ref,
icon,
iconBadgeStart,
iconBadgeEnd,
label,
labelSmall,
iconWide,
type,
badged,
contrast = 'medium',
path = '', // Make link optional for debugging purposes
showHover = SHOW_CATEGORY_IMAGE_HOVERS,
countOnHover,
hoverPhotoQueryOptions,
prefetch,
title,
action,
truncate = true,
className,
classNameIcon,
uppercase,
suppressSpinner,
debug,
}: {
icon: ReactNode
iconBadgeStart?: ReactNode
iconBadgeEnd?: ReactNode
label: string
labelSmall?: ReactNode
iconWide?: boolean
path?: string
prefetch?: boolean
title?: string
action?: ReactNode
truncate?: boolean
className?: string
classNameIcon?: string
uppercase?: boolean
debug?: boolean
} & EntityLinkExternalProps) {
const [isLoading, setIsLoading] = useState(false);
const hasBadgeIcon = Boolean(iconBadgeStart || iconBadgeEnd);
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
case 'frosted':
return 'text-black';
default:
return 'text-medium';
}
};
const showHoverEntity =
!isLoading &&
countOnHover &&
!showHover;
const renderLabel =
<ResponsiveText shortText={labelSmall}>
{label}
</ResponsiveText>;
const renderLink = (useForHover?: boolean) =>
<LinkWithStatus
href={path}
className={clsx(
'peer',
'inline-flex items-center gap-2 max-w-full truncate',
classForContrast(),
path && !badged && contrast !== 'frosted' &&
'hover:text-gray-900 dark:hover:text-gray-100',
path && !badged && 'active:text-medium!',
)}
isLoading={isLoading}
setIsLoading={setIsLoading}
>
<LabeledIcon {...{
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
prefetch,
title,
type: useForHover ? 'icon-first' : type,
uppercase,
className: useForHover ? 'text-white' : undefined,
classNameIcon: clsx(
!useForHover && 'text-dim',
classNameIcon,
),
debug,
}}>
{badged && !useForHover
? <Badge
type="small"
contrast={contrast}
className={clsx(
'translate-y-[-0.5px]',
hasBadgeIcon && '*:flex *:items-center *:gap-1',
)}
uppercase
interactive
>
{iconBadgeStart}
{renderLabel}
{iconBadgeEnd}
</Badge>
: <span className={clsx(
'text-content',
truncate && 'inline-flex max-w-full *:truncate',
'decoration-dotted underline-offset-[4px]',
'decoration-gray-300 dark:decoration-gray-600',
)}>
{renderLabel}
</span>}
</LabeledIcon>
</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,
)}
>
{showHover && countOnHover && hoverPhotoQueryOptions
? <EntityHover
hoverKey={path}
header={renderLink(true)}
photosCount={countOnHover}
getPhotos={() =>
getPhotosCachedAction({
...hoverPhotoQueryOptions,
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
})}
color={contrast === 'frosted' ? 'frosted' : undefined}
>
{renderLink()}
</EntityHover>
: renderLink()}
{action &&
<span className="action">
{action}
</span>}
{showHoverEntity &&
<span className="hidden peer-hover:inline text-dim">
{countOnHover}
</span>}
{isLoading && !suppressSpinner &&
<Spinner
className={clsx(
badged && 'translate-y-[0.5px]',
contrast === 'frosted' && 'text-neutral-500',
)}
color={contrast === 'frosted' ? 'text' : undefined}
/>}
</span>
);
}