Personal Website for @jaspermayone.com jaspermayone.com
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 253 lines 8.5 kB view raw
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}