Create link component with loader status
This commit is contained in:
parent
18b33389b5
commit
19a7c59c9a
@ -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 [
|
||||
|
||||
111
src/components/LinkWithStatus.tsx
Normal file
111
src/components/LinkWithStatus.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user