The design settled earlier, built as described: retrieval decides what may be cited, and the server enforces it. The model is handed a shortlist of at most fourteen sources from the learner's own library and told to cite them by marker. Afterwards every citation it wrote is checked against that shortlist and anything else is deleted before it is stored or shown. A hallucinated citation is not unlikely here, it is impossible — surviving is not a decision the model gets to make. A URL it invents is not a citation either: only the marker form counts, so a plausible-looking link stays in the prose citing nothing. Retrieval reuses the hybrid search already in place, and each corpus keeps its own visibility rules — the bank predicate and exam scope for questions, the draft rule for articles, deck ownership for cards. A question source carries the stem only: a chat that printed the answer would hand away the practice it exists to prepare you for. Curated links do the job they were built for. A retrieved row an educator tied to another retrieved row is boosted, because two things somebody already linked surfacing for one query is evidence rather than coincidence. Nothing is stored for this; the boost lives only in that ordering, and the answer marks those sources so the reader knows which claim rests on an educator's judgement rather than on a ranking. Citations are stored with the answer as filtered, so reopening a thread shows the links it showed at the time rather than a fresh retrieval that may now rank differently. In the page the markers become numbers and each number opens its source; a section citation deep-links into that section. Two smaller decisions worth naming: a question appears in the thread the moment you send it and is handed back to the input if the answer fails, because typed words are not something to lose on a 502; and someone else's thread returns 404 rather than 403, since whether it exists is not your business either. 182 backend, 206 frontend green — 16 of the backend tests are the citation contract and the retrieval boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
155 lines
7.4 KiB
JavaScript
155 lines
7.4 KiB
JavaScript
import { lazy, Suspense } from 'react'
|
|
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
|
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
|
import { ThemeProvider } from './context/ThemeContext'
|
|
import Navbar from './components/Navbar'
|
|
|
|
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
|
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
|
const UploadPage = lazy(() => import('./pages/UploadPage'))
|
|
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
|
|
const QuizPage = lazy(() => import('./pages/QuizPage'))
|
|
const CustomQuizPage = lazy(() => import('./pages/CustomQuizPage'))
|
|
const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
|
|
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
|
|
const AdminPage = lazy(() => import('./pages/AdminPage'))
|
|
const AccountPage = lazy(() => import('./pages/AccountPage'))
|
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
|
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
|
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
|
|
const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
|
|
const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage'))
|
|
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
|
|
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
|
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
|
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
|
|
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
|
|
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
|
|
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
|
|
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
|
|
const LandingPage = lazy(() => import('./pages/LandingPage'))
|
|
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
|
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
|
|
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
|
const AiModePage = lazy(() => import('./pages/AiModePage'))
|
|
const MediaPage = lazy(() => import('./pages/MediaPage'))
|
|
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
|
|
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
|
|
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
|
|
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
|
|
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
|
|
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
|
|
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
|
|
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
|
|
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
|
|
|
|
function LoadingFallback() {
|
|
return <div className="loading"><div className="spinner" /></div>
|
|
}
|
|
|
|
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
|
function AppLayout() {
|
|
return (
|
|
<>
|
|
<Navbar />
|
|
<div className="container">
|
|
<Outlet />
|
|
</div>
|
|
<footer className="site-footer">
|
|
<div className="container">© {new Date().getFullYear()} PedsHub</div>
|
|
</footer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Guard: redirect to /home if not logged in, or to / if not moderator
|
|
function RequireAuth({ moderator = false }) {
|
|
const { user, loading } = useAuth()
|
|
if (loading) return <LoadingFallback />
|
|
if (!user) return <Navigate to="/home" replace />
|
|
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
|
|
return <Outlet />
|
|
}
|
|
|
|
function AppRoutes() {
|
|
const { user, loading } = useAuth()
|
|
if (loading) return <LoadingFallback />
|
|
|
|
return (
|
|
<Suspense fallback={<LoadingFallback />}>
|
|
<Routes>
|
|
{/* Always public */}
|
|
<Route path="/home" element={<LandingPage />} />
|
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
|
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
|
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
|
<Route path="/share/:token" element={<PublicQuizPage />} />
|
|
|
|
{/* Authenticated app — wrapped in AppLayout */}
|
|
<Route element={<RequireAuth />}>
|
|
<Route element={<AppLayout />}>
|
|
<Route path="/" element={<DashboardPage />} />
|
|
<Route path="/quizzes" element={<QuizzesPage />} />
|
|
<Route path="/quizzes/create" element={<CustomQuizPage />} />
|
|
<Route path="/quizzes/:id" element={<QuizPage />} />
|
|
<Route path="/results/:id" element={<ResultsPage />} />
|
|
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
|
<Route path="/account" element={<AccountPage />} />
|
|
<Route path="/settings" element={<SettingsPage />} />
|
|
<Route path="/question-bank" element={<QuestionBankPage />} />
|
|
<Route path="/analysis" element={<AnalysisPage />} />
|
|
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
|
<Route path="/flashcards" element={<FlashcardsPage />} />
|
|
<Route path="/search" element={<SearchPage />} />
|
|
<Route path="/ai" element={<AiModePage />} />
|
|
<Route path="/media" element={<MediaPage />} />
|
|
<Route path="/study-plans" element={<StudyPlansPage />} />
|
|
<Route path="/study-plans/:id" element={<StudyPlanPage />} />
|
|
<Route path="/articles" element={<ArticlesPage />} />
|
|
<Route path="/articles/:id" element={<ArticlePage />} />
|
|
{/* Cross-references in article prose address a topic by slug, which
|
|
outlives a numeric id and is what an educator actually writes. */}
|
|
<Route path="/articles/s/:slug" element={<ArticlePage />} />
|
|
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
|
<Route path="/courses" element={<CoursesPage />} />
|
|
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
|
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
|
|
<Route path="/admin" element={<AdminPage />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Moderator-only */}
|
|
<Route element={<RequireAuth moderator />}>
|
|
<Route element={<AppLayout />}>
|
|
<Route path="/upload" element={<UploadPage />} />
|
|
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
|
<Route path="/jobs" element={<JobsPage />} />
|
|
<Route path="/trash" element={<TrashPage />} />
|
|
<Route path="/categories" element={<CategoriesPage />} />
|
|
<Route path="/questions/new" element={<QuestionEditPage mode="create" />} />
|
|
<Route path="/questions/:id" element={<QuestionEditPage />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Catch-all */}
|
|
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<ThemeProvider>
|
|
<AuthProvider>
|
|
<AppRoutes />
|
|
</AuthProvider>
|
|
</ThemeProvider>
|
|
</BrowserRouter>
|
|
)
|
|
}
|