210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
import Image from "next/image";
|
|
import { usePathname } from "next/navigation";
|
|
|
|
/**
|
|
* LoadingScreen
|
|
* ---------------------------------------------------------------------------
|
|
* Route-transition loader only: a black full-screen overlay with a centered,
|
|
* pulsing Doormile logo. It intentionally does not run on initial page render,
|
|
* image loading, lazy component loading, API requests, or scroll.
|
|
*
|
|
* The App Router does not expose the old `next/router` routeChangeStart /
|
|
* routeChangeComplete event API. This component provides the same behavior by
|
|
* detecting internal route-link starts and completing when `usePathname()`
|
|
* reports the committed route.
|
|
*/
|
|
type Phase = "hidden" | "visible" | "hiding";
|
|
|
|
const MIN_VISIBLE_MS = 420;
|
|
const MAX_VISIBLE_MS = 800;
|
|
|
|
function getRoutePath(url: URL) {
|
|
return `${url.pathname}${url.search}`;
|
|
}
|
|
|
|
export default function LoadingScreen() {
|
|
const pathname = usePathname();
|
|
const [phase, setPhase] = useState<Phase>("hidden");
|
|
const phaseRef = useRef<Phase>("hidden");
|
|
const visibleSince = useRef(0);
|
|
const pendingPath = useRef<string | null>(null);
|
|
const currentRoutePath = useRef<string | null>(null);
|
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const safetyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const setLoaderPhase = (nextPhase: Phase) => {
|
|
phaseRef.current = nextPhase;
|
|
setPhase(nextPhase);
|
|
};
|
|
|
|
const clearTimer = (timer: MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
|
|
if (!timer.current) return;
|
|
clearTimeout(timer.current);
|
|
timer.current = null;
|
|
};
|
|
|
|
const completeTransition = () => {
|
|
pendingPath.current = null;
|
|
clearTimer(safetyTimer);
|
|
|
|
if (phaseRef.current === "hidden" || phaseRef.current === "hiding") return;
|
|
|
|
const elapsed = performance.now() - visibleSince.current;
|
|
const wait = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
|
clearTimer(hideTimer);
|
|
hideTimer.current = setTimeout(() => {
|
|
setLoaderPhase("hiding");
|
|
hideTimer.current = setTimeout(() => setLoaderPhase("hidden"), 360);
|
|
}, wait);
|
|
};
|
|
|
|
useEffect(() => {
|
|
currentRoutePath.current = `${pathname}${window.location.search}`;
|
|
completeTransition();
|
|
// `phase` intentionally stays out of this dependency list. The route commit
|
|
// is the completion signal; phase changes should not repeatedly restart hide.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
const startTransition = (targetPath: string, force = false) => {
|
|
if (!force && targetPath === getRoutePath(new URL(window.location.href))) return;
|
|
if (pendingPath.current === targetPath && phaseRef.current === "visible") return;
|
|
|
|
pendingPath.current = targetPath;
|
|
clearTimer(hideTimer);
|
|
clearTimer(safetyTimer);
|
|
|
|
visibleSince.current = performance.now();
|
|
setLoaderPhase("visible");
|
|
|
|
safetyTimer.current = setTimeout(() => {
|
|
completeTransition();
|
|
}, MAX_VISIBLE_MS);
|
|
};
|
|
|
|
const getInternalRouteTarget = (anchor: HTMLAnchorElement) => {
|
|
const rawHref = anchor.getAttribute("href");
|
|
if (!rawHref || rawHref.startsWith("#")) return null;
|
|
if (anchor.target && anchor.target !== "_self") return null;
|
|
if (anchor.hasAttribute("download")) return null;
|
|
if (/^(mailto:|tel:|sms:|javascript:)/i.test(rawHref)) return null;
|
|
|
|
const url = new URL(rawHref, window.location.href);
|
|
if (url.origin !== window.location.origin) return null;
|
|
|
|
const current = new URL(window.location.href);
|
|
const sameRoute = url.pathname === current.pathname && url.search === current.search;
|
|
if (sameRoute) return null;
|
|
|
|
return getRoutePath(url);
|
|
};
|
|
|
|
const handleDocumentClick = (event: MouseEvent) => {
|
|
if (event.defaultPrevented) return;
|
|
if (event.button !== 0) return;
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
|
|
const anchor = (event.target as Element | null)?.closest("a[href]");
|
|
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
|
|
|
|
const targetPath = getInternalRouteTarget(anchor);
|
|
if (targetPath) startTransition(targetPath);
|
|
};
|
|
|
|
const originalPushState = window.history.pushState;
|
|
const originalReplaceState = window.history.replaceState;
|
|
|
|
window.history.pushState = function patchedPushState(...args) {
|
|
const urlArg = args[2];
|
|
if (typeof urlArg === "string" || urlArg instanceof URL) {
|
|
const url = new URL(urlArg, window.location.href);
|
|
if (url.origin === window.location.origin) startTransition(getRoutePath(url));
|
|
}
|
|
return originalPushState.apply(this, args);
|
|
};
|
|
|
|
window.history.replaceState = function patchedReplaceState(...args) {
|
|
const urlArg = args[2];
|
|
if (typeof urlArg === "string" || urlArg instanceof URL) {
|
|
const url = new URL(urlArg, window.location.href);
|
|
if (url.origin === window.location.origin) startTransition(getRoutePath(url));
|
|
}
|
|
return originalReplaceState.apply(this, args);
|
|
};
|
|
|
|
const handlePopState = () => {
|
|
const targetPath = getRoutePath(new URL(window.location.href));
|
|
if (targetPath !== currentRoutePath.current) startTransition(targetPath, true);
|
|
};
|
|
|
|
document.addEventListener("click", handleDocumentClick, true);
|
|
window.addEventListener("popstate", handlePopState);
|
|
|
|
return () => {
|
|
document.removeEventListener("click", handleDocumentClick, true);
|
|
window.removeEventListener("popstate", handlePopState);
|
|
window.history.pushState = originalPushState;
|
|
window.history.replaceState = originalReplaceState;
|
|
clearTimer(hideTimer);
|
|
clearTimer(safetyTimer);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
if (phase === "hidden") return null;
|
|
|
|
return (
|
|
<div
|
|
className={`dm-loader${phase === "hiding" ? " is-hiding" : ""}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-label="Loading"
|
|
onTransitionEnd={(e) => {
|
|
if (e.propertyName === "opacity" && phase === "hiding") {
|
|
setPhase("hidden");
|
|
phaseRef.current = "hidden";
|
|
}
|
|
}}
|
|
>
|
|
<div className="dm-loader__pulse">
|
|
<Image
|
|
src="/images/preloader.png"
|
|
alt="Doormile"
|
|
width={325}
|
|
height={239}
|
|
priority
|
|
className="dm-loader__logo"
|
|
/>
|
|
</div>
|
|
|
|
<style>{`
|
|
.dm-loader {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 100000;
|
|
display: grid;
|
|
place-items: center;
|
|
background: #000;
|
|
opacity: 1;
|
|
transition: opacity 0.32s ease;
|
|
will-change: opacity;
|
|
}
|
|
.dm-loader.is-hiding { opacity: 0; pointer-events: none; }
|
|
.dm-loader__pulse { animation: dmLoaderPulse 1.5s linear infinite; display: grid; place-items: center; }
|
|
.dm-loader__logo { display: block; margin: 0 auto; width: clamp(120px, 32vw, 180px); height: auto; }
|
|
@keyframes dmLoaderPulse {
|
|
50% { transform: scale(0.85); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.dm-loader__pulse { animation: none; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|