Create link component with loader status

This commit is contained in:
Sam Becker 2025-01-19 12:38:02 -06:00
parent 18b33389b5
commit 19a7c59c9a
4 changed files with 131 additions and 10 deletions

View File

@ -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 [

View File

@ -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<typeof Link> & {
loader?: ReactNode
}
export default function LinkWithStatus({
loader,
href,
className,
onClick,
children,
...props
}: LinkWithStatusProps) {
const path = usePathname();
const [isLoading, setIsLoading] = useState(false);
const isLoadingStartTime = useRef<number | undefined>(undefined);
const startLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(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 <Link
{...props }
href={href}
className={clsx(
className,
'relative',
)}
onClick={e => {
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);
}}
>
<span className={clsx(
'flex transition-opacity',
loader
? isLoading ? 'opacity-0' : 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100',
)}>
{children}
</span>
{isLoading && loader && <span className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
{loader}
</span>}
</Link>;
}

View File

@ -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
: <div className="w-[28px] h-[24px] flex items-center justify-center">
{icon}
{content}
</div>;
return (
href
? <Link {...{ title, href, className, prefetch }}>
{renderIcon()}
</Link>
: <div {...{ title, onClick, className }}>{renderIcon()}</div>
? <LinkWithStatus {...{
title,
href,
className,
prefetch,
loader: <Spinner />,
}}>
{renderContent(icon)}
</LinkWithStatus>
: <div {...{ title, onClick, className }}>{renderContent(icon)}</div>
);
};

View File

@ -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 });
};
};