mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 06:13:39 +00:00
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
|
import { usePathname, useSearchParams } from 'next/navigation';
|
|
|
|
// Minimum time the bar stays visible once shown, so very fast local
|
|
// transitions don't flash imperceptibly (standard practice for top-loading
|
|
// progress bars).
|
|
const MIN_VISIBLE_MS = 300;
|
|
|
|
function LoadingIndicatorInner() {
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const shownAtRef = useRef<number | null>(null);
|
|
|
|
// Detects the START of a navigation: a same-origin link click gives
|
|
// instant feedback on tap, before the RSC fetch even begins. Browser
|
|
// back/forward (popstate) is covered the same way.
|
|
useEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (e.defaultPrevented || e.button !== 0) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
|
|
const anchor = (e.target as HTMLElement | null)?.closest('a');
|
|
if (!anchor) return;
|
|
if (anchor.target && anchor.target !== '_self') return;
|
|
if (anchor.hasAttribute('download')) return;
|
|
|
|
const href = anchor.getAttribute('href');
|
|
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
|
|
|
let url: URL;
|
|
try {
|
|
url = new URL(href, window.location.href);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (url.origin !== window.location.origin) return;
|
|
if (url.pathname + url.search === window.location.pathname + window.location.search) return;
|
|
|
|
shownAtRef.current = Date.now();
|
|
setIsVisible(true);
|
|
};
|
|
|
|
const handlePopState = () => {
|
|
shownAtRef.current = Date.now();
|
|
setIsVisible(true);
|
|
};
|
|
|
|
document.addEventListener('click', handleClick, true);
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => {
|
|
document.removeEventListener('click', handleClick, true);
|
|
window.removeEventListener('popstate', handlePopState);
|
|
};
|
|
}, []);
|
|
|
|
// Detects the END of a navigation: pathname/search settling to a new value
|
|
// means the route transition has completed.
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS;
|
|
const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
|
const timer = setTimeout(() => setIsVisible(false), remaining);
|
|
return () => clearTimeout(timer);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pathname, searchParams]);
|
|
|
|
if (!isVisible) return null;
|
|
|
|
return (
|
|
<div className="fixed top-0 left-0 right-0 z-[9999]">
|
|
<div className="h-1 bg-gradient-to-r from-primary via-primary/70 to-primary animate-pulse">
|
|
<div className="h-full bg-gradient-to-r from-primary to-primary/50 animate-[shimmer_2s_infinite]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LoadingIndicator() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<LoadingIndicatorInner />
|
|
</Suspense>
|
|
);
|
|
}
|