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

View File

@@ -53,7 +53,10 @@ export function LocaleProvider({
const setLocale = useCallback((newLocale: Locale) => { const setLocale = useCallback((newLocale: Locale) => {
const path = window.location.pathname; const path = window.location.pathname;
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`); const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
startNavigation();
window.location.href = newPath || `/${newLocale}`; window.location.href = newPath || `/${newLocale}`;
});
}, []); }, []);
const value: LocaleContextValue = { const value: LocaleContextValue = {
@@ -89,7 +92,10 @@ function navigate(pathname: string, locale: Locale, opts?: { locale?: Locale })
const newLocale = opts?.locale ?? locale; const newLocale = opts?.locale ?? locale;
const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/"; const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/";
const newPath = `/${newLocale}${cleanPath === "/" ? "" : cleanPath}`; const newPath = `/${newLocale}${cleanPath === "/" ? "" : cleanPath}`;
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
startNavigation();
window.location.href = newPath; window.location.href = newPath;
});
} }
export function useLocaleRouter() { export function useLocaleRouter() {

View File

@@ -53,7 +53,10 @@ export function LocaleProvider({
const setLocale = useCallback((newLocale: Locale) => { const setLocale = useCallback((newLocale: Locale) => {
const path = window.location.pathname; const path = window.location.pathname;
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`); const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
startNavigation();
window.location.href = newPath || `/${newLocale}/digital`; window.location.href = newPath || `/${newLocale}/digital`;
});
}, []); }, []);
const value: LocaleContextValue = { const value: LocaleContextValue = {
@@ -95,7 +98,10 @@ export function useLocaleRouter() {
? cleanPath ? cleanPath
: `/digital${cleanPath === "/" ? "" : cleanPath}`; : `/digital${cleanPath === "/" ? "" : cleanPath}`;
const newPath = `/${newLocale}${digitalPath}`; const newPath = `/${newLocale}${digitalPath}`;
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
startNavigation();
window.location.href = newPath; window.location.href = newPath;
});
}, },
}; };
} }

View File

