This commit is contained in:
eric
2026-06-08 06:49:21 -05:00
parent 06ab949d81
commit 874941575c
9 changed files with 364 additions and 9 deletions

View File

@@ -0,0 +1,28 @@
"use client";
export default function NavigationLoader() {
return (
<div
className="nav-loader-overlay fixed inset-0 z-[9999] flex items-center justify-center"
role="status"
aria-busy="true"
aria-label="Loading"
>
<div className="nav-loader-backdrop absolute inset-0 bg-white/55 dark:bg-gray-950/65 backdrop-blur-md" />
<div className="nav-loader-progress absolute inset-x-0 top-0 h-1 overflow-hidden">
<div className="nav-loader-progress-bar h-full w-full" />
</div>
<div className="nav-loader-orbit relative flex h-28 w-28 items-center justify-center">
<span className="nav-loader-ring nav-loader-ring-1 absolute inset-0 rounded-full border-2 border-transparent" />
<span className="nav-loader-ring nav-loader-ring-2 absolute inset-2 rounded-full border-2 border-transparent" />
<span className="nav-loader-ring nav-loader-ring-3 absolute inset-4 rounded-full border-2 border-transparent" />
<span className="nav-loader-dot nav-loader-dot-1 absolute left-1/2 top-0 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.9)]" />
<span className="nav-loader-dot nav-loader-dot-2 absolute bottom-1 right-2 h-2 w-2 rounded-full bg-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.85)]" />
<span className="nav-loader-dot nav-loader-dot-3 absolute bottom-3 left-2 h-1.5 w-1.5 rounded-full bg-[#ff7a45] shadow-[0_0_8px_rgba(255,122,69,0.85)]" />
<span className="nav-loader-core absolute h-10 w-10 rounded-full bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-500 shadow-[0_0_40px_rgba(20,184,166,0.45)]" />
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import NavigationLoader from "./NavigationLoader";
import {
isInternalNavigationClick,
startNavigation,
stopNavigation,
subscribeNavigationLoading,
} from "@/app/lib/navigation-loading";
const LOADING_TIMEOUT_MS = 12000;
function shouldStartForHistoryUrl(nextUrl: string | URL | null | undefined) {
if (!nextUrl) return false;
try {
const url = new URL(String(nextUrl), window.location.href);
return (
url.pathname !== window.location.pathname ||
url.search !== window.location.search
);
} catch {
return true;
}
}
export default function NavigationLoadingProvider({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
useEffect(() => subscribeNavigationLoading(setVisible), []);
useEffect(() => {
stopNavigation();
}, [pathname, searchParams]);
useEffect(() => {
if (!visible) return;
const timeout = window.setTimeout(() => stopNavigation(), LOADING_TIMEOUT_MS);
return () => window.clearTimeout(timeout);
}, [visible]);
useEffect(() => {
const onDocumentClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest("a");
if (!(anchor instanceof HTMLAnchorElement)) return;
if (isInternalNavigationClick(event, anchor)) {
startNavigation();
}
};
const onPopState = () => startNavigation();
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
history.pushState = (...args) => {
if (shouldStartForHistoryUrl(args[2])) {
startNavigation();
}
return originalPushState(...args);
};
history.replaceState = (...args) => {
if (shouldStartForHistoryUrl(args[2])) {
startNavigation();
}
return originalReplaceState(...args);
};
document.addEventListener("click", onDocumentClick, true);
window.addEventListener("popstate", onPopState);
return () => {
document.removeEventListener("click", onDocumentClick, true);
window.removeEventListener("popstate", onPopState);
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
};
}, []);
return (
<>
{children}
{visible ? <NavigationLoader /> : null}
</>
);
}