Personal Website for @jaspermayone.com
jaspermayone.com
1// src/components/PageNavigation.tsx
2"use client";
3
4import { pages } from "@/lib/defs";
5import { PageItem } from "@/lib/types";
6import styles from "@/styles/Home.module.css";
7import { usePathname } from "next/navigation";
8import { useTransitionRouter } from "next-view-transitions";
9import { useEffect, useRef, useState } from "react";
10
11interface PageNavigationProps {
12 color?: string;
13 addTextShadow?: boolean;
14}
15
16// Centralized route map for aliases and nested routes
17const ROUTE_MAP: Record<string, string> = {
18 home: "/",
19 cv: "/to/cv",
20 gpg: "/keys/gpg",
21 ssh: "/keys/ssh",
22 // Add any other explicit aliases here
23};
24
25export default function PageNavigation(props: PageNavigationProps) {
26 const { color, addTextShadow } = props;
27 const router = useTransitionRouter();
28 const pathname = usePathname();
29 const [showMoreDropdown, setShowMoreDropdown] = useState(false);
30 const dropdownRef = useRef<HTMLDivElement>(null);
31
32 const textColor = addTextShadow ? "#1d4321" : color || "inherit";
33 const textShadowStyle = {}; // No text shadow needed with solid background
34
35 // Get pages that don't show in main nav
36 const morePages = pages
37 .filter((item: PageItem) => !item.showInNav)
38 .sort((a: PageItem, b: PageItem) => {
39 // Sort alphabetically by text
40 return a.text.localeCompare(b.text);
41 });
42
43 // Close dropdown when clicking outside
44 useEffect(() => {
45 const handleClickOutside = (event: MouseEvent) => {
46 if (
47 dropdownRef.current &&
48 !dropdownRef.current.contains(event.target as Node)
49 ) {
50 setShowMoreDropdown(false);
51 }
52 };
53
54 if (showMoreDropdown) {
55 document.addEventListener("mousedown", handleClickOutside);
56 }
57
58 return () => {
59 document.removeEventListener("mousedown", handleClickOutside);
60 };
61 }, [showMoreDropdown]);
62
63 /**
64 * Determine the selected tab based on the current path.
65 * Handles nested routes like /keys/gpg and /keys/ssh by mapping them to "gpg"/"ssh".
66 * Also normalizes trailing slashes.
67 */
68 const getSelectedTab = () => {
69 if (!pathname) return undefined;
70
71 // Normalize trailing slash
72 const normalized = pathname.replace(/\/+$/, "");
73
74 if (normalized === "/" || normalized === "") return "home";
75
76 // Special-case nested keys pages
77 if (normalized.startsWith("/keys/gpg")) return "gpg";
78 if (normalized.startsWith("/keys/ssh")) return "ssh";
79
80 // Handle redirects or alias routes if needed (example: /to/cv → cv)
81 if (normalized.startsWith("/to/cv")) return "cv";
82
83 // Default: first segment after /
84 const firstSegment = normalized.split("/")[1];
85 return firstSegment || "home";
86 };
87
88 const selectedTab = getSelectedTab();
89
90 /**
91 * Normalize a menu item into the canonical key used for navigation.
92 * This ensures items like "keys/gpg" are treated as "gpg".
93 */
94 const normalizeItem = (item: string): string => {
95 if (!item) return "home";
96 // strip leading slashes
97 const trimmed = item.replace(/^\/+/, "");
98 // map nested keys to leaf tabs
99 if (trimmed.startsWith("keys/gpg")) return "gpg";
100 if (trimmed.startsWith("keys/ssh")) return "ssh";
101 if (trimmed.startsWith("to/cv")) return "cv";
102 // default to first segment
103 const seg = trimmed.split("/")[0];
104 return seg || "home";
105 };
106
107 const handleMenuClick = async (rawItem: string) => {
108 const item = normalizeItem(rawItem);
109
110 // Prefer explicit mapping when present
111 const mapped = ROUTE_MAP[item];
112
113 // Determine the href we will push to
114 const href =
115 typeof mapped === "string" && mapped.length > 0 ? mapped : `/${item}`;
116
117 // Final validation: ensure href is a non-empty string and starts with '/'
118 if (
119 typeof href !== "string" ||
120 href.length === 0 ||
121 !href.startsWith("/")
122 ) {
123 // Log detailed info to help debug the offending item
124 console.error("Invalid route for menu click", {
125 rawItem,
126 normalizedItem: item,
127 mapped,
128 href,
129 });
130 return; // Avoid calling router.push with an invalid value
131 }
132
133 try {
134 await router.push(href);
135 } catch (err) {
136 // Network or Next router errors (e.g., middleware fetch failures)
137 console.error("router.push failed", { href, error: err });
138 }
139 };
140
141 return (
142 <div
143 className={styles.menuContainer}
144 style={
145 addTextShadow
146 ? {
147 background: "#e0eb60",
148 padding: "0.75rem 2rem",
149 borderRadius: "50px",
150 boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
151 viewTransitionName: "main-navigation",
152 }
153 : {
154 viewTransitionName: "main-navigation",
155 }
156 }
157 >
158 <div
159 className={`${styles.menu} flex items-center`}
160 aria-label="main menu"
161 >
162 {pages
163 .filter((item: PageItem) => item.showInNav)
164 .sort((a: PageItem, b: PageItem) => a.order - b.order)
165 .map((item: PageItem) => (
166 <div
167 key={item.slug}
168 className={`${styles.menuItemContainer} flex items-center`}
169 >
170 <button
171 type="button"
172 className={`${styles.menuItem} ${item.slug === selectedTab ? "lnk" : ""} flex cursor-pointer items-center border-0 bg-transparent p-0 hover:!text-[#56ba8e]`}
173 onClick={() => handleMenuClick(item.slug)}
174 title={`Go to ${item.slug}`}
175 style={
176 item.slug === selectedTab
177 ? { fontFamily: "var(--font-balgin)", ...textShadowStyle }
178 : {
179 fontFamily: "var(--font-balgin)",
180 color: textColor,
181 ...textShadowStyle,
182 }
183 }
184 >
185 /{item.slug}
186 </button>
187 </div>
188 ))}
189 <div
190 key={"more"}
191 className={`${styles.menuItemContainer} relative flex items-center`}
192 style={{ isolation: "isolate" }}
193 ref={dropdownRef}
194 >
195 <button
196 type="button"
197 className={`${styles.menuItem} ${showMoreDropdown ? "lnk" : ""} flex cursor-pointer items-center border-0 bg-transparent p-0 hover:!text-[#56ba8e]`}
198 onClick={() => setShowMoreDropdown(!showMoreDropdown)}
199 title={`see more pages`}
200 style={
201 showMoreDropdown
202 ? { fontFamily: "var(--font-balgin)", ...textShadowStyle }
203 : {
204 fontFamily: "var(--font-balgin)",
205 color: textColor,
206 ...textShadowStyle,
207 }
208 }
209 >
210 /more
211 </button>
212 {showMoreDropdown && morePages.length > 0 && (
213 <div
214 className="absolute top-full z-[9999] mt-2 min-w-[150px] rounded-[10px] border-2 border-dashed border-stone-950 px-3 py-2 backdrop-blur-[10px] dark:border-stone-50"
215 style={{
216 background: "light-dark(#f8fbf8, #151922)",
217 boxShadow:
218 "light-dark(0 2px 5px rgba(0, 0, 0, 0.1), 0 2px 5px rgba(255, 255, 255, 0.1))",
219 }}
220 >
221 {morePages.map((item: PageItem, index) => (
222 <div key={item.slug}>
223 <button
224 type="button"
225 className="w-full cursor-pointer border-0 bg-transparent px-2 py-1 text-left text-sm italic transition-colors duration-300 ease-in-out hover:!text-[#56ba8e] hover:underline hover:decoration-wavy"
226 onClick={() => {
227 handleMenuClick(item.slug);
228 setShowMoreDropdown(false);
229 }}
230 style={{
231 fontFamily: "var(--font-balgin)",
232 color: textColor,
233 }}
234 >
235 /{item.slug}
236 </button>
237 {index < morePages.length - 1 && (
238 <div
239 className="my-1 h-px opacity-20"
240 style={{
241 background: "light-dark(#000, #fff)",
242 }}
243 />
244 )}
245 </div>
246 ))}
247 </div>
248 )}
249 </div>
250 </div>
251 </div>
252 );
253}