's'
This commit is contained in:
28
app/components/NavigationLoader.tsx
Normal file
28
app/components/NavigationLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
app/components/NavigationLoadingProvider.tsx
Normal file
95
app/components/NavigationLoadingProvider.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,10 @@ export function LocaleProvider({
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
const path = window.location.pathname;
|
||||
const newPath = path.replace(/^\/(zh|en)/, `/${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}`;
|
||||
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
|
||||
startNavigation();
|
||||
window.location.href = newPath;
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocaleRouter() {
|
||||
|
||||
@@ -53,7 +53,10 @@ export function LocaleProvider({
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
const path = window.location.pathname;
|
||||
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
|
||||
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}`;
|
||||
void import("@/app/lib/navigation-loading").then(({ startNavigation }) => {
|
||||
startNavigation();
|
||||
window.location.href = newPath;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof NextLink>;
|
||||
|
||||
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 <NextLink href={localeHref} {...props} />;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={localeHref}
|
||||
onClick={(event) => {
|
||||
if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) {
|
||||
startNavigation();
|
||||
}
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePathname() {
|
||||
|
||||
113
app/globals.css
113
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<ThemeProvider>
|
||||
<Suspense fallback={null}>
|
||||
<NavigationLoadingProvider>{children}</NavigationLoadingProvider>
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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<typeof NextLink>;
|
||||
|
||||
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 <NextLink href={localeHref} {...props} />;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={localeHref}
|
||||
onClick={(event) => {
|
||||
if (isInternalNavigationClick(event.nativeEvent, event.currentTarget)) {
|
||||
startNavigation();
|
||||
}
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePathname() {
|
||||
|
||||
69
app/lib/navigation-loading.ts
Normal file
69
app/lib/navigation-loading.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user