Add loader status to photo links

This commit is contained in:
Sam Becker 2025-03-24 23:45:15 -05:00
parent d7faf2ab92
commit 0f1753fad0
10 changed files with 93 additions and 67 deletions

View File

@ -1,13 +1,13 @@
'use client'; 'use client';
import { PATH_ADMIN_CONFIGURATION, PATH_ADMIN_INSIGHTS } from '@/app/paths'; import { PATH_ADMIN_CONFIGURATION, PATH_ADMIN_INSIGHTS } from '@/app/paths';
import LinkWithStatus from '@/components/LinkWithStatus';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import ClearCacheButton from '@/admin/ClearCacheButton'; import ClearCacheButton from '@/admin/ClearCacheButton';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import LinkWithLoaderBadge from '@/components/LinkWithLoaderBadge';
const ADMIN_INFO_PAGES = [{ const ADMIN_INFO_PAGES = [{
title: 'App Insights', title: 'App Insights',
@ -47,7 +47,7 @@ export default function AdminInfoPage({
)}> )}>
{pages {pages
.map(({ title, titleShort, path }) => .map(({ title, titleShort, path }) =>
<LinkWithStatus <LinkWithLoaderBadge
key={path} key={path}
href={path} href={path}
className={clsx( className={clsx(
@ -57,10 +57,8 @@ export default function AdminInfoPage({
? 'font-medium' ? 'font-medium'
: 'text-dim' : 'text-dim'
: undefined, : undefined,
'px-1 py-0.5 rounded-md',
'hover:text-main', 'hover:text-main',
)} )}
loadingClassName="bg-gray-200/50 dark:bg-gray-700/50"
> >
<ResponsiveText shortText={titleShort}> <ResponsiveText shortText={titleShort}>
{title} {title}
@ -71,7 +69,7 @@ export default function AdminInfoPage({
top={4} top={4}
right={-2} right={-2}
/>} />}
</LinkWithStatus>)} </LinkWithLoaderBadge>)}
</div> </div>
<ClearCacheButton /> <ClearCacheButton />
</div> </div>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import LinkWithLoader from '@/components/LinkWithLoader'; import LinkWithIconLoader from '@/components/LinkWithIconLoader';
import Note from '@/components/Note'; import Note from '@/components/Note';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
@ -21,7 +21,7 @@ import AdminAppInfoIcon from './AdminAppInfoIcon';
import AdminInfoNav from './AdminInfoNav'; import AdminInfoNav from './AdminInfoNav';
import LinkWithLoaderBadge from '@/components/LinkWithLoaderBadge'; import LinkWithLoaderBadge from '@/components/LinkWithLoaderBadge';
// Updates considered recent if they occurred in past 5 minutes // Updates from past 5 minutes considered recent
const areTimesRecent = (dates: Date[]) => dates const areTimesRecent = (dates: Date[]) => dates
.some(date => differenceInMinutes(new Date(), date) < 5); .some(date => differenceInMinutes(new Date(), date) < 5);
@ -89,17 +89,16 @@ export default function AdminNavClient({
<span>({count})</span>} <span>({count})</span>}
</LinkWithLoaderBadge>)} </LinkWithLoaderBadge>)}
</div> </div>
<LinkWithLoader <LinkWithIconLoader
href={includeInsights href={includeInsights
? PATH_ADMIN_INSIGHTS ? PATH_ADMIN_INSIGHTS
: PATH_ADMIN_CONFIGURATION} : PATH_ADMIN_CONFIGURATION}
className={isPathAdminInfo(pathname) className={isPathAdminInfo(pathname)
? 'font-bold' ? 'font-bold'
: 'text-dim'} : 'text-dim'}
icon={<AdminAppInfoIcon />}
loader={<Spinner className="translate-y-[-0.75px]" />} loader={<Spinner className="translate-y-[-0.75px]" />}
> />
<AdminAppInfoIcon />
</LinkWithLoader>
</div> </div>
{shouldShowBanner && {shouldShowBanner &&
<Note icon={<FaRegClock className="shrink-0" />}> <Note icon={<FaRegClock className="shrink-0" />}>

View File

@ -1,25 +1,28 @@
import { ComponentProps, ReactNode } from 'react'; import { ComponentProps, ReactNode } from 'react';
import LinkWithStatus from './LinkWithStatus'; import LinkWithStatus from './LinkWithStatus';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import Link from 'next/link';
export default function LinkWithLoader({ export default function LinkWithIconLoader({
className,
icon,
loader, loader,
children,
debugLoading, debugLoading,
...props ...props
}: ComponentProps<typeof Link> & { }: Omit<ComponentProps<typeof LinkWithStatus>, 'children'> & {
icon: ReactNode
loader: ReactNode loader: ReactNode
debugLoading?: boolean
}) { }) {
return ( return (
<LinkWithStatus {...props}> <LinkWithStatus
{...props}
className={clsx('relative', className)}
>
{({ isLoading }) => <> {({ isLoading }) => <>
<span className={clsx( <span className={clsx(
'flex transition-opacity', 'flex transition-opacity',
isLoading ? 'opacity-0' : 'opacity-100', isLoading || debugLoading ? 'opacity-0' : 'opacity-100',
)}> )}>
{children} {icon}
</span> </span>
{(isLoading || debugLoading) && <span className={clsx( {(isLoading || debugLoading) && <span className={clsx(
'absolute inset-0', 'absolute inset-0',

View File

@ -5,13 +5,18 @@ import LinkWithStatus from './LinkWithStatus';
export default function LinkWithLoaderBadge({ export default function LinkWithLoaderBadge({
className, className,
loadingClassName, loadingClassName,
offsetPadding,
...props ...props
}: ComponentProps<typeof LinkWithStatus>) { }: ComponentProps<typeof LinkWithStatus> & {
offsetPadding?: boolean
}) {
return ( return (
<LinkWithStatus <LinkWithStatus
{...props} {...props}
className={clsx( className={clsx(
'px-1 py-0.5 rounded-md', offsetPadding && '-mx-1 -my-0.5',
'px-1 py-0.5',
'rounded-md',
className, className,
)} )}
loadingClassName={clsx( loadingClassName={clsx(

View File

@ -89,10 +89,10 @@ export default function LinkWithStatus({
useEffect(() => () => clearTimeouts(), [clearTimeouts]); useEffect(() => () => clearTimeouts(), [clearTimeouts]);
return <Link return <Link
{...props } {...props}
href={href} href={href}
className={clsx( className={clsx(
'relative inline-flex transition-[colors,opacity]', 'transition-[colors,opacity]',
(loadingClassName || isControlled) (loadingClassName || isControlled)
? 'opacity-100' ? 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100', : isLoading ? 'opacity-50' : 'opacity-100',

View File

@ -2,7 +2,7 @@ import { clsx } from 'clsx/lite';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/app/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/app/config';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Spinner from './Spinner'; import Spinner from './Spinner';
import LinkWithLoader from './LinkWithLoader'; import LinkWithIconLoader from './LinkWithIconLoader';
export default function SwitcherItem({ export default function SwitcherItem({
icon, icon,
@ -52,15 +52,14 @@ export default function SwitcherItem({
return ( return (
href href
? <LinkWithLoader {...{ ? <LinkWithIconLoader {...{
title, title,
href, href,
className, className,
prefetch, prefetch,
icon: renderIcon(),
loader: <Spinner />, loader: <Spinner />,
}}> }} />
{renderIcon()}
</LinkWithLoader>
: <div {...{ title, onClick, className }}> : <div {...{ title, onClick, className }}>
{renderIcon()} {renderIcon()}
</div> </div>

View File

@ -68,7 +68,7 @@ export default function EntityLink({
return ( return (
<span className={clsx( <span className={clsx(
'group inline-flex max-w-full overflow-hidden', 'group inline-flex max-w-full overflow-hidden select-none',
className, className,
)}> )}>
<LinkWithStatus <LinkWithStatus

View File

@ -256,20 +256,18 @@ export default function PhotoLarge({
'pb-6', 'pb-6',
)}> )}>
{/* Meta */} {/* Meta */}
<div className="pr-2 md:pr-0"> <div>
<div className="md:relative flex gap-2 items-start"> <div className="float-right translate-y-[-4px]">
{hasTitle && (showTitleAsH1 <AdminPhotoMenuClient {...{
? <h1>{renderPhotoLink}</h1> photo,
: renderPhotoLink)} revalidatePhoto,
<div className="absolute right-0 translate-y-[-4px] z-10"> includeFavorite: includeFavoriteInAdminMenu,
<AdminPhotoMenuClient {...{ ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
photo, }} />
revalidatePhoto,
includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
}} />
</div>
</div> </div>
{hasTitle && (showTitleAsH1
? <h1>{renderPhotoLink}</h1>
: renderPhotoLink)}
<div className="space-y-baseline"> <div className="space-y-baseline">
{photo.caption && {photo.caption &&
<div className={clsx( <div className={clsx(

View File

@ -1,13 +1,15 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ReactNode, ComponentProps } from 'react';
import { Photo, titleForPhoto } from '@/photo'; import { Photo, titleForPhoto } from '@/photo';
import { PhotoSetCategory } from '@/category'; import { PhotoSetCategory } from '@/category';
import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems'; import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { pathForPhoto } from '@/app/paths'; import { pathForPhoto } from '@/app/paths';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import LinkWithStatus from '@/components/LinkWithStatus';
import Spinner from '@/components/Spinner';
import LinkWithLoaderBadge from '@/components/LinkWithLoaderBadge';
export default function PhotoLink({ export default function PhotoLink({
photo, photo,
@ -15,7 +17,8 @@ export default function PhotoLink({
prefetch, prefetch,
nextPhotoAnimation, nextPhotoAnimation,
className, className,
children, children: _children,
loaderType = 'spinner',
...categories ...categories
}: { }: {
photo?: Photo photo?: Photo
@ -24,29 +27,52 @@ export default function PhotoLink({
nextPhotoAnimation?: AnimationConfig nextPhotoAnimation?: AnimationConfig
className?: string className?: string
children?: ReactNode children?: ReactNode
loaderType?: 'spinner' | 'badge'
} & PhotoSetCategory) { } & PhotoSetCategory) {
const { setNextPhotoAnimation } = useAppState(); const { setNextPhotoAnimation } = useAppState();
return ( const linkProps:
photo Omit<ComponentProps<typeof LinkWithStatus>, 'children'> |
? <Link undefined = photo
href={pathForPhoto({ photo, ...categories })} ? {
prefetch={prefetch} className,
onClick={() => { href: pathForPhoto({ photo, ...categories }),
onClick: () => {
if (nextPhotoAnimation) { if (nextPhotoAnimation) {
setNextPhotoAnimation?.(nextPhotoAnimation); setNextPhotoAnimation?.(nextPhotoAnimation);
} }
}} },
className={className} scroll,
scroll={scroll} prefetch,
> }
{children ?? titleForPhoto(photo)} : undefined;
</Link>
const children = photo
? (_children ?? titleForPhoto(photo))
: _children;
return (
photo && linkProps
? loaderType === 'spinner'
? <LinkWithStatus {...linkProps}>
{({ isLoading }) => <>
{children}
{isLoading && <>
&nbsp;<Spinner className="translate-y-[0.5px]" />
</>}
</>}
</LinkWithStatus>
: <LinkWithLoaderBadge
{...linkProps}
offsetPadding
>
{children}
</LinkWithLoaderBadge>
: <span className={clsx( : <span className={clsx(
'text-gray-300 dark:text-gray-700 cursor-default', 'text-gray-300 dark:text-gray-700 cursor-default',
className, className,
)}> )}>
{children ?? (photo ? titleForPhoto(photo) : undefined)} {children}
</span> </span>
); );
}; };

View File

@ -85,21 +85,21 @@ export default function PhotoPrevNext({
className, className,
)}> )}>
<div className={clsx( <div className={clsx(
'h-4',
'flex gap-2 select-none', 'flex gap-2 select-none',
// Fixes alignment issue when switching from chevrons to text // Fixes alignment issue when switching from chevrons to text
'items-center sm:items-start', 'items-center sm:items-start',
'*:select-none',
)}> )}>
<PhotoLink <PhotoLink
{...categories} {...categories}
photo={previousPhoto} photo={previousPhoto}
className="select-none h-[1rem]"
nextPhotoAnimation={ANIMATION_RIGHT} nextPhotoAnimation={ANIMATION_RIGHT}
scroll={false} scroll={false}
loaderType="badge"
prefetch prefetch
> >
<FiChevronLeft <FiChevronLeft className="sm:hidden text-[1.1rem]" />
className="sm:hidden text-[1.1rem] translate-y-[-1px]"
/>
<span className="hidden sm:inline-block">PREV</span> <span className="hidden sm:inline-block">PREV</span>
</PhotoLink> </PhotoLink>
<span className="text-extra-extra-dim"> <span className="text-extra-extra-dim">
@ -108,14 +108,12 @@ export default function PhotoPrevNext({
<PhotoLink <PhotoLink
{...categories} {...categories}
photo={nextPhoto} photo={nextPhoto}
className="select-none h-[1rem]"
nextPhotoAnimation={ANIMATION_LEFT} nextPhotoAnimation={ANIMATION_LEFT}
scroll={false} scroll={false}
loaderType="badge"
prefetch prefetch
> >
<FiChevronRight <FiChevronRight className="sm:hidden text-[1.1rem]" />
className="sm:hidden text-[1.1rem] translate-y-[-1px]"
/>
<span className="hidden sm:inline-block">NEXT</span> <span className="hidden sm:inline-block">NEXT</span>
</PhotoLink> </PhotoLink>
</div> </div>