v1.2 - added react router

This commit is contained in:
Zaldimmar 2026-09-25 02:34:15 -05:00
parent c83ff25a36
commit b0fba52c0e
28 changed files with 4318 additions and 993 deletions

64
src/components/Banner.tsx Normal file
View 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
View 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
View 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>
);
}

View 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>
))}
</>
);
}