diff --git a/package.json b/package.json index ff17dc3..194d387 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "deploy": "gh-pages -d dist", "dev": "vite", "build": "vite build", - "postbuild": "node -e \"require('fs').copyFileSync('dist/index.html', 'dist/404.html')\"" + "postbuild": "node scripts/copy-404.mjs" }, "dependencies": { "@types/cytoscape": "^3.21.9", diff --git a/scripts/copy-404.mjs b/scripts/copy-404.mjs new file mode 100644 index 0000000..c1a338c --- /dev/null +++ b/scripts/copy-404.mjs @@ -0,0 +1,8 @@ +// GitHub Pages serves 404.html for unknown paths. Copying the SPA entry point +// there lets BrowserRouter handle deep links and refreshes (e.g. /assets/projects). +// Paths resolve from this script's location, so it works from any working directory. +import { copyFileSync } from 'node:fs'; + +const dist = new URL('../dist/', import.meta.url); +copyFileSync(new URL('index.html', dist), new URL('404.html', dist)); +console.log('Copied dist/index.html to dist/404.html (SPA fallback)'); diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index df446e4..7edcf11 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { Menu, X } from 'lucide-react'; import { cn } from '@/lib/utils.ts'; @@ -41,18 +41,44 @@ const navStyles = { const Layout: React.FC = ({ children, title }) => { const location = useLocation(); - const [menuOpen, setMenuOpen] = useState(false); - - // Close the mobile menu on Escape + const menuButtonRef = useRef(null); + const menuRef = useRef(null); + + // Any navigation closes the menu: link, back/forward or a native hash change. The key + // includes pathname, search and hash because location.key alone can stay 'default' + // (initial load, native fragment navigation). Reset during render, not in an effect. + const navKey = `${location.pathname}${location.search}${location.hash}:${location.key}`; + const [menu, setMenu] = useState({ open: false, navKey }); + if (menu.navKey !== navKey) setMenu({ open: false, navKey }); + const menuOpen = menu.open && menu.navKey === navKey; + const setMenuOpen = (open: boolean) => setMenu({ open, navKey }); + const closeMenu = () => setMenuOpen(false); + + // Close the mobile menu on Escape; if focus was inside it, return focus to the toggle useEffect(() => { if (!menuOpen) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setMenuOpen(false); + if (e.key === 'Escape') { + if (menuRef.current?.contains(document.activeElement)) menuButtonRef.current?.focus(); + setMenu((m) => ({ ...m, open: false })); + } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [menuOpen]); + // Close the mobile menu when the viewport reaches the lg breakpoint (where it's hidden), + // so it doesn't reappear open after e.g. rotating a tablet to landscape and back + useEffect(() => { + if (!menuOpen) return; + const desktop = window.matchMedia('(min-width: 1024px)'); + const handleChange = (e: MediaQueryListEvent) => { + if (e.matches) setMenu((m) => ({ ...m, open: false })); + }; + desktop.addEventListener('change', handleChange); + return () => desktop.removeEventListener('change', handleChange); + }, [menuOpen]); + const isItemActive = (item: NavItem) => item.kind === 'hash' ? location.hash === item.hash @@ -61,7 +87,6 @@ const Layout: React.FC = ({ children, title }) => { const renderNavLink = (item: NavItem, variant: keyof typeof navStyles) => { const styles = navStyles[variant]; const classes = cn(styles.base, isItemActive(item) ? styles.active : styles.inactive); - const closeMenu = () => setMenuOpen(false); if (item.kind === 'route') { return ( @@ -110,19 +135,20 @@ const Layout: React.FC = ({ children, title }) => { {/* Mobile menu button */} {/* Mobile links: always rendered (hidden when closed) so the button's aria-controls target exists */} - diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index a07b56b..1e82181 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,26 +1,40 @@ import React, { useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import Layout from '@/components/layout/Layout'; const HomePage: React.FC = () => { - // Handle smooth scrolling for anchor links + const navigate = useNavigate(); + + // Smooth-scroll in-page section links (e.g. /#about) useEffect(() => { const handleAnchorClick = (e: MouseEvent) => { - const target = e.target as HTMLAnchorElement; - if (target.hash && target.hash.startsWith('#')) { - e.preventDefault(); - const element = document.querySelector(target.hash); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - // Update URL without reload - window.history.pushState(null, '', target.hash); - } - } + // Leave modified and middle clicks (open in new tab/window) to the browser + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const anchor = (e.target as Element).closest('a'); + // Only same-page section links: not other origins, other paths, or new-tab targets + if ( + !anchor?.hash || + anchor.origin !== window.location.origin || + anchor.pathname !== window.location.pathname || + (anchor.target && anchor.target !== '_self') + ) return; + // getElementById, not querySelector: ids like "1-intro" aren't valid CSS selectors + const element = document.getElementById(decodeURIComponent(anchor.hash.slice(1))); + if (!element) return; // unknown section: let the browser handle the link + e.preventDefault(); + element.scrollIntoView({ behavior: 'smooth' }); + // Update the URL through the router (not history.pushState) so location.hash, the + // nav's active state and back/forward stay in sync. Keep the query string (e.g. + // utm_* tags) and don't stack duplicate entries when the section is already current. + navigate( + { search: window.location.search, hash: anchor.hash }, + { replace: anchor.hash === window.location.hash } + ); }; document.addEventListener('click', handleAnchorClick); return () => document.removeEventListener('click', handleAnchorClick); - }, []); + }, [navigate]); return ( diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx index a0d91a9..92e684f 100644 --- a/src/pages/ProjectsPage.tsx +++ b/src/pages/ProjectsPage.tsx @@ -100,12 +100,14 @@ const ProjectCard: React.FC = ({ {/* [&>a]: lifts every action link above the title's stretched-link overlay */}
{detailUrl && ( - Deep Dive → - + )} {demoUrl && (