mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
Introduce an interactive top navigation and theming + site UI/branding changes. - Add CardNav component (TSX + CSS) with GSAP animations and responsive behavior. - Dynamic Header now loads CardNav (client-side). - Add Providers (next-themes) and wrap RootLayout; enable theme toggle and update site title to "Koring Team". - Add Koring logo component and public SVG asset. - Add custom 404 (app/not-found.tsx). - Update home page spacing and card paddings for responsiveness. - Refactor Footer content and layout for legal/branding text. - Add dependencies: gsap, react-icons and update pnpm lock.
268 lines
7.7 KiB
TypeScript
268 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useLayoutEffect, useRef, useState } from "react";
|
|
import { gsap } from "gsap";
|
|
import { GoArrowUpRight } from "react-icons/go";
|
|
import { useTheme } from "next-themes";
|
|
import "./CardNav.css";
|
|
|
|
interface CardNavLink {
|
|
label: string;
|
|
href?: string;
|
|
ariaLabel: string;
|
|
}
|
|
|
|
interface CardNavItem {
|
|
label: string;
|
|
bgColor: string;
|
|
textColor: string;
|
|
links: CardNavLink[];
|
|
}
|
|
|
|
interface CardNavProps {
|
|
logo?: string;
|
|
logoAlt?: string;
|
|
items: CardNavItem[];
|
|
className?: string;
|
|
ease?: string;
|
|
baseColor?: string;
|
|
menuColor?: string;
|
|
buttonBgColor?: string;
|
|
buttonTextColor?: string;
|
|
}
|
|
|
|
const CardNav: React.FC<CardNavProps> = ({
|
|
logo,
|
|
logoAlt = "Logo",
|
|
items,
|
|
className = "",
|
|
ease = "power3.out",
|
|
baseColor = "#fff",
|
|
menuColor,
|
|
buttonBgColor,
|
|
buttonTextColor,
|
|
}) => {
|
|
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const navRef = useRef<HTMLElement>(null);
|
|
const cardsRef = useRef<HTMLDivElement[]>([]);
|
|
const tlRef = useRef<gsap.core.Timeline | null>(null);
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
const calculateHeight = () => {
|
|
const navEl = navRef.current;
|
|
if (!navEl) return 260;
|
|
|
|
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
if (isMobile) {
|
|
const contentEl = navEl.querySelector<HTMLElement>(".card-nav-content");
|
|
if (contentEl) {
|
|
const wasVisible = contentEl.style.visibility;
|
|
const wasPointerEvents = contentEl.style.pointerEvents;
|
|
const wasPosition = contentEl.style.position;
|
|
const wasHeight = contentEl.style.height;
|
|
|
|
contentEl.style.visibility = "visible";
|
|
contentEl.style.pointerEvents = "auto";
|
|
contentEl.style.position = "static";
|
|
contentEl.style.height = "auto";
|
|
|
|
contentEl.offsetHeight;
|
|
|
|
const topBar = 60;
|
|
const padding = 16;
|
|
const contentHeight = contentEl.scrollHeight;
|
|
|
|
contentEl.style.visibility = wasVisible;
|
|
contentEl.style.pointerEvents = wasPointerEvents;
|
|
contentEl.style.position = wasPosition;
|
|
contentEl.style.height = wasHeight;
|
|
|
|
return topBar + contentHeight + padding;
|
|
}
|
|
}
|
|
return 260;
|
|
};
|
|
|
|
const createTimeline = () => {
|
|
const navEl = navRef.current;
|
|
if (!navEl) return null;
|
|
|
|
gsap.set(navEl, { height: 60 });
|
|
gsap.set(cardsRef.current, { y: 50, opacity: 0 });
|
|
|
|
const tl = gsap.timeline({ paused: true });
|
|
|
|
tl.to(navEl, {
|
|
height: calculateHeight,
|
|
duration: 0.4,
|
|
ease,
|
|
});
|
|
|
|
tl.to(
|
|
cardsRef.current,
|
|
{ y: 0, opacity: 1, duration: 0.4, ease, stagger: 0.08 },
|
|
"-=0.1"
|
|
);
|
|
|
|
return tl;
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const tl = createTimeline();
|
|
tlRef.current = tl;
|
|
|
|
return () => {
|
|
tl?.kill();
|
|
tlRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ease, items]);
|
|
|
|
useLayoutEffect(() => {
|
|
const handleResize = () => {
|
|
if (!tlRef.current) return;
|
|
|
|
if (isExpanded) {
|
|
const newHeight = calculateHeight();
|
|
gsap.set(navRef.current, { height: newHeight });
|
|
|
|
tlRef.current.kill();
|
|
const newTl = createTimeline();
|
|
if (newTl) {
|
|
newTl.progress(1);
|
|
tlRef.current = newTl;
|
|
}
|
|
} else {
|
|
tlRef.current.kill();
|
|
const newTl = createTimeline();
|
|
if (newTl) {
|
|
tlRef.current = newTl;
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isExpanded]);
|
|
|
|
const toggleMenu = () => {
|
|
const tl = tlRef.current;
|
|
if (!tl) return;
|
|
if (!isExpanded) {
|
|
setIsHamburgerOpen(true);
|
|
setIsExpanded(true);
|
|
tl.play(0);
|
|
} else {
|
|
setIsHamburgerOpen(false);
|
|
tl.eventCallback("onReverseComplete", () => setIsExpanded(false));
|
|
tl.reverse();
|
|
}
|
|
};
|
|
|
|
const setCardRef =
|
|
(i: number) =>
|
|
(el: HTMLDivElement | null) => {
|
|
if (el) cardsRef.current[i] = el;
|
|
};
|
|
|
|
return (
|
|
<div className={`card-nav-container ${className}`}>
|
|
<nav
|
|
ref={navRef}
|
|
className={`card-nav ${isExpanded ? "open" : ""}`}
|
|
style={{ backdropFilter: "blur(20px) saturate(1.4)", WebkitBackdropFilter: "blur(20px) saturate(1.4)" }}
|
|
>
|
|
<div className="card-nav-top">
|
|
<div className="card-nav-left">
|
|
<div
|
|
className={`hamburger-menu ${isHamburgerOpen ? "open" : ""}`}
|
|
onClick={toggleMenu}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
toggleMenu();
|
|
}
|
|
}}
|
|
role="button"
|
|
aria-label={isExpanded ? "Close menu" : "Open menu"}
|
|
aria-expanded={isExpanded}
|
|
tabIndex={0}
|
|
style={{ color: menuColor || "#000" }}
|
|
>
|
|
<div className="hamburger-line" />
|
|
<div className="hamburger-line" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="logo-container">
|
|
<a href="/" className="no-underline">
|
|
{logo ? (
|
|
<img src={logo} alt={logoAlt} className="logo" />
|
|
) : (
|
|
<span className="logo-text" style={{ cursor: "pointer" }}>{logoAlt}</span>
|
|
)}
|
|
</a>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="theme-toggle"
|
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
aria-label="Toggle dark mode"
|
|
>
|
|
{theme === "dark" ? (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2" />
|
|
<path d="M12 20v2" />
|
|
<path d="m4.93 4.93 1.41 1.41" />
|
|
<path d="m17.66 17.66 1.41 1.41" />
|
|
<path d="M2 12h2" />
|
|
<path d="M20 12h2" />
|
|
<path d="m6.34 17.66-1.41 1.41" />
|
|
<path d="m19.07 4.93-1.41 1.41" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-nav-content" aria-hidden={!isExpanded}>
|
|
{(items || []).slice(0, 3).map((item, idx) => (
|
|
<div
|
|
key={`${item.label}-${idx}`}
|
|
className="nav-card"
|
|
ref={setCardRef(idx)}
|
|
style={{ backgroundColor: item.bgColor, color: item.textColor }}
|
|
>
|
|
<div className="nav-card-label">{item.label}</div>
|
|
<div className="nav-card-links">
|
|
{item.links?.map((lnk, i) => (
|
|
<a
|
|
key={`${lnk.label}-${i}`}
|
|
className="nav-card-link"
|
|
href={lnk.href}
|
|
aria-label={lnk.ariaLabel}
|
|
>
|
|
<GoArrowUpRight className="nav-card-link-icon" aria-hidden="true" />
|
|
{lnk.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CardNav;
|