chrysopedia/frontend/src/App.tsx
jlightner 8417f0e9e0 feat: Built WatchPage with video player, synced transcript sidebar, laz…
- "frontend/src/api/videos.ts"
- "frontend/src/components/TranscriptSidebar.tsx"
- "frontend/src/pages/WatchPage.tsx"
- "frontend/src/App.tsx"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S01/T03
2026-04-03 23:50:15 +00:00

209 lines
7.8 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback, Suspense } 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 Login from "./pages/Login";
import Register from "./pages/Register";
// Lazy-loaded pages — admin, creator, and info routes split into separate chunks
const AdminReports = React.lazy(() => import("./pages/AdminReports"));
const AdminPipeline = React.lazy(() => import("./pages/AdminPipeline"));
const AdminTechniquePages = React.lazy(() => import("./pages/AdminTechniquePages"));
const About = React.lazy(() => import("./pages/About"));
const CreatorDashboard = React.lazy(() => import("./pages/CreatorDashboard"));
const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings"));
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
import AdminDropdown from "./components/AdminDropdown";
import AppFooter from "./components/AppFooter";
import SearchAutocomplete from "./components/SearchAutocomplete";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider, useAuth } from "./context/AuthContext";
function LoadingFallback() {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "40vh" }}>
<p style={{ color: "var(--text-secondary, #94a3b8)", fontSize: "0.95rem" }}>Loading</p>
</div>
);
}
function AuthNav() {
const { isAuthenticated, user, logout } = useAuth();
const navigate = useNavigate();
if (isAuthenticated) {
return (
<>
<Link to="/creator/dashboard" className="auth-nav__user">
{user?.display_name ?? "Dashboard"}
</Link>
<button
className="auth-nav__logout"
onClick={() => { logout(); navigate("/"); }}
>
Logout
</button>
</>
);
}
return <Link to="/login" className="auth-nav__login">Login</Link>;
}
function AppShell() {
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 (
<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>
<AuthNav />
<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 />} />
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
{/* 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={<Suspense fallback={<LoadingFallback />}><AdminReports /></Suspense>} />
<Route path="/admin/pipeline" element={<Suspense fallback={<LoadingFallback />}><AdminPipeline /></Suspense>} />
<Route path="/admin/techniques" element={<Suspense fallback={<LoadingFallback />}><AdminTechniquePages /></Suspense>} />
{/* Info routes */}
<Route path="/about" element={<Suspense fallback={<LoadingFallback />}><About /></Suspense>} />
{/* Auth routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Creator routes (protected) */}
<Route path="/creator/dashboard" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorDashboard /></Suspense></ProtectedRoute>} />
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<AppFooter />
</div>
);
}
export default function App() {
return (
<AuthProvider>
<AppShell />
</AuthProvider>
);
}