chrysopedia/frontend/src/App.tsx
jlightner b344307a89 feat: Added AuthContext provider with JWT persistence, auth API client…
- "frontend/src/context/AuthContext.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/Login.tsx"
- "frontend/src/pages/Login.module.css"
- "frontend/src/pages/Register.tsx"
- "frontend/src/pages/Register.module.css"
- "frontend/src/App.tsx"

GSD-Task: S02/T03
2026-04-03 21:58:08 +00:00

160 lines
5.8 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import Home from "./pages/Home";
import SearchResults from "./pages/SearchResults";
import TechniquePage from "./pages/TechniquePage";
import CreatorsBrowse from "./pages/CreatorsBrowse";
import CreatorDetail from "./pages/CreatorDetail";
import TopicsBrowse from "./pages/TopicsBrowse";
import SubTopicPage from "./pages/SubTopicPage";
import AdminReports from "./pages/AdminReports";
import AdminPipeline from "./pages/AdminPipeline";
import AdminTechniquePages from "./pages/AdminTechniquePages";
import About from "./pages/About";
import Login from "./pages/Login";
import Register from "./pages/Register";
import AdminDropdown from "./components/AdminDropdown";
import AppFooter from "./components/AppFooter";
import SearchAutocomplete from "./components/SearchAutocomplete";
import { AuthProvider } from "./context/AuthContext";
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const showNavSearch = location.pathname !== "/";
const [menuOpen, setMenuOpen] = useState(false);
const headerRef = useRef<HTMLElement>(null);
// Close menu on route change
useEffect(() => {
setMenuOpen(false);
}, [location.pathname]);
// Close menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [menuOpen]);
// Close menu on outside click
const handleOutsideClick = useCallback(
(e: MouseEvent) => {
if (
menuOpen &&
headerRef.current &&
!headerRef.current.contains(e.target as Node)
) {
setMenuOpen(false);
}
},
[menuOpen],
);
useEffect(() => {
if (!menuOpen) return;
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [menuOpen, handleOutsideClick]);
return (
<AuthProvider>
<div className="app">
<a href="#main-content" className="skip-link">Skip to content</a>
<header className="app-header" ref={headerRef}>
<Link to="/" className="app-header__brand">
<span className="app-header__logo" aria-hidden="true">
<svg viewBox="0 0 32 32" width="24" height="24" fill="none">
<path d="M22 10.5a6.5 6.5 0 0 0-6.5-6.5C11.36 4 8 7.36 8 11.5c0 4.14 3.36 7.5 7.5 7.5" stroke="#22d3ee" strokeWidth="3" strokeLinecap="round"/>
<circle cx="22" cy="22" r="3" fill="#22d3ee" opacity="0.5"/>
</svg>
</span>
<span>Chrysopedia</span>
</Link>
{showNavSearch && (
<SearchAutocomplete
variant="nav"
globalShortcut
placeholder="Search… Ctrl+⇧F"
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>
)}
<div className="app-header__right">
<button
className="hamburger-btn"
onClick={() => setMenuOpen((v) => !v)}
aria-label="Toggle navigation"
aria-expanded={menuOpen}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{menuOpen ? (
<>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="6" y1="18" x2="18" y2="6" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
<nav className={`app-nav${menuOpen ? " app-nav--open" : ""}`}>
<Link to="/">Home</Link>
<Link to="/topics">Topics</Link>
<Link to="/creators">Creators</Link>
<AdminDropdown />
{/* Mobile-only: search bar inside menu when not shown in header */}
{menuOpen && showNavSearch && (
<div className="mobile-nav-search">
<SearchAutocomplete
variant="nav"
placeholder="Search…"
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>
</div>
)}
</nav>
</div>
</header>
<main className="app-main" id="main-content">
<Routes>
{/* Public routes */}
<Route path="/" element={<Home />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/techniques/:slug" element={<TechniquePage />} />
{/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} />
<Route path="/creators/:slug" element={<CreatorDetail />} />
<Route path="/topics/:category/:subtopic" element={<SubTopicPage />} />
<Route path="/topics" element={<TopicsBrowse />} />
{/* Admin routes */}
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/pipeline" element={<AdminPipeline />} />
<Route path="/admin/techniques" element={<AdminTechniquePages />} />
{/* Info routes */}
<Route path="/about" element={<About />} />
{/* Auth routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<AppFooter />
</div>
</AuthProvider>
);
}