mirror of
https://github.com/lingke-net/koring-space.git
synced 2026-09-12 05:45:17 +08:00
Add CardNav, theme providers, and UI updates
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.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
.card-nav-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 99;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-nav {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: 60px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.card-nav-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.45rem 0.55rem 1.1rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.hamburger-menu {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hamburger-menu:hover .hamburger-line {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
background-color: currentColor;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.2s ease,
|
||||
margin 0.3s ease;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.hamburger-menu.open .hamburger-line:first-child {
|
||||
transform: translateY(4px) rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger-menu.open .hamburger-line:last-child {
|
||||
transform: translateY(-4px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.card-nav-cta-button {
|
||||
background-color: #111;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: calc(0.75rem - 0.35rem);
|
||||
padding: 0 1rem;
|
||||
height: 100%;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-nav-cta-button:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.card-nav-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 60px;
|
||||
bottom: 0;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-nav.open .card-nav-content {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
height: 100%;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: calc(0.75rem - 0.2rem);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-card-label {
|
||||
font-weight: 400;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.nav-card-links {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-card-link {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-card-link:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.dark .card-nav {
|
||||
background: rgba(26, 26, 26, 0.7);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dark .logo-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark .hamburger-menu {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.dark .theme-toggle {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark .theme-toggle:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-nav-container {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.card-nav-top {
|
||||
padding: 0.5rem 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hamburger-menu {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
position: static;
|
||||
transform: none;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.card-nav-cta-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-nav-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 0.5rem;
|
||||
bottom: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
height: auto;
|
||||
min-height: 60px;
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.nav-card-label {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-card-link {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user