Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
33af66d
fix(routing): serve SPA fallback so deep links and refreshes resolve …
donbr Oct 2, 2026
d7fd5d0
fix(nav): collapse navigation into a menu below lg
donbr Oct 2, 2026
9e9112a
fix(meta): add the og-image.jpg referenced by Open Graph and Twitter …
donbr Oct 2, 2026
415d41e
fix(a11y): stop nesting links inside project cards
donbr Oct 2, 2026
44f2214
perf(routing): lazy-load every route except Home and 404
donbr Oct 2, 2026
e9c133f
feat(meta): give every route its own document title
donbr Oct 2, 2026
05e30f6
docs(claude-md): document lazy routes and per-page titles
donbr Oct 2, 2026
1edd4fe
fix(routing): catch failed route loads instead of blanking the app
donbr Oct 2, 2026
2b17751
perf(routing): load ProjectsPage eagerly
donbr Oct 2, 2026
78003f7
fix(seo): drop sitemap entries that GitHub Pages serves with a 404 st…
donbr Oct 2, 2026
da6f942
docs(claude-md): note the homepage exception to the page-title format
donbr Oct 2, 2026
f1cfed8
fix(a11y): keep the mobile menu in the DOM so aria-controls resolves
donbr Oct 2, 2026
2cf1dd1
fix(routing): only blame a site update when a chunk actually failed t…
donbr Oct 2, 2026
4dcddc2
fix(routing): reload once automatically when a lazy chunk fails to load
donbr Oct 2, 2026
32a94fd
docs(claude-md): drop stale line numbers and list RouteErrorBoundary
donbr Oct 2, 2026
2d61b06
refactor(layout): set page titles via a Layout prop and simplify nav …
donbr Oct 2, 2026
74bcd46
refactor(layout): share one StatusPage between the 404 page and error…
donbr Oct 2, 2026
dd349c5
refactor(main): collapse the chunk-reload guard into one try/catch
donbr Oct 2, 2026
64d8205
fix(routing): don't reload offline, and don't cancel the chunk error
donbr Oct 2, 2026
7b91646
fix(routing): clear the route error screen on any navigation
donbr Oct 2, 2026
5fd2f11
fix(routing): treat Vite's 'Unable to preload CSS' as a load failure
donbr Oct 2, 2026
0065e56
fix(routing): tell offline visitors they're offline instead of to reload
donbr Oct 3, 2026
e8c607e
fix(routing): include pathname and hash in the error boundary reset key
donbr Oct 3, 2026
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
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ npm run deploy # Deploy to GitHub Pages (runs predeploy build automati
- `NotFoundPage.tsx`: 404 page
- **src/components/**: React components
- `layout/Layout.tsx`: Main layout wrapper with navigation and footer
- `layout/RouteErrorBoundary.tsx`: Error boundary around the routes (reload prompt when a lazy chunk fails to load)
- `layout/StatusPage.tsx`: Full-page message with actions, shared by the 404 page and the error boundary
- `ui/`: shadcn/ui components (button, etc.)
- `projects/`: Project-specific components, each in its own subdirectory
- `gdelt/GdeltRecordViewer.tsx`: GDELT data viewer
Expand All @@ -64,17 +66,17 @@ Simple React Router setup in App.tsx:
- `/assets/projects/advanced-retrieval` - AdvancedRetrieval
- `*` - NotFoundPage (404)

No lazy loading is used - all components are directly imported.
HomePage, ProjectsPage and NotFoundPage are imported directly; every project route is loaded with `React.lazy` inside one `Suspense` boundary (fallback keeps the Layout visible). This keeps ECharts, Cytoscape and Leaflet out of the homepage bundle - add new project routes the same way.

### Project Data Management

Projects are defined inline as a simple array in `ProjectsPage.tsx` (lines 153-242). Each project has:
Projects are defined inline as a simple `projects` array in `ProjectsPage.tsx`. Each project has:
- title
- description
- tags (with color mapping defined in tagColorMap at lines 24-73)
- tags (with color mapping defined in `tagColorMap` inside `ProjectCard`)
- Optional: demoUrl, codeUrl, huggingFaceUrl, detailUrl

The ProjectCard component is also defined inline in ProjectsPage.tsx (lines 15-150).
The `ProjectCard` component is also defined inline in `ProjectsPage.tsx`.

### Layout Component

Expand Down Expand Up @@ -151,5 +153,6 @@ Located in `src/data/`:

- No testing framework is currently configured
- No formatter (like Prettier) is configured in package.json
- Project uses direct imports (no code splitting/lazy loading)
- Project detail routes are code-split with `React.lazy`, wrapped in `RouteErrorBoundary` (shows a reload prompt if a chunk fails to load, e.g. after a deploy)
- Page titles: pass `title` to `<Layout>` (e.g. `<Layout title="Projects">`); Layout renders `<title>Projects | Don Branson</title>`, which React 19 hoists into `<head>`. Omit it to keep the full site title from `index.html` (the homepage does)
- Some project components include their own types (e.g., situational-awareness/types.ts)
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"predeploy": "npm run build",
"deploy": "gh-pages -d dist",
"dev": "vite",
"build": "vite build"
"build": "vite build",
"postbuild": "node -e \"require('fs').copyFileSync('dist/index.html', 'dist/404.html')\""
},
"dependencies": {
"@types/cytoscape": "^3.21.9",
Expand Down
Binary file added public/og-image.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 49 additions & 25 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,61 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import Layout from '@/components/layout/Layout';
import RouteErrorBoundary from '@/components/layout/RouteErrorBoundary';

// Page Components
// Page Components: small and light, so they stay in the main bundle (ProjectsPage is
// the most-visited secondary route; splitting its ~11 KB out only added a round trip)
import HomePage from '@/pages/HomePage';
import ProjectsPage from '@/pages/ProjectsPage';
import NotFoundPage from '@/pages/NotFoundPage';

// Project Components
import GdeltRecordViewer from '@/components/projects/gdelt/GdeltRecordViewer'; // Add .jsx extension
import CytoscapeViewer from '@/components/projects/cytoscape/CytoscapeViewer';
import EventAnalyzer from '@/components/projects/event-analyzer/EventAnalyzer';
import SituationalAwareness from '@/components/projects/situational-awareness/SituationalAwareness';
import GdeltKnowledgeBase from '@/components/projects/gdelt-kb/GdeltKnowledgeBase';
import AdvancedRetrieval from '@/components/projects/advanced-retrieval/AdvancedRetrieval';
// Lazy-loaded project routes: keeps ECharts, Cytoscape and Leaflet out of the main bundle
const GdeltRecordViewer = lazy(() => import('@/components/projects/gdelt/GdeltRecordViewer'));
const CytoscapeViewer = lazy(() => import('@/components/projects/cytoscape/CytoscapeViewer'));
const EventAnalyzer = lazy(() => import('@/components/projects/event-analyzer/EventAnalyzer'));
const SituationalAwareness = lazy(() => import('@/components/projects/situational-awareness/SituationalAwareness'));
const GdeltKnowledgeBase = lazy(() => import('@/components/projects/gdelt-kb/GdeltKnowledgeBase'));
const AdvancedRetrieval = lazy(() => import('@/components/projects/advanced-retrieval/AdvancedRetrieval'));

// Shown while a lazy route's chunk loads; keeps the nav and footer in place
const RouteFallback: React.FC = () => (
<Layout>
<div className="max-w-6xl mx-auto px-4 py-16 text-gray-500" role="status">
Loading…
</div>
</Layout>
);

const AppRoutes: React.FC = () => {
const location = useLocation();

return (
<RouteErrorBoundary resetKey={`${location.pathname}${location.hash}:${location.key}`}>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/assets/projects" element={<ProjectsPage />} />

{/* Project routes */}
<Route path="/assets/projects/gdelt" element={<GdeltRecordViewer />} />
<Route path="/assets/projects/cytoscape" element={<CytoscapeViewer />} />
<Route path="/assets/projects/event-analyzer" element={<EventAnalyzer />} />
<Route path="/assets/projects/situational-awareness" element={<SituationalAwareness />} />
<Route path="/assets/projects/gdelt-knowledge-base" element={<GdeltKnowledgeBase />} />
<Route path="/assets/projects/advanced-retrieval" element={<AdvancedRetrieval />} />

