Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions scripts/copy-404.mjs
Original file line number Diff line number Diff line change
@@ -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)');
42 changes: 34 additions & 8 deletions src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -41,18 +41,44 @@ const navStyles = {

const Layout: React.FC<LayoutProps> = ({ children, title }) => {
const location = useLocation();
const [menuOpen, setMenuOpen] = useState(false);

// Close the mobile menu on Escape
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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
Expand All @@ -61,7 +87,6 @@ const Layout: React.FC<LayoutProps> = ({ 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 (
Expand Down Expand Up @@ -110,19 +135,20 @@ const Layout: React.FC<LayoutProps> = ({ children, title }) => {

{/* Mobile menu button */}
<button
ref={menuButtonRef}
type="button"
className="lg:hidden inline-flex items-center justify-center w-11 h-11 -mr-2 rounded-md text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-controls="mobile-menu"
aria-expanded={menuOpen}
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setMenuOpen((open) => !open)}
onClick={() => setMenuOpen(!menuOpen)}
>
{menuOpen ? <X className="w-6 h-6" aria-hidden="true" /> : <Menu className="w-6 h-6" aria-hidden="true" />}
</button>
</div>

{/* Mobile links: always rendered (hidden when closed) so the button's aria-controls target exists */}
<div id="mobile-menu" hidden={!menuOpen} className="lg:hidden border-t border-gray-200 py-2">
<div id="mobile-menu" ref={menuRef} hidden={!menuOpen} className="lg:hidden border-t border-gray-200 py-2">
{navItems.map((item) => renderNavLink(item, 'mobile'))}
</div>
</div>
Expand Down
40 changes: 27 additions & 13 deletions src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Layout>
Expand Down
10 changes: 6 additions & 4 deletions src/pages/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,14 @@ const ProjectCard: React.FC<ProjectCardProps> = ({
{/* [&>a]: lifts every action link above the title's stretched-link overlay */}
<div className="mt-4 pt-3 border-t border-gray-100 flex flex-wrap gap-4 text-sm font-semibold [&>a]:relative [&>a]:z-10">
{detailUrl && (
<Link
to={detailUrl}
className="text-blue-600 hover:text-blue-800 inline-flex items-center gap-1"
// Visual cue only: the stretched title link already covers the card, so this
// isn't a second link (avoids a duplicate tab stop to the same destination)
<span
aria-hidden="true"
className="text-blue-600 inline-flex items-center gap-1"
>
Deep Dive <span className="text-sm">&rarr;</span>
</Link>
</span>
)}
{demoUrl && (
<a
Expand Down
Loading