* 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
197 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|