Streamline recipe overlay interactions
This commit is contained in:
parent
ae8a38f462
commit
d1689371a2
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -37,6 +37,7 @@
|
||||
"mitigations",
|
||||
"nanoids",
|
||||
"nextjs",
|
||||
"nowrap",
|
||||
"parameterizes",
|
||||
"presigner",
|
||||
"Provia",
|
||||
|
||||
@ -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',
|
||||
)}>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,61 +74,66 @@ export default function EntityLink({
|
||||
</ResponsiveText>;
|
||||
|
||||
return (
|
||||
<span className={clsx(
|
||||
'group inline-flex max-w-full overflow-hidden select-none',
|
||||
className,
|
||||
)}>
|
||||
<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,
|
||||
href,
|
||||
prefetch,
|
||||
title,
|
||||
type,
|
||||
uppercase,
|
||||
className: clsx(
|
||||
classForContrast(),
|
||||
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
classNameIcon,
|
||||
),
|
||||
classNameIcon: 'text-dim',
|
||||
debug,
|
||||
}}>
|
||||
{badged
|
||||
? <Badge
|
||||
type="small"
|
||||
contrast={contrast}
|
||||
className='translate-y-[-0.5px]'
|
||||
uppercase
|
||||
interactive
|
||||
>
|
||||
{renderLabel}
|
||||
</Badge>
|
||||
: <span className={clsx(
|
||||
truncate && 'inline-flex max-w-full *:truncate',
|
||||
)}>
|
||||
{renderLabel}
|
||||
</span>}
|
||||
</LabeledIcon>
|
||||
{!isLoading && hoverEntity !== undefined &&
|
||||
<span className="hidden group-hover:inline text-dim">
|
||||
{hoverEntity}
|
||||
<LabeledIcon {...{
|
||||
icon,
|
||||
iconWide,
|
||||
href,
|
||||
prefetch,
|
||||
title,
|
||||
type,
|
||||
uppercase,
|
||||
className: clsx(
|
||||
classForContrast(),
|
||||
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
classNameIcon,
|
||||
),
|
||||
classNameIcon: 'text-dim',
|
||||
debug,
|
||||
}}>
|
||||
{badged
|
||||
? <Badge
|
||||
type="small"
|
||||
contrast={contrast}
|
||||
className='translate-y-[-0.5px]'
|
||||
uppercase
|
||||
interactive
|
||||
>
|
||||
{renderLabel}
|
||||
</Badge>
|
||||
: <span className={clsx(
|
||||
truncate && 'inline-flex max-w-full *:truncate',
|
||||
)}>
|
||||
{renderLabel}
|
||||
</span>}
|
||||
{isLoading &&
|
||||
<Spinner
|
||||
className={clsx(
|
||||
badged && 'translate-y-[0.5px]',
|
||||
contrast === 'frosted' && 'text-neutral-500',
|
||||
)}
|
||||
color={contrast === 'frosted' ? 'text' : undefined}
|
||||
/>}
|
||||
</>}
|
||||
</LabeledIcon>
|
||||
</LinkWithStatus>
|
||||
{accessory}
|
||||
{!isLoading && hoverEntity !== undefined &&
|
||||
<span className="hidden peer-hover:inline text-dim">
|
||||
{hoverEntity}
|
||||
</span>}
|
||||
{isLoading &&
|
||||
<Spinner
|
||||
className={clsx(
|
||||
badged && 'translate-y-[0.5px]',
|
||||
contrast === 'frosted' && 'text-neutral-500',
|
||||
)}
|
||||
color={contrast === 'frosted' ? 'text' : undefined}
|
||||
/>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>}
|
||||
{showFilmContent && photo.film &&
|
||||
<PhotoFilm
|
||||
film={photo.film}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
countOnHover={filmCount}
|
||||
/>}
|
||||
</>}
|
||||
<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
|
||||
|
||||
@ -4,53 +4,43 @@ 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}
|
||||
title="Recipe"
|
||||
label={formatRecipe(recipe)}
|
||||
href={pathForRecipe(recipe)}
|
||||
icon={<IconRecipe
|
||||
size={16}
|
||||
className={clsx(
|
||||
props.badged
|
||||
? 'translate-x-[-1px] translate-y-[0.5px]'
|
||||
: 'translate-y-[-0.5px]',
|
||||
)}
|
||||
/>}
|
||||
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>
|
||||
<EntityLink
|
||||
{...props}
|
||||
ref={ref}
|
||||
title="Recipe"
|
||||
label={formatRecipe(recipe)}
|
||||
href={pathForRecipe(recipe)}
|
||||
icon={<IconRecipe
|
||||
size={16}
|
||||
className={clsx(
|
||||
props.badged
|
||||
? 'translate-x-[-1px] translate-y-[0.5px]'
|
||||
: 'translate-y-[-0.5px]',
|
||||
)}
|
||||
/>}
|
||||
accessory={toggleRecipeOverlay &&
|
||||
<PhotoRecipeOverlayButton {...{
|
||||
toggleRecipeOverlay,
|
||||
shouldShowRecipeOverlay,
|
||||
}} />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/recipe/PhotoRecipeOverlayButton.tsx
Normal file
45
src/recipe/PhotoRecipeOverlayButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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?.({
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -101,7 +101,6 @@
|
||||
@utility text-light {
|
||||
@apply text-gray-100
|
||||
}
|
||||
|
||||
/* Text */
|
||||
@utility text-main {
|
||||
@apply
|
||||
|
||||
Loading…
Reference in New Issue
Block a user