- "frontend/src/api/highlights.ts" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" - "frontend/src/App.tsx" - "frontend/src/pages/CreatorDashboard.tsx" GSD-Task: S01/T02
225 lines
9.3 KiB
TypeScript
225 lines
9.3 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 ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard"));
|
|
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
|
|
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
|
const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
|
|
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
|
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
|
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
|
import AdminDropdown from "./components/AdminDropdown";
|
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
|
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">
|
|
<ImpersonationBanner />
|
|
<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>
|
|
<Link to="/chat">Chat</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>} />
|
|
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></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>} />
|
|
<Route path="/admin/users" element={<Suspense fallback={<LoadingFallback />}><AdminUsers /></Suspense>} />
|
|
<Route path="/admin/audit-log" element={<Suspense fallback={<LoadingFallback />}><AdminAuditLog /></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/consent" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></Suspense></ProtectedRoute>} />
|
|
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
|
|
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
|
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
|
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
|
|
|
|
{/* Fallback */}
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</main>
|
|
|
|
<AppFooter />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<AppShell />
|
|
</AuthProvider>
|
|
);
|
|
}
|