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", "mitigations",
"nanoids", "nanoids",
"nextjs", "nextjs",
"nowrap",
"parameterizes", "parameterizes",
"presigner", "presigner",
"Provia", "Provia",

View File

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

View File

@ -17,30 +17,28 @@ const FLICKER_THRESHOLD = 400;
// Clear loading status after long duration // Clear loading status after long duration
const MAX_LOADING_DURATION = 15_000; 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({ export default function LinkWithStatus({
loadingClassName, loadingClassName,
href, href,
className, className,
onClick, onClick,
children, children,
debugLoading = false, isLoading: isLoadingProp = false,
setIsLoading: setIsLoadingProp,
...props ...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 path = usePathname();
const [pathWhenClicked, setPathWhenClicked] = useState<string>(); const [pathWhenClicked, setPathWhenClicked] = useState<string>();
const [_isLoading, setIsLoading] = useState(false); const [_isLoading, _setIsLoading] = useState(false);
const isLoading = _isLoading || debugLoading; const isLoading = isLoadingProp || _isLoading;
const setIsLoading = setIsLoadingProp || _setIsLoading;
const isLoadingStartTime = useRef<number | undefined>(undefined); const isLoadingStartTime = useRef<number | undefined>(undefined);
@ -60,7 +58,7 @@ export default function LinkWithStatus({
const stopLoading = useCallback(() => { const stopLoading = useCallback(() => {
setIsLoading(false); setIsLoading(false);
setPathWhenClicked(undefined); setPathWhenClicked(undefined);
}, []); }, [setIsLoading]);
const isVisitingLinkHref = path === href; const isVisitingLinkHref = path === href;

View File

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

View File

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

View File

@ -4,27 +4,26 @@ import EntityLink, {
} from '@/components/primitives/EntityLink'; } from '@/components/primitives/EntityLink';
import { formatRecipe } from '.'; import { formatRecipe } from '.';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { RefObject } from 'react'; import { ComponentProps } from 'react';
import IconRecipe from '@/components/icons/IconRecipe'; import IconRecipe from '@/components/icons/IconRecipe';
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
export default function PhotoRecipe({ export default function PhotoRecipe({
ref,
recipe, recipe,
countOnHover, countOnHover,
refButton, toggleRecipeOverlay,
isOpen, shouldShowRecipeOverlay,
recipeOnClick,
...props ...props
}: { }: {
recipe: string recipe: string
refButton?: RefObject<HTMLButtonElement | null>
isOpen?: boolean
recipeOnClick?: () => void
countOnHover?: number countOnHover?: number
} & EntityLinkExternalProps) { } & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
return ( return (
<div className="flex w-full gap-2">
<EntityLink <EntityLink
{...props} {...props}
ref={ref}
title="Recipe" title="Recipe"
label={formatRecipe(recipe)} label={formatRecipe(recipe)}
href={pathForRecipe(recipe)} href={pathForRecipe(recipe)}
@ -36,21 +35,12 @@ export default function PhotoRecipe({
: 'translate-y-[-0.5px]', : 'translate-y-[-0.5px]',
)} )}
/>} />}
accessory={toggleRecipeOverlay &&
<PhotoRecipeOverlayButton {...{
toggleRecipeOverlay,
shouldShowRecipeOverlay,
}} />}
hoverEntity={countOnHover} 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 count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const { setRecipeModalProps } = useAppState(); const { recipeModalProps, setRecipeModalProps } = useAppState();
const photo = getPhotoWithRecipeFromPhotos(photos, selectedPhoto); const photo = getPhotoWithRecipeFromPhotos(photos, selectedPhoto);
@ -30,7 +30,8 @@ export default function RecipeHeader({
entity={<PhotoRecipe entity={<PhotoRecipe
recipe={recipe} recipe={recipe}
contrast="high" contrast="high"
recipeOnClick={() => ( shouldShowRecipeOverlay={Boolean(recipeModalProps)}
toggleRecipeOverlay={() => (
photo?.recipeData && photo?.recipeData &&
photo?.film photo?.film
) ? setRecipeModalProps?.({ ) ? 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 { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom'; import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside'; import useClickInsideOutside from '@/utility/useClickInsideOutside';
@ -14,53 +9,15 @@ export default function useRecipeOverlay({
ref?: RefObject<HTMLElement | null>, ref?: RefObject<HTMLElement | null>,
refTriggers?: RefObject<HTMLElement | null>[], refTriggers?: RefObject<HTMLElement | null>[],
}) { }) {
const pathname = usePathname();
const {
photoId,
...pathComponents
} = getPathComponents(pathname);
const [shouldShowRecipeOverlay, setShouldShowRecipeOverlay] = useState(false); 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 = const showRecipeOverlay =
useCallback(() => setVisibility(true), [setVisibility]); useCallback(() => setShouldShowRecipeOverlay(true), []);
const hideRecipeOverlay = const hideRecipeOverlay =
useCallback(() => setVisibility(false), [setVisibility]); useCallback(() => setShouldShowRecipeOverlay(false), []);
const toggleRecipeOverlay = useCallback(() => const toggleRecipeOverlay = useCallback(() =>
setVisibility(!shouldShowRecipeOverlay), setShouldShowRecipeOverlay(current => !current),
[setVisibility, shouldShowRecipeOverlay]); []);
useClickInsideOutside({ useClickInsideOutside({
htmlElements: [ref, ...refTriggers], htmlElements: [ref, ...refTriggers],

View File

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