v1.2 - added react router
This commit is contained in:
parent
c83ff25a36
commit
b0fba52c0e
28 changed files with 4318 additions and 993 deletions
64
src/components/Banner.tsx
Normal file
64
src/components/Banner.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SITE_BANNER } from "../bannerConfig.js";
|
||||
|
||||
export default function Banner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const storageKey = `ngu-banner-dismissed:${SITE_BANNER.id}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!SITE_BANNER.enabled) return;
|
||||
if (!SITE_BANNER.dismissible) return setVisible(true);
|
||||
try {
|
||||
if (!localStorage.getItem(storageKey)) setVisible(true);
|
||||
} catch {
|
||||
setVisible(true); // storage blocked — show it anyway
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
try { localStorage.setItem(storageKey, "1"); } catch {}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const linkClass =
|
||||
"underline underline-offset-4 decoration-[#aac992] hover:text-white transition-colors";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full px-12 py-2.5 text-center"
|
||||
style={{ background: "rgba(0, 47, 57, 0.95)" }}
|
||||
role="region"
|
||||
aria-label="Site announcement"
|
||||
>
|
||||
<p className="text-sm text-white/85">
|
||||
{SITE_BANNER.content.map((part, i) => {
|
||||
if (!part.href) return <span key={i}>{part.text}</span>;
|
||||
return part.external ? (
|
||||
<a key={i} href={part.href} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
{part.text}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={i} to={part.href} className={linkClass}>
|
||||
{part.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
|
||||
{SITE_BANNER.dismissible && (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss announcement"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
src/components/Footer.tsx
Normal file
176
src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
|
||||
import nguLogo from "../assets/NGU_Logo.svg";
|
||||
|
||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FEDA75" />
|
||||
<stop offset="25%" stopColor="#FA7E1E" />
|
||||
<stop offset="50%" stopColor="#D62976" />
|
||||
<stop offset="75%" stopColor="#962FBF" />
|
||||
<stop offset="100%" stopColor="#4F5BD5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DiscordIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Social_Links = [
|
||||
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
|
||||
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
|
||||
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
|
||||
];
|
||||
|
||||
// Give's destination lives in navConfig so the footer and the nav can't drift.
|
||||
// Only the label differs down here.
|
||||
const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy");
|
||||
|
||||
const Get_In_Touch = [
|
||||
{ label: "Feedback", to: "/feedback"},
|
||||
{ label: "Contact Us", to: "/leadership#contact" },
|
||||
];
|
||||
|
||||
const Footer_Links = [
|
||||
{ label: "Privacy Policy", to: "/privacy" },
|
||||
{ label: "Terms of Service", to: "/terms" },
|
||||
];
|
||||
|
||||
const footerLinkClass =
|
||||
"text-white/40 hover:text-[#aac992] text-sm transition-colors";
|
||||
|
||||
const pageLinkClass =
|
||||
"text-white/80 hover:text-[#aac992] text-sm font-semibold transition-colors";
|
||||
|
||||
const sectionLinkClass =
|
||||
"text-white/45 hover:text-[#aac992] text-sm transition-colors";
|
||||
|
||||
const headingClass = "text-white/90 text-sm font-semibold mb-4";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="py-16 px-6" style={{ background: "rgba(0, 47, 58, 0.92)" }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-8 mb-12">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-3 flex justify-center md:justify-start">
|
||||
<Link to="/">
|
||||
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Site map — mirrors both nav tiers */}
|
||||
<div className="md:col-span-6 text-center md:text-left">
|
||||
<nav aria-label="Site map">
|
||||
<h2 className={headingClass}>Site Map</h2>
|
||||
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-6">
|
||||
{PAGE_LINKS.map((page) => {
|
||||
const sections = PAGE_SECTIONS[page.path] ?? [];
|
||||
return (
|
||||
<li key={page.path}>
|
||||
<Link to={page.path} className={pageLinkClass}>
|
||||
{page.label}
|
||||
</Link>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{sections.map((section) => (
|
||||
<li key={section.hash}>
|
||||
<Link
|
||||
to={`${page.path}${section.hash}`}
|
||||
className={sectionLinkClass}
|
||||
>
|
||||
{section.label.replace(/^#/, "")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{giveAction && (
|
||||
<a
|
||||
href={giveAction.to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-8 px-6 py-2.5 rounded-full text-sm font-bold text-white whitespace-nowrap transition-all duration-200 hover:scale-105"
|
||||
style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}
|
||||
>
|
||||
Support Us
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Socials + get in touch */}
|
||||
<div className="md:col-span-3 flex flex-col items-center md:items-start">
|
||||
<div className="flex gap-4 mb-8">
|
||||
{Social_Links.map(({ label, href, Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="footer-icons w-10 h-10 rounded-full flex items-center justify-center text-white/70 transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}
|
||||
>
|
||||
{label === "Instagram" ? <InstagramIcon id="ig-footer" /> : <Icon />}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className={headingClass}>Get in Touch</h2>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{Get_In_Touch.map((link) => (
|
||||
<li key={link.label}>
|
||||
{link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={pageLinkClass}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={link.to} className={pageLinkClass}>
|
||||
{link.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
|
||||
<div className="flex gap-6">
|
||||
{Footer_Links.map((link) => (
|
||||
<Link key={link.label} to={link.to} className={footerLinkClass}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
325
src/components/Layout.tsx
Normal file
325
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js";
|
||||
import Banner from "./Banner.jsx";
|
||||
import nguLogo from "../assets/NGU_Logo.svg";
|
||||
import Footer from "./Footer.tsx";
|
||||
|
||||
/* ── Right-side nav actions ──────────────────────────────── */
|
||||
|
||||
type NavActionItem = {
|
||||
label: string;
|
||||
to: string;
|
||||
variant: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const ACTION_VARIANTS: Record<
|
||||
string,
|
||||
{ className: string; style: React.CSSProperties }
|
||||
> = {
|
||||
ghost: {
|
||||
className:
|
||||
"font-semibold border border-white/30 text-white/85 hover:border-white/60 hover:text-white hover:bg-white/10",
|
||||
style: {},
|
||||
},
|
||||
fancy: {
|
||||
className: "font-bold text-white hover:scale-105",
|
||||
style: { background: "linear-gradient(135deg, #008fa8, #88b668)" },
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_SIZES = {
|
||||
desktop: "px-5 py-2 text-sm leading-relaxed",
|
||||
mobile: "px-8 py-3 text-lg",
|
||||
};
|
||||
|
||||
function NavAction({
|
||||
item,
|
||||
size = "desktop",
|
||||
onClick,
|
||||
}: {
|
||||
item: NavActionItem;
|
||||
size?: keyof typeof ACTION_SIZES;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const v = ACTION_VARIANTS[item.variant] ?? ACTION_VARIANTS.ghost;
|
||||
const className = `inline-block whitespace-nowrap rounded-full transition-all duration-200 ${ACTION_SIZES[size]} ${v.className}`;
|
||||
|
||||
return item.external ? (
|
||||
<a
|
||||
href={item.to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
style={v.style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={item.to} className={className} style={v.style} onClick={onClick}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const navActions = NAV_ACTIONS as NavActionItem[];
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────── */
|
||||
|
||||
export default function Layout() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(72);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
const sections = PAGE_SECTIONS[location.pathname] ?? [];
|
||||
const activePage = PAGE_LINKS.find((p) => p.path === location.pathname);
|
||||
|
||||
// Close the mobile menu whenever the route changes
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
setActiveSection(null);
|
||||
}, [location.pathname]);
|
||||
|
||||
// The header grows and shrinks: the banner can be dismissed and the
|
||||
// subnav only exists on some pages. Measure it instead of guessing,
|
||||
// so the spacer below always matches.
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(([entry]) =>
|
||||
setHeaderHeight(entry.contentRect.height)
|
||||
);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Router does not scroll on navigation. Go to top on a new page,
|
||||
// or to the anchor if the URL carries one (handles deep links too).
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
const el = document.querySelector(location.hash);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.scrollTo({ top: 0 });
|
||||
}, [location.pathname, location.hash, location.key]);
|
||||
|
||||
// Highlight whichever section is currently in view
|
||||
useEffect(() => {
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const targets = sections
|
||||
.map((s) => document.querySelector(s.hash))
|
||||
.filter(Boolean);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top
|
||||
)[0];
|
||||
if (visible) setActiveSection(`#${visible.target.id}`);
|
||||
},
|
||||
// Top margin tracks the live header height so the highlight stays
|
||||
// accurate whether or not the banner is showing.
|
||||
{ rootMargin: `-${headerHeight + 30}px 0px -60% 0px`, threshold: 0 }
|
||||
);
|
||||
|
||||
targets.forEach((t) => observer.observe(t));
|
||||
return () => observer.disconnect();
|
||||
}, [location.pathname, headerHeight]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-x-hidden">
|
||||
|
||||
{/* ── NAV ─────────────────────────────────────────────── */}
|
||||
<header
|
||||
ref={headerRef}
|
||||
className="fixed top-0 left-0 right-0 z-50 backdrop-blur-md"
|
||||
>
|
||||
|
||||
{/* Tier 0 — site-wide announcement */}
|
||||
<Banner />
|
||||
|
||||
{/* Tier 1 — site navigation */}
|
||||
<nav
|
||||
className="flex items-center justify-between px-6 py-4"
|
||||
aria-label="Site"
|
||||
style={{ background: "rgba(0, 69, 82,0.92)" }}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" aria-label="Next Generation of Unity home" className="inline-block">
|
||||
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{PAGE_LINKS.map((page) => (
|
||||
<NavLink
|
||||
key={page.path}
|
||||
to={page.path}
|
||||
end={page.path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`text-base font-bold pb-1 border-b-2 transition-colors duration-200 ${
|
||||
isActive
|
||||
? "text-white border-[#aac992]"
|
||||
: "text-white/75 hover:text-white border-transparent"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{page.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{navActions.map((item) => (
|
||||
<NavAction key={item.label} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="md:hidden text-white p-2"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all" />
|
||||
<div className="w-6 h-0.5 bg-white mb-1.5" />
|
||||
<div className="w-6 h-0.5 bg-white" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Tier 2 — sections of the current page */}
|
||||
{sections.length > 0 && (
|
||||
<nav
|
||||
className="hidden md:flex items-center px-6 py-2"
|
||||
aria-label={`On this page: ${activePage?.label}`}
|
||||
style={{
|
||||
background: "rgba(0, 47, 57, 0.92)",
|
||||
borderBottom: "1px solid rgba(45,200,224,0.15)",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-white/45">{activePage?.label}</span>
|
||||
|
||||
{/* Separates the page label from its sections */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="mx-5 h-4 w-px"
|
||||
style={{ background: "rgba(45,200,224,0.3)" }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{sections.map((section) => (
|
||||
<a
|
||||
key={section.hash}
|
||||
href={section.hash}
|
||||
aria-current={activeSection === section.hash ? "location" : undefined}
|
||||
className={`text-sm transition-colors duration-200 ${
|
||||
activeSection === section.hash
|
||||
? "text-[#aac992]"
|
||||
: "text-white/75 hover:text-[#aac992]"
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Keeps page content clear of the fixed header */}
|
||||
<div aria-hidden style={{ height: headerHeight }} />
|
||||
|
||||
{/* Page content */}
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/*Site Footer applied to every page*/}
|
||||
<Footer />
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center overflow-y-auto py-16"
|
||||
style={{ background: "rgba(7,61,74,0.97)" }}
|
||||
>
|
||||
<button
|
||||
className="absolute top-5 right-6 text-white text-3xl"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="w-64">
|
||||
{PAGE_LINKS.map((page) => {
|
||||
const pageSections = PAGE_SECTIONS[page.path] ?? [];
|
||||
const isActive = location.pathname === page.path;
|
||||
|
||||
return (
|
||||
<div key={page.path}>
|
||||
<NavLink
|
||||
to={page.path}
|
||||
end={page.path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`block text-2xl font-bold py-3 transition-colors ${
|
||||
isActive ? "text-[#aac992]" : "text-white hover:text-[#10d48a]"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{page.label}
|
||||
</NavLink>
|
||||
|
||||
{/* Sections nest under the page they belong to */}
|
||||
{isActive && pageSections.length > 0 && (
|
||||
<div
|
||||
className="ml-3 pl-4 mb-2 flex flex-col"
|
||||
style={{ borderLeft: "1px solid rgba(45,200,224,0.35)" }}
|
||||
>
|
||||
{pageSections.map((section) => (
|
||||
<a
|
||||
key={section.hash}
|
||||
href={section.hash}
|
||||
className="text-white/80 text-lg py-2 hover:text-[#10d48a] transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-3">
|
||||
{navActions.map((item) => (
|
||||
<NavAction
|
||||
key={item.label}
|
||||
item={item}
|
||||
size="mobile"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/PageShell.tsx
Normal file
99
src/components/PageShell.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAGE SHELL
|
||||
Just the chrome that makes pages look alike: a page header, then
|
||||
sections with a thin accent banner between them, alternating
|
||||
backgrounds, and a left-aligned heading with a blurb and rule.
|
||||
|
||||
Whatever goes inside a section is passed as `content` — the shell
|
||||
doesn't care what it is.
|
||||
|
||||
No <main> and no footer here — Layout provides both, so a page
|
||||
using this shell drops straight into the router's Outlet.
|
||||
|
||||
Usage:
|
||||
<PageShell
|
||||
title="Community"
|
||||
intro="One line under the page title."
|
||||
sections={[
|
||||
{
|
||||
id: "local", // matches the nav hash, #local
|
||||
|
||||
title: "Local Chapters",
|
||||
blurb: "Optional line under the section heading.",
|
||||
accent: "#138ba0", // heading, banner, rule
|
||||
background: "#eef9fb",
|
||||
actions: <SomeButton />, // optional, right of the heading
|
||||
content: <YourStuff />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const TEAL = "#138ba0";
|
||||
|
||||
export function Section({ section }) {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
blurb,
|
||||
accent,
|
||||
background,
|
||||
// Optional action bar for this section's heading row — a view
|
||||
// toggle, a filter, a link. Whatever the section needs; the
|
||||
// shell just gives it a place to sit.
|
||||
actions,
|
||||
content,
|
||||
} = section;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="py-20"
|
||||
style={{ background, scrollMarginTop: "5rem" }}
|
||||
>
|
||||
{/* Heading — left aligned, optional actions on the right */}
|
||||
<div className="max-w-6xl mx-auto px-6 mb-10">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-4xl md:text-5xl font-800" style={{ color: accent }}>
|
||||
{title}
|
||||
</h2>
|
||||
{blurb && <p className="mt-2 text-[#4a6b72] max-w-xl">{blurb}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="mt-6 h-px w-full" style={{ background: accent, opacity: 0.35 }} />
|
||||
</div>
|
||||
|
||||
{content}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageShell({ title, intro, sections }) {
|
||||
return (
|
||||
<>
|
||||
{/* Page header */}
|
||||
<div className="pt-24 pb-12 px-6" style={{ background: "#eef9fb" }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-5xl md:text-6xl font-800" style={{ color: TEAL }}>
|
||||
{title}
|
||||
</h1>
|
||||
{intro && <p className="mt-3 text-lg text-[#4a6b72] max-w-2xl">{intro}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.map((section, i) => (
|
||||
<div key={section.id}>
|
||||
{/* Thin solid banner between sections */}
|
||||
<div className="h-2" style={{ background: section.accent }} />
|
||||
<Section section={section} />
|
||||
{i === sections.length - 1 && (
|
||||
<div className="h-2" style={{ background: section.accent }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue