From 19a7c59c9a56a939554f5ed3f2e8ce1e765be987 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 19 Jan 2025 12:38:02 -0600 Subject: [PATCH] Create link component with loader status --- src/app/admin/photos/page.tsx | 3 + src/components/LinkWithStatus.tsx | 111 ++++++++++++++++++++++++++++++ src/components/SwitcherItem.tsx | 25 ++++--- src/photo/PhotoEscapeHandler.tsx | 2 +- 4 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 src/components/LinkWithStatus.tsx diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index d8ef3d11..280a8e4b 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -6,6 +6,7 @@ import AdminPhotosClient from '@/admin/AdminPhotosClient'; import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; +import sleep from '@/utility/sleep'; export const maxDuration = 60; @@ -15,6 +16,8 @@ const INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS = 25; const INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS = 50; export default async function AdminPhotosPage() { + await sleep(3000); + const timezone = (await cookies()).get(TIMEZONE_COOKIE_NAME)?.value; const [ diff --git a/src/components/LinkWithStatus.tsx b/src/components/LinkWithStatus.tsx new file mode 100644 index 00000000..7ea67ca9 --- /dev/null +++ b/src/components/LinkWithStatus.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { + ComponentProps, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import clsx from 'clsx/lite'; + +// Avoid showing spinner for too short a time +const FLICKER_THRESHOLD = 400; +// Clear loading status after 10 seconds of inactivity +const MAX_LOADING_DURATION = 10_000; + +export type LinkWithStatusProps = ComponentProps & { + loader?: ReactNode +} + +export default function LinkWithStatus({ + loader, + href, + className, + onClick, + children, + ...props +}: LinkWithStatusProps) { + const path = usePathname(); + + const [isLoading, setIsLoading] = useState(false); + + const isLoadingStartTime = useRef(undefined); + + const startLoadingTimeout = useRef(undefined); + const stopLoadingTimeout = useRef(undefined); + const maxLoadingTimeout = useRef(undefined); + + const clearTimeouts = useCallback(() => + [startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout] + .forEach(timeout => { + if (timeout.current) { clearTimeout(timeout.current); } + }), + []); + + const isVisitingLinkHref = path === href; + + useEffect(() => { + if (isVisitingLinkHref) { + clearTimeouts(); + const loadingDuration = isLoadingStartTime.current + ? Date.now() - isLoadingStartTime.current + : 0; + if (loadingDuration < FLICKER_THRESHOLD) { + stopLoadingTimeout.current = setTimeout( + () => { setIsLoading(false); }, + FLICKER_THRESHOLD - loadingDuration, + ); + } else { + setIsLoading(false); + } + } + }, [isVisitingLinkHref, clearTimeouts]); + + // Clear timeouts when unmounting + useEffect(() => () => clearTimeouts(), [clearTimeouts]); + + return { + const isOpeningNewTab = e.metaKey || e.ctrlKey; + if (!isVisitingLinkHref && !isOpeningNewTab) { + startLoadingTimeout.current = setTimeout( + () => { + isLoadingStartTime.current = Date.now(); + setIsLoading(true); + }, + FLICKER_THRESHOLD, + ); + maxLoadingTimeout.current = setTimeout( + () => { setIsLoading(false); }, + MAX_LOADING_DURATION, + ); + } + onClick?.(e); + }} + > + + {children} + + {isLoading && loader && + {loader} + } + ; +} diff --git a/src/components/SwitcherItem.tsx b/src/components/SwitcherItem.tsx index 496db457..d6160bda 100644 --- a/src/components/SwitcherItem.tsx +++ b/src/components/SwitcherItem.tsx @@ -1,7 +1,8 @@ -import Link from 'next/link'; import { clsx } from 'clsx/lite'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; -import { JSX } from 'react'; +import { JSX, ReactNode } from 'react'; +import LinkWithStatus from './LinkWithStatus'; +import Spinner from './Spinner'; export default function SwitcherItem({ icon, @@ -36,17 +37,23 @@ export default function SwitcherItem({ : 'hover:text-gray-700 dark:hover:text-gray-400', ); - const renderIcon = () => noPadding - ? icon + const renderContent = (content: ReactNode) => noPadding + ? content :
- {icon} + {content}
; return ( href - ? - {renderIcon()} - - :
{renderIcon()}
+ ? , + }}> + {renderContent(icon)} + + :
{renderContent(icon)}
); }; diff --git a/src/photo/PhotoEscapeHandler.tsx b/src/photo/PhotoEscapeHandler.tsx index 3500ce00..b8da15c9 100644 --- a/src/photo/PhotoEscapeHandler.tsx +++ b/src/photo/PhotoEscapeHandler.tsx @@ -19,7 +19,7 @@ export default function PhotoEscapeHandler() { useEffect(() => { if (shouldRespondToKeyboardCommands) { const onKeyUp = (e: KeyboardEvent) => { - if (e.key.toUpperCase() === 'ESCAPE' && escapePath) { + if (e.key?.toUpperCase() === 'ESCAPE' && escapePath) { router.push(escapePath, { scroll: false }); }; };