Streamline recipe overlay interactions

This commit is contained in:
Sam Becker 2025-04-08 20:00:28 -05:00
parent ae8a38f462
commit d1689371a2
10 changed files with 172 additions and 201 deletions

View File

@ -37,6 +37,7 @@
"mitigations",
"nanoids",
"nextjs",
"nowrap",
"parameterizes",
"presigner",
"Provia",

View File

@ -6,7 +6,6 @@ export default function LinkWithIconLoader({
className,
icon,
loader,
debugLoading,
...props
}: Omit<ComponentProps<typeof LinkWithStatus>, 'children'> & {
icon: ReactNode
@ -20,11 +19,11 @@ export default function LinkWithIconLoader({
{({ isLoading }) => <>
<span className={clsx(
'flex transition-opacity',
isLoading || debugLoading ? 'opacity-0' : 'opacity-100',
isLoading ? 'opacity-0' : 'opacity-100',
)}>
{icon}
</span>
{(isLoading || debugLoading) && <span className={clsx(
{isLoading && <span className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>

View File

@ -17,30 +17,28 @@ const FLICKER_THRESHOLD = 400;
// Clear loading status after long duration
const MAX_LOADING_DURATION = 15_000;
export type LinkWithStatusProps = Omit<
ComponentProps<typeof Link>, 'children'
> & {
loadingClassName?: string
children: ReactNode | ((props: {
isLoading: boolean
}) => ReactNode)
debugLoading?: boolean
}
export default function LinkWithStatus({
loadingClassName,
href,
className,
onClick,
children,
debugLoading = false,
isLoading: isLoadingProp = false,
setIsLoading: setIsLoadingProp,
...props
}: LinkWithStatusProps) {
}: Omit<ComponentProps<typeof Link>, 'children'> & {
children: ReactNode | ((props: { isLoading: boolean }) => ReactNode)
loadingClassName?: string
// For hoisting state to a parent component, e.g., <EntityLink />
isLoading?: boolean
setIsLoading?: (isLoading: boolean) => void
}) {
const path = usePathname();
const [pathWhenClicked, setPathWhenClicked] = useState<string>();
const [_isLoading, setIsLoading] = useState(false);
const isLoading = _isLoading || debugLoading;
const [_isLoading, _setIsLoading] = useState(false);
const isLoading = isLoadingProp || _isLoading;
const setIsLoading = setIsLoadingProp || _setIsLoading;
const isLoadingStartTime = useRef<number | undefined>(undefined);
@ -60,7 +58,7 @@ export default function LinkWithStatus({
const stopLoading = useCallback(() => {
setIsLoading(false);
setPathWhenClicked(undefined);
}, []);
}, [setIsLoading]);
const isVisitingLinkHref = path === href;

View File

@ -1,6 +1,6 @@
'use client';
import { ComponentProps, ReactNode } from 'react';
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
@ -9,6 +9,7 @@ import Spinner from '../Spinner';
import ResponsiveText from './ResponsiveText';
export interface EntityLinkExternalProps {
ref?: RefObject<HTMLSpanElement | null>
type?: LabeledIconType
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
@ -18,6 +19,7 @@ export interface EntityLinkExternalProps {
}
export default function EntityLink({
ref,
icon,
label,
labelSmall,
@ -28,6 +30,7 @@ export default function EntityLink({
href = '', // Make link optional for debugging purposes
prefetch,
title,
accessory,
hoverEntity,
truncate = true,
className,
@ -42,6 +45,7 @@ export default function EntityLink({
href?: string
prefetch?: boolean
title?: string
accessory?: ReactNode
hoverEntity?: ReactNode
truncate?: boolean
className?: string
@ -49,6 +53,8 @@ export default function EntityLink({
uppercase?: boolean
debug?: boolean
} & EntityLinkExternalProps) {
const [isLoading, setIsLoading] = useState(false);
const classForContrast = () => {
switch (contrast) {
case 'low':
@ -68,15 +74,20 @@ export default function EntityLink({
</ResponsiveText>;
return (
<span className={clsx(
'group inline-flex max-w-full overflow-hidden select-none',
<span
ref={ref}
className={clsx(
'inline-flex items-center gap-2',
'max-w-full overflow-hidden select-none',
className,
)}>
)}
>
<LinkWithStatus
href={href}
className="inline-flex items-center gap-2 max-w-full"
className="peer inline-flex items-center gap-2 max-w-full truncate"
isLoading={isLoading}
setIsLoading={setIsLoading}
>
{({ isLoading }) => <>
<LabeledIcon {...{
icon,
iconWide,
@ -109,8 +120,10 @@ export default function EntityLink({
{renderLabel}
</span>}
</LabeledIcon>
</LinkWithStatus>
{accessory}
{!isLoading && hoverEntity !== undefined &&
<span className="hidden group-hover:inline text-dim">
<span className="hidden peer-hover:inline text-dim">
{hoverEntity}
</span>}
{isLoading &&
@ -121,8 +134,6 @@ export default function EntityLink({
)}
color={contrast === 'frosted' ? 'text' : undefined}
/>}
</>}
</LinkWithStatus>
</span>
);
}

View File

@ -42,8 +42,6 @@ import { LuExpand } from 'react-icons/lu';
import LoaderButton from '@/components/primitives/LoaderButton';
import Tooltip from '@/components/Tooltip';
import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence } from 'framer-motion';
import useRecipeOverlay from '../recipe/useRecipeOverlay';
import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
@ -105,8 +103,8 @@ export default function PhotoLarge({
onVisible?: () => void
}) {
const ref = useRef<HTMLDivElement>(null);
const zoomControlsRef = useRef<ZoomControlsRef>(null);
const refZoomControls = useRef<ZoomControlsRef>(null);
const refPhotoRecipe = useRef<HTMLDivElement>(null);
const {
areZoomControlsShown,
@ -132,8 +130,7 @@ export default function PhotoLarge({
, []);
const refRecipe = useRef<HTMLDivElement>(null);
const refRecipeButton = useRef<HTMLButtonElement>(null);
const refTriggers = useMemo(() => [refRecipeButton], []);
const refTriggers = useMemo(() => [refPhotoRecipe], []);
const {
shouldShowRecipeOverlay,
toggleRecipeOverlay,
@ -155,7 +152,6 @@ export default function PhotoLarge({
const showLensContent = showLens && shouldShowLensDataForPhoto(photo);
const showTagsContent = tags.length > 0;
const showRecipeContent = showRecipe && shouldShowRecipeDataForPhoto(photo);
const showRecipeButton = shouldShowRecipeDataForPhoto(photo);
const showFilmContent = showFilm && shouldShowFilmDataForPhoto(photo);
useVisible({ ref, onVisible });
@ -205,7 +201,7 @@ export default function PhotoLarge({
arePhotosMatted && matteContentWidthForAspectRatio,
)}>
<ZoomControls
ref={zoomControlsRef}
ref={refZoomControls}
selectImageElement={selectZoomImageElement}
{...{ isEnabled: showZoomControls, shouldZoomOnFKeydown }}
>
@ -334,10 +330,13 @@ export default function PhotoLarge({
</div>}
{showRecipeContent && recipeTitle &&
<PhotoRecipe
ref={refPhotoRecipe}
recipe={recipeTitle}
contrast="medium"
prefetch={prefetchRelatedLinks}
countOnHover={recipeCount}
toggleRecipeOverlay={toggleRecipeOverlay}
shouldShowRecipeOverlay={shouldShowRecipeOverlay}
/>}
{showTagsContent &&
<PhotoTags
@ -397,41 +396,12 @@ export default function PhotoLarge({
<li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul>
{(showRecipeButton || showFilmContent) &&
<div className="flex items-center gap-2 *:w-auto">
{showFilmContent && photo.film &&
<PhotoFilm
film={photo.film}
prefetch={prefetchRelatedLinks}
countOnHover={filmCount}
/>}
{showRecipeButton &&
<Tooltip content="Fujifilm Recipe">
<button
ref={refRecipeButton}
title="Fujifilm Recipe"
onClick={() => {
toggleRecipeOverlay();
// Avoid unexpected tooltip trigger
refRecipeButton.current?.blur();
}}
className={clsx(
'text-medium',
'border-medium rounded-md',
'px-[4px] py-[2.5px] my-[-3px]',
'translate-y-[2px]',
'hover:bg-dim active:bg-main',
!showFilm && 'translate-x-[-2px]',
)}>
{shouldShowRecipeOverlay
? <IoCloseSharp size={15} />
: <TbChecklist
className="translate-x-[0.5px]"
size={15}
/>}
</button>
</Tooltip>}
</div>}
</>}
<div className={clsx(
'flex gap-x-3 gap-y-baseline',
@ -458,7 +428,7 @@ export default function PhotoLarge({
<LoaderButton
title="Open Image Viewer"
icon={<LuExpand size={15} />}
onClick={() => zoomControlsRef.current?.open()}
onClick={() => refZoomControls.current?.open()}
styleAs="link"
className="text-medium translate-y-[0.25px]"
hideFocusOutline

View File

@ -4,27 +4,26 @@ import EntityLink, {
} from '@/components/primitives/EntityLink';
import { formatRecipe } from '.';
import clsx from 'clsx/lite';
import { RefObject } from 'react';
import { ComponentProps } from 'react';
import IconRecipe from '@/components/icons/IconRecipe';
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
export default function PhotoRecipe({
ref,
recipe,
countOnHover,
refButton,
isOpen,
recipeOnClick,
toggleRecipeOverlay,
shouldShowRecipeOverlay,
...props
}: {
recipe: string
refButton?: RefObject<HTMLButtonElement | null>
isOpen?: boolean
recipeOnClick?: () => void
countOnHover?: number
} & EntityLinkExternalProps) {
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
return (
<div className="flex w-full gap-2">
<EntityLink
{...props}
ref={ref}
title="Recipe"
label={formatRecipe(recipe)}
href={pathForRecipe(recipe)}
@ -36,21 +35,12 @@ export default function PhotoRecipe({
: 'translate-y-[-0.5px]',
)}
/>}
accessory={toggleRecipeOverlay &&
<PhotoRecipeOverlayButton {...{
toggleRecipeOverlay,
shouldShowRecipeOverlay,
}} />}
hoverEntity={countOnHover}
/>
{recipeOnClick &&
<button
ref={refButton}
onClick={recipeOnClick}
className={clsx(
'self-start',
'px-1 py-0.5',
'text-[10px] text-main font-medium tracking-wider',
'translate-y-[0.5px]',
)}
>
{isOpen ? 'CLOSE' : 'RECIPE'}
</button>}
</div>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import clsx from 'clsx/lite';
import { FaPlus } from 'react-icons/fa6';
import Tooltip from '@/components/Tooltip';
import { useRef } from 'react';
export default function PhotoRecipeOverlayButton({
className,
toggleRecipeOverlay,
shouldShowRecipeOverlay,
}: {
className?: string
toggleRecipeOverlay: () => void
shouldShowRecipeOverlay?: boolean
}) {
const ref = useRef<HTMLButtonElement>(null);
return (
<Tooltip content="Recipe Info">
<button
ref={ref}
onClick={() => {
toggleRecipeOverlay?.();
// Avoid unexpected tooltip trigger
ref.current?.blur();
}}
className={clsx(
'text-medium',
'border-medium rounded-md shadow-none',
'px-[3px] py-[3px] my-[-3px]',
'hover:bg-extra-dim active:bg-dim',
className,
)}>
<FaPlus
className={clsx(
'transition-transform',
shouldShowRecipeOverlay && 'rotate-45',
)}
size={10}
/>
</button>
</Tooltip>
);
}

View File

@ -20,7 +20,7 @@ export default function RecipeHeader({
count?: number
dateRange?: PhotoDateRange
}) {
const { setRecipeModalProps } = useAppState();
const { recipeModalProps, setRecipeModalProps } = useAppState();
const photo = getPhotoWithRecipeFromPhotos(photos, selectedPhoto);
@ -30,7 +30,8 @@ export default function RecipeHeader({
entity={<PhotoRecipe
recipe={recipe}
contrast="high"
recipeOnClick={() => (
shouldShowRecipeOverlay={Boolean(recipeModalProps)}
toggleRecipeOverlay={() => (
photo?.recipeData &&
photo?.film
) ? setRecipeModalProps?.({

View File

@ -1,8 +1,3 @@
import {
getPathComponents,
pathForPhoto,
} from '@/app/paths';
import { usePathname } from 'next/navigation';
import { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
@ -14,53 +9,15 @@ export default function useRecipeOverlay({
ref?: RefObject<HTMLElement | null>,
refTriggers?: RefObject<HTMLElement | null>[],
}) {
const pathname = usePathname();
const {
photoId,
...pathComponents
} = getPathComponents(pathname);
const [shouldShowRecipeOverlay, setShouldShowRecipeOverlay] = useState(false);
const setVisibility = useCallback((shouldShow: boolean) => {
if (shouldShow) {
setShouldShowRecipeOverlay(true);
// Only add query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
showRecipe: true,
}),
);
}
} else {
setShouldShowRecipeOverlay(false);
// Only remove query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
}),
);
}
}
}, [pathComponents, photoId]);
const showRecipeOverlay =
useCallback(() => setVisibility(true), [setVisibility]);
useCallback(() => setShouldShowRecipeOverlay(true), []);
const hideRecipeOverlay =
useCallback(() => setVisibility(false), [setVisibility]);
useCallback(() => setShouldShowRecipeOverlay(false), []);
const toggleRecipeOverlay = useCallback(() =>
setVisibility(!shouldShowRecipeOverlay),
[setVisibility, shouldShowRecipeOverlay]);
setShouldShowRecipeOverlay(current => !current),
[]);
useClickInsideOutside({
htmlElements: [ref, ...refTriggers],

View File

@ -101,7 +101,6 @@
@utility text-light {
@apply text-gray-100
}
/* Text */
@utility text-main {
@apply