diff --git a/app/components/NavigationLoader.tsx b/app/components/NavigationLoader.tsx
new file mode 100644
index 0000000..8f67cc1
--- /dev/null
+++ b/app/components/NavigationLoader.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+export default function NavigationLoader() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/NavigationLoadingProvider.tsx b/app/components/NavigationLoadingProvider.tsx
new file mode 100644
index 0000000..2fa77ba
--- /dev/null
+++ b/app/components/NavigationLoadingProvider.tsx
@@ -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 ? : null}
+ >
+ );
+}
diff --git a/app/context/LocaleContext.tsx b/app/context/LocaleContext.tsx
index 8862962..c502417 100644
--- a/app/context/LocaleContext.tsx
+++ b/app/context/LocaleContext.tsx
@@ -53,7 +53,10 @@ export function LocaleProvider({
const setLocale = useCallback((newLocale: Locale) => {
const path = window.location.pathname;
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
- window.location.href = newPath || `/${newLocale}`;
+ void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
+ startNavigation();
+ window.location.href = newPath || `/${newLocale}`;
+ });
}, []);
const value: LocaleContextValue = {
@@ -89,7 +92,10 @@ function navigate(pathname: string, locale: Locale, opts?: { locale?: Locale })
const newLocale = opts?.locale ?? locale;
const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/";
const newPath = `/${newLocale}${cleanPath === "/" ? "" : cleanPath}`;
- window.location.href = newPath;
+ void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
+ startNavigation();
+ window.location.href = newPath;
+ });
}
export function useLocaleRouter() {
diff --git a/app/digital/context/LocaleContext.tsx b/app/digital/context/LocaleContext.tsx
index a678685..90b228e 100644
--- a/app/digital/context/LocaleContext.tsx
+++ b/app/digital/context/LocaleContext.tsx
@@ -53,7 +53,10 @@ export function LocaleProvider({
const setLocale = useCallback((newLocale: Locale) => {
const path = window.location.pathname;
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
- window.location.href = newPath || `/${newLocale}/digital`;
+ void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
+ startNavigation();
+ window.location.href = newPath || `/${newLocale}/digital`;
+ });
}, []);
const value: LocaleContextValue = {
@@ -95,7 +98,10 @@ export function useLocaleRouter() {
? cleanPath
: `/digital${cleanPath === "/" ? "" : cleanPath}`;
const newPath = `/${newLocale}${digitalPath}`;
- window.location.href = newPath;
+ void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
+ startNavigation();
+ window.location.href = newPath;
+ });
},
};
}
diff --git a/app/digital/lib/locale-link.tsx b/app/digital/lib/locale-link.tsx
index a20bc6f..7288356 100644
--- a/app/digital/lib/locale-link.tsx
+++ b/app/digital/lib/locale-link.tsx
@@ -3,10 +3,14 @@
import NextLink from "next/link";
import { usePathname as useNextPathname } from "next/navigation";
import { useLocale } from "../context/LocaleContext";
+import {
+ isInternalNavigationClick,
+ startNavigation,
+} from "@/app/lib/navigation-loading";
type LocaleLinkProps = React.ComponentProps;
-export function LocaleLink({ href, ...props }: LocaleLinkProps) {
+export function LocaleLink({ href, onClick, ...props }: LocaleLinkProps) {
const locale = useLocale();
const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
const localeHref =
@@ -15,7 +19,19 @@ export function LocaleLink({ href, ...props }: LocaleLinkProps) {
: hrefStr.startsWith("/")
? `/${locale}/digital${hrefStr === "/" ? "" : hrefStr}`
: hrefStr;
- return ;
+
+ return (
+ {
+ if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) {
+ startNavigation();
+ }
+ onClick?.(event);
+ }}
+ {...props}
+ />
+ );
}
export function usePathname() {
diff --git a/app/globals.css b/app/globals.css
index e704556..4e3b7b3 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1382,3 +1382,116 @@ html {
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;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 24921fd..344a6f0 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next";
+import { Suspense } from "react";
import "./globals.css";
import { ThemeProvider } from "./context/ThemeContext";
+import NavigationLoadingProvider from "./components/NavigationLoadingProvider";
import { getSiteConfig } from "@/config";
export const viewport: Viewport = {
@@ -29,7 +31,11 @@ export default function RootLayout({
/>
- {children}
+
+
+ {children}
+
+