From 874941575c7a6475c82cfbf3ba4b640e8e42ac49 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 8 Jun 2026 06:49:21 -0500 Subject: [PATCH] 's' --- app/components/NavigationLoader.tsx | 28 +++++ app/components/NavigationLoadingProvider.tsx | 95 ++++++++++++++++ app/context/LocaleContext.tsx | 10 +- app/digital/context/LocaleContext.tsx | 10 +- app/digital/lib/locale-link.tsx | 20 +++- app/globals.css | 113 +++++++++++++++++++ app/layout.tsx | 8 +- app/lib/locale-link.tsx | 20 +++- app/lib/navigation-loading.ts | 69 +++++++++++ 9 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 app/components/NavigationLoader.tsx create mode 100644 app/components/NavigationLoadingProvider.tsx create mode 100644 app/lib/navigation-loading.ts 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} + + ); diff --git a/app/lib/locale-link.tsx b/app/lib/locale-link.tsx index 065e279..ba74ea0 100644 --- a/app/lib/locale-link.tsx +++ b/app/lib/locale-link.tsx @@ -3,16 +3,32 @@ 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 = hrefStr.startsWith("/") ? `/${locale}${hrefStr === "/" ? "" : hrefStr}` : hrefStr; - return ; + + return ( + { + if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) { + startNavigation(); + } + onClick?.(event); + }} + {...props} + /> + ); } export function usePathname() { diff --git a/app/lib/navigation-loading.ts b/app/lib/navigation-loading.ts new file mode 100644 index 0000000..511ec30 --- /dev/null +++ b/app/lib/navigation-loading.ts @@ -0,0 +1,69 @@ +type NavigationListener = (loading: boolean) => void; + +let listeners = new Set(); +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; + } +}