www-new/components/Navbar.tsx

412 lines
9.4 KiB
TypeScript

import Link from "next/link";
import { useRouter } from "next/router";
import React, { useReducer } from "react";
import { Image } from "./Image";
import styles from "./Navbar.module.css";
type Menu = {
name: string;
route: string;
exact?: boolean;
submenu?: Menu;
}[];
const menu: Menu = [
{
name: "Home",
route: "/",
exact: true,
},
{
name: "About",
route: "/about",
submenu: [
{
name: "About Us",
route: "/about",
exact: true,
},
{
name: "Meet the Team",
route: "/about/team",
},
{
name: "Members",
route: "/about/members",
},
{
name: "Constitution",
route: "/about/constitution",
},
{
name: "Code of Conduct",
route: "/about/code-of-conduct",
},
{
name: "Our Supporters",
route: "/about/our-supporters",
},
],
},
{
name: "Get Involved",
route: "/get-involved",
},
{
name: "Events",
route: "/events",
},
{
name: "Resources",
route: "/resources/services",
submenu: [
{
name: "Services",
route: "/resources/services",
},
{
name: "Machine Usage",
route: "/resources/machine-usage-agreement",
},
{
name: "Tech Talks",
route: "/resources/tech-talks",
},
{
name: "CS Club Wiki",
route: "https://wiki.csclub.uwaterloo.ca",
},
{
name: "Advice",
route: "/resources/advice/co-op",
submenu: [
{
name: "Co-op Advice",
route: "/resources/advice/co-op",
},
{
name: "Academic Advice",
route: "/resources/advice/academic",
},
{
name: "Additional Resources",
route: "/resources/advice/misc",
},
],
},
{
name: "Internships",
route: "https://github.com/uwcsc/2023-internships",
},
],
},
];
export function Navbar() {
const router = useRouter();
const [state, dispatch] = useReducer(reducer, initialState);
return (
<nav className={styles.navbar}>
<div className={styles.navContent}>
<Link href="/">
<a className={styles.logo}>
<Image src="/images/logo-icon.svg" alt="CSC Logo" />
</a>
</Link>
<button
className={styles.hamburger}
onClick={() => dispatch({ type: "open", route: router.pathname })}
>
<HamburgerSvg />
</button>
<div
className={
state.isNavOpen
? `${styles.navMobileBackground} ${styles.show}`
: styles.navMobileBackground
}
onClick={() => dispatch({ type: "close" })}
/>
<div className={styles.navMenuWrapper}>
<Link href="/">
<a
className={styles.logoMobile}
onClick={() => dispatch({ type: "close" })}
>
<Image src="/images/logo-icon.svg" alt="CSC Logo" />
</a>
</Link>
<ul className={styles.navMenu}>
{menu.map((item) => {
return (
<li className={styles.itemWrapper} key={item.name}>
<NavItem
name={item.name}
route={item.route}
submenu={item.submenu}
mainRouteActive={state.activeSubmenus.has(
getMainRoute(item.route)
)}
onClose={() => dispatch({ type: "close" })}
onToggle={(route) => dispatch({ type: "toggle", route })}
/>
</li>
);
})}
</ul>
</div>
</div>
</nav>
);
}
interface MobileState {
isNavOpen: boolean;
activeSubmenus: Set<string>; // strings are NavLink routes
}
type MobileAction =
| { type: "open"; route: string }
| { type: "toggle"; route: string }
| { type: "close" };
const initialState: MobileState = {
isNavOpen: false,
activeSubmenus: new Set(),
};
function reducer(state: MobileState, action: MobileAction): MobileState {
switch (action.type) {
case "open":
return {
isNavOpen: true,
activeSubmenus: new Set([getMainRoute(action.route)]),
};
case "toggle": {
const newSet = new Set(state.activeSubmenus);
if (state.activeSubmenus.has(getMainRoute(action.route))) {
newSet.delete(getMainRoute(action.route));
} else {
newSet.add(getMainRoute(action.route));
}
return {
isNavOpen: state.isNavOpen,
activeSubmenus: newSet,
};
}
case "close":
return initialState;
}
}
interface NavItemProps {
name: string;
route: string;
submenu?: {
name: string;
route: string;
}[];
mainRouteActive: boolean;
onToggle(route: string): void;
onClose(): void;
}
function NavItem(props: NavItemProps) {
const router = useRouter();
const isCurrentPage = shouldHighlight(
router.pathname,
props.name,
props.route
);
const isExternalLink =
props.route.includes("http://") || props.route.includes("https://");
function handleClick() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
props.onClose();
}
return (
<>
{isExternalLink ? (
<a
href={props.route}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{props.name}
</a>
) : (
<Link href={props.route}>
<a
title={props.name}
className={isCurrentPage ? styles.currentPage : ""}
onClick={handleClick}
>
{props.name}
</a>
</Link>
)}
{(props.submenu?.length ?? 0) > 0 ? (
<>
<button
className={
props.mainRouteActive
? `${styles.dropdownIcon} ${styles.rotate}`
: styles.dropdownIcon
}
onClick={() => props.onToggle(props.route)}
>
<DropdownSvg />
</button>
<ul
className={
props.mainRouteActive
? `${styles.dropdown} ${styles.show}`
: styles.dropdown
}
>
{props.submenu?.map((item) => {
return (
<li className={styles.itemWrapper} key={item.name}>
<NavItem
name={item.name}
route={item.route}
mainRouteActive={props.mainRouteActive}
onClose={() => props.onClose()}
onToggle={(route) => props.onToggle(route)}
/>
</li>
);
})}
</ul>
</>
) : null}
</>
);
}
interface Leaf {
name: string;
route: string;
exact?: boolean;
ancestors: { name: string; route: string }[];
}
function collectLeaves(
accumulator: Leaf[],
entry: {
name: string;
route: string;
exact?: boolean;
submenu?: Menu;
}
): Leaf[] {
if (entry.submenu == null) {
return [...accumulator, { ...entry, ancestors: [] }];
}
const subleaves = entry.submenu.reduce(collectLeaves, [] as Leaf[]);
return [
...accumulator,
...subleaves.map((leaf) => ({
...leaf,
ancestors: [...leaf.ancestors, { name: entry.name, route: entry.route }],
})),
];
}
const leaves: Leaf[] = menu.reduce(collectLeaves, [] as Leaf[]);
function shouldHighlight(
pathname: string,
name: string,
route: string
): boolean {
const match = leaves.find((leaf) =>
leaf.exact ? leaf.route === pathname : pathname.startsWith(leaf.route)
);
return match
? (match.name === name && match.route === route) ||
match.ancestors.find(
(ancestor) => ancestor.name === name && ancestor.route === route
) != null
: false;
}
function getMainRoute(route: string) {
if (route === "/") {
return "/";
} else if (route.startsWith("http://") || route.startsWith("https://")) {
return route;
}
return "/" + route.split("/")[1];
}
function HamburgerSvg() {
return (
<svg
width="30"
height="23"
viewBox="0 0 30 23"
className={styles.icon}
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="28"
y1="2"
x2="2"
y2="2"
stroke="#2A2A62"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="28"
y1="11.375"
x2="2"
y2="11.375"
stroke="#2A2A62"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="28"
y1="20.75"
x2="2"
y2="20.75"
stroke="#2A2A62"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function DropdownSvg() {
return (
<svg
width="14"
height="9"
viewBox="0 0 14 9"
fill="none"
className={styles.icon}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.75593 8.12713C7.35716 8.58759 6.64284 8.58759 6.24407 8.12713L0.638743 1.65465C0.0778675 1.00701 0.537921 0 1.39467 0L12.6053 0C13.4621 0 13.9221 1.00701 13.3613 1.65465L7.75593 8.12713Z"
fill="#2A2A62"
/>
</svg>
);
}