@@ -3,10 +3,14 @@
import NextLink from "next/link"; import NextLink from "next/link";
import { usePathname as useNextPathname } from "next/navigation"; import { usePathname as useNextPathname } from "next/navigation";
import { useLocale } from "../context/LocaleContext"; import { useLocale } from "../context/LocaleContext";
import {
isInternalNavigationClick,
startNavigation,
} from "@/app/lib/navigation-loading";
type LocaleLinkProps = React.ComponentProps<typeof NextLink>; type LocaleLinkProps = React.ComponentProps<typeof NextLink>;
export function LocaleLink({ href, ...props }: LocaleLinkProps) { export function LocaleLink({ href, onClick, ...props }: LocaleLinkProps) {
const locale = useLocale(); const locale = useLocale();
const hrefStr = typeof href === "string" ? href : href.pathname ?? "/"; const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
const localeHref = const localeHref =
@@ -15,7 +19,19 @@ export function LocaleLink({ href, ...props }: LocaleLinkProps) {
: hrefStr.startsWith("/") : hrefStr.startsWith("/")
? `/${locale}/digital${hrefStr === "/" ? "" : hrefStr}` ? `/${locale}/digital${hrefStr === "/" ? "" : hrefStr}`
: hrefStr; : hrefStr;
return <NextLink href={localeHref} {...props} />;
return (
<NextLink
href={localeHref}
onClick={(event) => {
if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) {
startNavigation();
}
onClick?.(event);
}}
{...props}
/>
);
} }
export function usePathname() { export function usePathname() {

View File

@@ -1382,3 +1382,116 @@ html {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* ==================== Page navigation loader ==================== */
@keyframes nav-loader-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes nav-loader-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes nav-loader-orbit-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes nav-loader-orbit-spin-reverse {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
@keyframes nav-loader-core-pulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 40px rgba(20, 184, 166, 0.45);
}
50% {
transform: scale(1.06);
box-shadow: 0 0 56px rgba(20, 184, 166, 0.65);
}
}
.nav-loader-overlay {
animation: nav-loader-fade-in 0.2s ease-out;
}
.nav-loader-progress-bar {
background: linear-gradient(
90deg,
transparent,
#34d399,
#2dd4bf,
#22d3ee,
#ff7a45,
transparent
);
animation: nav-loader-progress 1.1s ease-in-out infinite;
}
.nav-loader-ring-1 {
border-top-color: rgba(52, 211, 153, 0.85);
border-right-color: rgba(52, 211, 153, 0.15);
animation: nav-loader-orbit-spin 1.8s linear infinite;
}
.nav-loader-ring-2 {
border-bottom-color: rgba(34, 211, 238, 0.75);
border-left-color: rgba(34, 211, 238, 0.12);
animation: nav-loader-orbit-spin-reverse 2.4s linear infinite;
}
.nav-loader-ring-3 {
border-top-color: rgba(255, 122, 69, 0.65);
border-right-color: rgba(255, 122, 69, 0.1);
animation: nav-loader-orbit-spin 3s linear infinite;
}
.nav-loader-core {
animation: nav-loader-core-pulse 1.6s ease-in-out infinite;
}
.nav-loader-dot-1 {
animation: nav-loader-orbit-spin 1.8s linear infinite;
transform-origin: 50% 3.5rem;
}
.nav-loader-dot-2 {
animation: nav-loader-orbit-spin-reverse 2.4s linear infinite;
transform-origin: 50% 3.5rem;
}
.nav-loader-dot-3 {
animation: nav-loader-orbit-spin 3s linear infinite;
transform-origin: 50% 3.5rem;
}
@media (prefers-reduced-motion: reduce) {
.nav-loader-overlay,
.nav-loader-progress-bar,
.nav-loader-ring,
.nav-loader-core,
.nav-loader-dot {
animation: none !important;
}
}

View File

@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import NavigationLoadingProvider from "./components/NavigationLoadingProvider";
import { getSiteConfig } from "@/config"; import { getSiteConfig } from "@/config";
export const viewport: Viewport = { export const viewport: Viewport = {
@@ -29,7 +31,11 @@ export default function RootLayout({
/> />
</head> </head>
<body className="antialiased"> <body className="antialiased">
<ThemeProvider>{children}</ThemeProvider> <ThemeProvider>
<Suspense fallback={null}>
<NavigationLoadingProvider>{children}</NavigationLoadingProvider>
</Suspense>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -3,16 +3,32 @@
import NextLink from "next/link"; import NextLink from "next/link";
import { usePathname as useNextPathname } from "next/navigation"; import { usePathname as useNextPathname } from "next/navigation";
import { useLocale } from "../context/LocaleContext"; import { useLocale } from "../context/LocaleContext";
import {
isInternalNavigationClick,
startNavigation,
} from "@/app/lib/navigation-loading";
type LocaleLinkProps = React.ComponentProps<typeof NextLink>; type LocaleLinkProps = React.ComponentProps<typeof NextLink>;
export function LocaleLink({ href, ...props }: LocaleLinkProps) { export function LocaleLink({ href, onClick, ...props }: LocaleLinkProps) {
const locale = useLocale(); const locale = useLocale();
const hrefStr = typeof href === "string" ? href : href.pathname ?? "/"; const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
const localeHref = hrefStr.startsWith("/") const localeHref = hrefStr.startsWith("/")
? `/${locale}${hrefStr === "/" ? "" : hrefStr}` ? `/${locale}${hrefStr === "/" ? "" : hrefStr}`
: hrefStr; : hrefStr;
return <NextLink href={localeHref} {...props} />;
return (
<NextLink
href={localeHref}
onClick={(event) => {
if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) {
startNavigation();
}
onClick?.(event);
}}
{...props}
/>
);
} }
export function usePathname() { export function usePathname() {

View File

@@ -0,0 +1,69 @@
type NavigationListener = (loading: boolean) => void;
let listeners = new Set<NavigationListener>();
let loading = false;
function notify() {
listeners.forEach((listener) => listener(loading));
}
export function subscribeNavigationLoading(listener: NavigationListener) {
listeners.add(listener);
listener(loading);
return () => {
listeners.delete(listener);
};
}
export function startNavigation() {
if (loading) return;
loading = true;
notify();
}
export function stopNavigation() {
if (!loading) return;
loading = false;
notify();
}
export function isNavigationLoading() {
return loading;
}
export function isInternalNavigationClick(
event: MouseEvent,
anchor: HTMLAnchorElement
): boolean {
if (event.defaultPrevented) return false;
if (event.button !== 0) return false;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
if (anchor.target === "_blank") return false;
if (anchor.hasAttribute("download")) return false;
const href = anchor.getAttribute("href");
if (
!href ||
href.startsWith("#") ||
href.startsWith("mailto:") ||
href.startsWith("tel:") ||
href.startsWith("javascript:")
) {
return false;
}
try {
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return false;
if (
url.pathname === window.location.pathname &&
url.search === window.location.search &&
!url.hash
) {
return false;
}
return true;
} catch {
return false;
}
}