Files
doormile_react/src/components/layout/LoadingScreen.tsx

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