{/* Catch-all route for 404s */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</RouteErrorBoundary>
);
};

const App: React.FC = () => {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/assets/projects" element={<ProjectsPage />} />

{/* Project routes */}
<Route path="/assets/projects/gdelt" element={<GdeltRecordViewer />} />
<Route path="/assets/projects/cytoscape" element={<CytoscapeViewer />} />
<Route path="/assets/projects/event-analyzer" element={<EventAnalyzer />} />
<Route path="/assets/projects/situational-awareness" element={<SituationalAwareness />} />
<Route path="/assets/projects/gdelt-knowledge-base" element={<GdeltKnowledgeBase />} />
<Route path="/assets/projects/advanced-retrieval" element={<AdvancedRetrieval />} />

{/* Catch-all route for 404s */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
<AppRoutes />
</BrowserRouter>
);
};
Expand Down
213 changes: 116 additions & 97 deletions src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,110 +1,129 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { cn } from '@/lib/utils.ts';

interface LayoutProps {
children: React.ReactNode;
// Rendered as "<title> | Don Branson" (React 19 hoists it into <head>). Omit it to keep
// the full site title from index.html, as the homepage does.
title?: string;
}

const Layout: React.FC<LayoutProps> = ({ children }) => {
type NavItem =
| { label: string; kind: 'hash'; hash: string }
| { label: string; kind: 'route'; to: string }
| { label: string; kind: 'external'; href: string };

const navItems: NavItem[] = [
{ label: 'About', kind: 'hash', hash: '#about' },
{ label: 'Expertise', kind: 'hash', hash: '#expertise' },
{ label: 'Publications', kind: 'hash', hash: '#publications' },
{ label: 'Projects', kind: 'route', to: '/assets/projects' },
{ label: 'Teaching', kind: 'hash', hash: '#teaching' },
{ label: 'Certifications', kind: 'hash', hash: '#certifications' },
{ label: 'Contact', kind: 'hash', hash: '#contact' },
{ label: 'Graph Demos', kind: 'external', href: 'https://graph-viz-next.vercel.app/' },
];

const navStyles = {
desktop: {
base: 'py-4 px-2 whitespace-nowrap hover:text-gray-900',
active: 'text-gray-900 border-b-2 border-blue-500',
inactive: 'text-gray-500',
},
mobile: {
base: 'block py-3 px-2 rounded-md text-base hover:bg-gray-50 hover:text-gray-900',
active: 'text-gray-900 font-semibold',
inactive: 'text-gray-600',
},
} as const;

const Layout: React.FC<LayoutProps> = ({ children, title }) => {
const location = useLocation();

const isActive = (path: string) => {
if (path === '/' && location.pathname === '/') {
return true;
const [menuOpen, setMenuOpen] = useState(false);

// Close the mobile menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [menuOpen]);

const isItemActive = (item: NavItem) =>
item.kind === 'hash'
? location.hash === item.hash
: item.kind === 'route' && location.pathname.startsWith(item.to);

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 (
<Link key={item.label} to={item.to} className={classes} onClick={closeMenu}>
{item.label}
</Link>
);
}
if (path !== '/' && location.pathname.startsWith(path)) {
return true;
if (item.kind === 'external') {
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className={cn(styles.base, "text-blue-600 hover:text-blue-800")}
onClick={closeMenu}
>
{item.label}
</a>
);
}
return false;
return (
<a key={item.label} href={`/${item.hash}`} className={classes} onClick={closeMenu}>
{item.label}
</a>
);
};

return (
<div className="bg-gray-50 min-h-screen flex flex-col">
{title && <title>{`${title} | Don Branson`}</title>}

{/* Navigation */}
<nav className="bg-white shadow-lg">
<nav className="bg-white shadow-lg" aria-label="Primary">
<div className="max-w-6xl mx-auto px-4">
<div className="flex justify-between">
<div className="flex space-x-7">
<div>
<Link to="/" className="flex items-center py-4">
<span className="font-semibold text-gray-700 text-lg">Don Branson</span>
</Link>
</div>
</div>
<div className="flex items-center space-x-3">
<a
href="/#about"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#about' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
About
</a>
<a
href="/#expertise"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#expertise' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Expertise
</a>
<a
href="/#publications"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#publications' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Publications
</a>
<Link
to="/assets/projects"
className={cn(
"py-4 px-2 hover:text-gray-900",
isActive('/assets/projects') ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Projects
</Link>
<a
href="/#teaching"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#teaching' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Teaching
</a>
<a
href="/#certifications"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#certifications' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Certifications
</a>
<a
href="/#contact"
className={cn(
"py-4 px-2 hover:text-gray-900",
location.hash === '#contact' ? "text-gray-900 border-b-2 border-blue-500" : "text-gray-500"
)}
>
Contact
</a>
<a
href="https://graph-viz-next.vercel.app/"
target="_blank"
rel="noopener noreferrer"
className="py-4 px-2 text-blue-600 hover:text-blue-800"
>
Graph Demos
</a>
<div className="flex justify-between items-center">
<Link to="/" className="flex items-center py-4">
<span className="font-semibold text-gray-700 text-lg">Don Branson</span>
</Link>

{/* Desktop links */}
<div className="hidden lg:flex items-center space-x-1 xl:space-x-3 text-sm xl:text-base">
{navItems.map((item) => renderNavLink(item, 'desktop'))}
</div>

{/* Mobile menu button */}
<button
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)}
>
{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">
{navItems.map((item) => renderNavLink(item, 'mobile'))}
</div>
</div>
</nav>
Expand All @@ -120,24 +139,24 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<div className="flex flex-col md:flex-row justify-between items-center">
<p>&copy; {new Date().getFullYear()} Don Branson. All rights reserved.</p>
<div className="flex space-x-4 mt-4 md:mt-0">
<a
href="https://github.com/donbr"
<a
href="https://github.com/donbr"
className="text-gray-300 hover:text-white"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
<a
href="https://www.linkedin.com/in/donbranson/"
<a
href="https://www.linkedin.com/in/donbranson/"
className="text-gray-300 hover:text-white"
target="_blank"
rel="noopener noreferrer"
>
LinkedIn
</a>
<a
href="https://graph-viz-next.vercel.app/"
<a
href="https://graph-viz-next.vercel.app/"
className="text-gray-300 hover:text-white"
target="_blank"
rel="noopener noreferrer"
Expand All @@ -152,4 +171,4 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
);
};

export default Layout;
export default Layout;
Loading
Loading