diff --git a/CLAUDE.md b/CLAUDE.md index 7915b25..1778754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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 `` (e.g. ``); Layout renders `Projects | Don Branson`, which React 19 hoists into ``. 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) diff --git a/package.json b/package.json index 1865246..ff17dc3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/og-image.jpg b/public/og-image.jpg new file mode 100644 index 0000000..a78252f Binary files /dev/null and b/public/og-image.jpg differ diff --git a/src/App.tsx b/src/App.tsx index 85546da..1ffca94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( + +
+ Loading… +
+
+); + +const AppRoutes: React.FC = () => { + const location = useLocation(); + + return ( + + }> + + } /> + } /> + + {/* Project routes */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Catch-all route for 404s */} + } /> + + + + ); +}; const App: React.FC = () => { return ( - - } /> - } /> - - {/* Project routes */} - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Catch-all route for 404s */} - } /> - + ); }; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 2a3c251..df446e4 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -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 " | 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`}} + {/* Navigation */} -