diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 960c809..7a0fdec 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,53 +1,54 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' import SiteFooter from './components/SiteFooter' import ErrorBoundary from './components/ErrorBoundary' +import lazyPage from './utils/lazyPage' -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 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 AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage')) -const AiModePage = lazy(() => import('./pages/AiModePage')) -const MediaPage = lazy(() => import('./pages/MediaPage')) -const EditorialPage = lazy(() => import('./pages/EditorialPage')) -const AccessPage = lazy(() => import('./pages/AccessPage')) -const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage')) -const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage')) -const StudyPlanBlockPage = lazy(() => import('./pages/StudyPlanBlockPage')) -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')) +const LoginPage = lazyPage(() => import('./pages/LoginPage')) +const RegisterPage = lazyPage(() => import('./pages/RegisterPage')) +const DashboardPage = lazyPage(() => import('./pages/DashboardPage')) +const UploadPage = lazyPage(() => import('./pages/UploadPage')) +const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage')) +const QuizPage = lazyPage(() => import('./pages/QuizPage')) +const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage')) +const ResultsPage = lazyPage(() => import('./pages/ResultsPage')) +const AdminPage = lazyPage(() => import('./pages/AdminPage')) +const AccountPage = lazyPage(() => import('./pages/AccountPage')) +const SettingsPage = lazyPage(() => import('./pages/SettingsPage')) +const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage')) +const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage')) +const CategoriesPage = lazyPage(() => import('./pages/CategoriesPage')) +const QuestionEditPage = lazyPage(() => import('./pages/QuestionEditPage')) +const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage')) +const JobsPage = lazyPage(() => import('./pages/JobsPage')) +const TrashPage = lazyPage(() => import('./pages/TrashPage')) +const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage')) +const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage')) +const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage')) +const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage')) +const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage')) +const LandingPage = lazyPage(() => import('./pages/LandingPage')) +const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage')) +const ArticlesPage = lazyPage(() => import('./pages/ArticlesPage')) +const SearchPage = lazyPage(() => import('./pages/SearchPage')) +const AnalysisSessionPage = lazyPage(() => import('./pages/AnalysisSessionPage')) +const AiModePage = lazyPage(() => import('./pages/AiModePage')) +const MediaPage = lazyPage(() => import('./pages/MediaPage')) +const EditorialPage = lazyPage(() => import('./pages/EditorialPage')) +const AccessPage = lazyPage(() => import('./pages/AccessPage')) +const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage')) +const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage')) +const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage')) +const ArticlePage = lazyPage(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) +const PublicQuizPage = lazyPage(() => import('./pages/PublicQuizPage')) +const FlashcardStudyPage = lazyPage(() => import('./pages/FlashcardStudyPage')) +const CoursesPage = lazyPage(() => import('./pages/CoursesPage')) +const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage')) +const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage')) +const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage')) function LoadingFallback() { return
diff --git a/frontend/src/components/ExamSwitcher.css b/frontend/src/components/ExamSwitcher.css index a1db30d..796886a 100644 --- a/frontend/src/components/ExamSwitcher.css +++ b/frontend/src/components/ExamSwitcher.css @@ -19,6 +19,47 @@ } @media (max-width: 900px) { .exam-switcher-label { display: none; } } +/* ── The short menu ─────────────────────────────────────────────────── + What you are on, where you have been lately, and a way to the full + list — so changing objective does not start with a full-screen dialog. */ +.exam-switcher { position: relative; display: inline-flex; } +.exm { + position: absolute; top: calc(100% + 6px); left: 0; z-index: 1090; + min-width: 260px; max-width: min(320px, calc(100vw - 24px)); + padding: 6px; background: var(--card-bg); + border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18); +} +.exm-current { padding: 9px 11px 11px; border-bottom: 1px solid var(--border); } +.exm-current small { + display: block; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); +} +.exm-current strong { display: block; margin-top: 3px; font-size: 0.9rem; font-weight: 650; } +.exm-heading { + margin: 8px 0 2px; padding: 0 11px; + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); +} +.exm-item { + display: block; width: 100%; padding: 8px 11px; + font: inherit; font-size: 0.86rem; text-align: left; cursor: pointer; + background: none; border: 0; border-radius: 7px; color: var(--text); +} +.exm-item:hover:not(:disabled) { background: var(--bg); } +.exm-item:disabled { opacity: 0.6; cursor: default; } +.exm-item small { display: block; margin-top: 1px; font-size: 0.74rem; color: var(--text-muted); } +.exm-item.is-more { + margin-top: 4px; border-top: 1px solid var(--border); border-radius: 0 0 7px 7px; + color: var(--primary); font-weight: 600; +} +.exm-error { margin: 6px 11px 2px; font-size: 0.78rem; color: var(--wrong-fg); } + +/* On a phone the menu is the width of the screen rather than of the button. */ +@media (max-width: 520px) { + .exm { left: auto; right: 0; min-width: 240px; } +} + /* ── The dialog ───────────────────────────────────────────────────── */ .exo-overlay { position: fixed; inset: 0; z-index: 1100; display: flex; @@ -67,7 +108,6 @@ .exo-option input { width: 18px; height: 18px; margin-top: 1px; flex-shrink: 0; } .exo-option strong { display: block; font-size: 0.92rem; font-weight: 650; } .exo-option small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); } -.exo-option.is-all { margin-top: 8px; } /* Shown, so an educator can see it exists; not selectable, because choosing it would scope the bank to nothing. */ .exo-option.is-empty { opacity: 0.55; cursor: not-allowed; } diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx index 0892d84..287e836 100644 --- a/frontend/src/components/ExamSwitcher.jsx +++ b/frontend/src/components/ExamSwitcher.jsx @@ -14,16 +14,40 @@ import './ExamSwitcher.css' * and confirmed rather than applied the instant the pointer passes over an * option. The choice is stored on the user, so it follows them between * devices rather than living in this browser. + * + * The button opens a short menu first: what you are studying for now, the ones + * you have been on lately, and a way through to the full picker. Switching + * back to last week's objective is one click; the whole list is only opened + * when you actually want the whole list. */ +const RECENT_KEY = 'pedshub.recentObjectives' +const RECENT_MAX = 3 + +// A per-browser convenience, not a record: if it is missing or unreadable the +// menu is simply shorter. +function readRecent() { + try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]').filter(Number.isInteger) } + catch { return [] } +} + +function rememberRecent(id) { + if (!Number.isInteger(id)) return + try { + const next = [id, ...readRecent().filter(x => x !== id)].slice(0, RECENT_MAX + 1) + localStorage.setItem(RECENT_KEY, JSON.stringify(next)) + } catch { /* private browsing, or storage turned off */ } +} export default function ExamSwitcher({ onChange }) { const [exams, setExams] = useState([]) const [activeId, setActiveId] = useState(null) + const [menu, setMenu] = useState(false) const [open, setOpen] = useState(false) const [chosen, setChosen] = useState(null) const [query, setQuery] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const search = useRef(null) + const wrap = useRef(null) const load = () => api.get('/exams/') .then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) }) @@ -31,6 +55,18 @@ export default function ExamSwitcher({ onChange }) { useEffect(() => { load() }, []) + useEffect(() => { + if (!menu) return undefined + const away = e => { if (!wrap.current?.contains(e.target)) setMenu(false) } + const onKey = e => { if (e.key === 'Escape') setMenu(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [menu]) + useEffect(() => { if (!open) return undefined setChosen(activeId) @@ -55,28 +91,73 @@ export default function ExamSwitcher({ onChange }) { return [...groups.entries()] }, [exams, query]) - const save = async () => { + const apply = async (examId) => { setBusy(true); setError('') try { - await api.put('/exams/active', { exam_id: chosen }) - setActiveId(chosen) + await api.put('/exams/active', { exam_id: examId }) + rememberRecent(examId) + setActiveId(examId) setOpen(false) - onChange?.(chosen) + setMenu(false) + onChange?.(examId) } catch { setError('Could not change your study objective') } finally { setBusy(false) } } if (exams.length === 0) return null const active = exams.find(e => e.id === activeId) + // Lately, minus where you are now, and minus anything that has since been + // emptied or withdrawn. + const recent = readRecent() + .filter(id => id !== activeId) + .map(id => exams.find(e => e.id === id)) + .filter(e => e && e.question_count > 0) + .slice(0, RECENT_MAX) return ( - <> - + {menu && ( +
+ {active ? ( +
+ Current objective + {active.name} +
+ ) : ( +
+ No objective set + Everything is in scope until you pick one +
+ )} + + {recent.length > 0 && ( + <> +

Recently

+ {recent.map(exam => ( + + ))} + + )} + + + {error && !open &&

{error}

} +
+ )} + {open && (
e.target === e.currentTarget && setOpen(false)}>
@@ -94,15 +175,9 @@ export default function ExamSwitcher({ onChange }) { onChange={e => setQuery(e.target.value)} />
- - + {/* There is no unscoped choice. Studying for nothing in + particular is not an objective, and the whole bank at once + makes the filters and the analysis mean less, not more. */} {families.map(([family, list]) => (

{family}

@@ -133,12 +208,13 @@ export default function ExamSwitcher({ onChange }) { {error &&

{error}

}
- +
)} - +
) } diff --git a/frontend/src/components/ExamSwitcher.test.jsx b/frontend/src/components/ExamSwitcher.test.jsx new file mode 100644 index 0000000..76df576 --- /dev/null +++ b/frontend/src/components/ExamSwitcher.test.jsx @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ExamSwitcher from './ExamSwitcher' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn() } })) + +const EXAMS = [ + { id: 1, name: 'Pediatrics Boards', family: 'Boards', question_count: 2948 }, + { id: 2, name: 'Shelf – Psychiatry', family: 'Shelf', question_count: 140 }, + { id: 3, name: 'USMLE Step 2 CK', family: 'USMLE', question_count: 0 }, +] + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + api.get.mockResolvedValue({ data: { exams: EXAMS, active_exam_id: 1 } }) + api.put.mockResolvedValue({ data: {} }) +}) + +const openMenu = async () => { + render() + await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ })) +} + +describe('ExamSwitcher', () => { + it('opens a short menu before the full picker', async () => { + await openMenu() + const menu = screen.getByRole('menu') + expect(within(menu).getByText('Current objective')).toBeInTheDocument() + expect(within(menu).getByText('Pediatrics Boards')).toBeInTheDocument() + // The whole list is a step further in, not the first thing you meet. + expect(screen.queryByRole('dialog')).toBeNull() + await userEvent.click(within(menu).getByRole('menuitem', { name: 'Choose a new study objective' })) + expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument() + }) + + it('switches straight back to one you were on lately', async () => { + localStorage.setItem('pedshub.recentObjectives', JSON.stringify([2])) + await openMenu() + const menu = screen.getByRole('menu') + await userEvent.click(within(menu).getByRole('menuitem', { name: /Shelf – Psychiatry/ })) + expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 }) + }) + + it('offers no unscoped choice, and no objective with nothing behind it', async () => { + await openMenu() + await userEvent.click(screen.getByRole('menuitem', { name: 'Choose a new study objective' })) + const dialog = await screen.findByRole('dialog') + // Studying for nothing in particular is not an objective. + expect(within(dialog).queryByText('All content')).toBeNull() + expect(within(dialog).getByRole('radio', { name: /USMLE Step 2 CK/ })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/QuestionEditors.jsx b/frontend/src/components/QuestionEditors.jsx index 7cd2c3e..652d9b3 100644 --- a/frontend/src/components/QuestionEditors.jsx +++ b/frontend/src/components/QuestionEditors.jsx @@ -1,9 +1,10 @@ /* Question authoring modals shared by the question bank and the question manager. */ -import { useState, useEffect, lazy, Suspense } from 'react' +import { useState, useEffect, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import api from '../api/client' import CategoryTree from './CategoryTree' -const RichEditor = lazy(() => import('../components/RichEditor')) +const RichEditor = lazyPage(() => import('../components/RichEditor')) const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] diff --git a/frontend/src/components/QuestionPreview.jsx b/frontend/src/components/QuestionPreview.jsx index 8cf7ab9..a253595 100644 --- a/frontend/src/components/QuestionPreview.jsx +++ b/frontend/src/components/QuestionPreview.jsx @@ -1,10 +1,11 @@ -import { Suspense, lazy } from 'react' +import { Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { Link } from 'react-router-dom' import RichText from './RichText' import QuestionReadingLinks from './QuestionReadingLinks' import { uploadUrl } from '../utils/uploads' -const TeachChat = lazy(() => import('./TeachChat')) +const TeachChat = lazyPage(() => import('./TeachChat')) /** * One question, shown whole, without leaving the page you found it on. diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx index 2083c5a..d337457 100644 --- a/frontend/src/pages/CourseEditorPage.jsx +++ b/frontend/src/pages/CourseEditorPage.jsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react' +import { useState, useEffect, useCallback, useRef, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { useParams, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' -const RichEditor = lazy(() => import('../components/RichEditor')) +const RichEditor = lazyPage(() => import('../components/RichEditor')) function stripHtml(html) { if (!html) return '' diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index d6b461d..0ca8ece 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1,6 +1,7 @@ import { uploadUrl } from '../utils/uploads' import QuestionReadingLinks from '../components/QuestionReadingLinks' -import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' +import { useState, useEffect, useRef, useCallback, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import RichText from '../components/RichText' import { mergeTextRanges } from '../utils/highlightOffsets' @@ -14,7 +15,7 @@ import '../components/Feedback.css' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' -const TeachChat = lazy(() => import('../components/TeachChat')) +const TeachChat = lazyPage(() => import('../components/TeachChat')) const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] const QUESTION_HIGHLIGHT_WORDS = 8 @@ -508,6 +509,11 @@ export default function QuizPage() { }, [id, manualHighlights]) const [leaveTarget, setLeaveTarget] = useState(null) + + // Suspending is not abandoning: it ends on the session's own analysis, where + // what has been answered so far is scored and the Resume button sits. A + // course quiz still returns to the course it belongs to. + const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/') const questions = quiz?.questions || [] const current = questions[currentIdx] const isStudy = quizMode === 'study' @@ -1240,7 +1246,7 @@ const timerStarted = timeLeft !== null
{timeLeft !== null && } - {restartConfirm ? ( @@ -1638,7 +1644,7 @@ const timerStarted = timeLeft !== null scrolls inside it, rather than the whole page scrolling. */}
+ onClick={() => setLeaveTarget(exitTarget())}>Exit session {quizNavigation('bottom')} {answeredCount > 0 && (