feat: an objective menu, no unscoped choice, and pages that survive a deploy
The switcher opened the whole picker on the first click. Now it opens a short menu: what you are studying for, the objectives you have been on lately (one click to switch back), and a way through to the full list. "All content" is gone. Studying for nothing in particular is not an objective, and the whole bank at once makes the filters and the analysis mean less rather than more. Saving requires an objective to be chosen. Suspending a session now lands on that session's analysis, where what has been answered so far is scored and Resume sits, rather than on a list of every session you own. A course quiz still returns to its course. lazyPage() replaces lazy() for every route and lazily-loaded component. Each build fingerprints the chunk filenames, so a tab still holding the previous index.html asks for a file the new image does not have and the user meets "This page failed to load" for a page that is fine. One reload per tab fetches the new index; a failure that survives the reload is shown, not looped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
5f61422daf
commit
c20b1e678f
11 changed files with 357 additions and 73 deletions
|
|
@ -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 { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import { ThemeProvider } from './context/ThemeContext'
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
import Navbar from './components/Navbar'
|
import Navbar from './components/Navbar'
|
||||||
import SiteFooter from './components/SiteFooter'
|
import SiteFooter from './components/SiteFooter'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
|
import lazyPage from './utils/lazyPage'
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
|
||||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
const DashboardPage = lazyPage(() => import('./pages/DashboardPage'))
|
||||||
const UploadPage = lazy(() => import('./pages/UploadPage'))
|
const UploadPage = lazyPage(() => import('./pages/UploadPage'))
|
||||||
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
|
const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
|
||||||
const QuizPage = lazy(() => import('./pages/QuizPage'))
|
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
|
||||||
const CustomQuizPage = lazy(() => import('./pages/CustomQuizPage'))
|
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
|
||||||
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
|
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
|
||||||
const AdminPage = lazy(() => import('./pages/AdminPage'))
|
const AdminPage = lazyPage(() => import('./pages/AdminPage'))
|
||||||
const AccountPage = lazy(() => import('./pages/AccountPage'))
|
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
|
||||||
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
|
||||||
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
|
const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage'))
|
||||||
const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
|
const CategoriesPage = lazyPage(() => import('./pages/CategoriesPage'))
|
||||||
const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage'))
|
const QuestionEditPage = lazyPage(() => import('./pages/QuestionEditPage'))
|
||||||
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
|
const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage'))
|
||||||
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
const JobsPage = lazyPage(() => import('./pages/JobsPage'))
|
||||||
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
const TrashPage = lazyPage(() => import('./pages/TrashPage'))
|
||||||
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
|
const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage'))
|
||||||
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
|
const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage'))
|
||||||
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
|
const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage'))
|
||||||
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
|
const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage'))
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
|
const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage'))
|
||||||
const LandingPage = lazy(() => import('./pages/LandingPage'))
|
const LandingPage = lazyPage(() => import('./pages/LandingPage'))
|
||||||
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage'))
|
||||||
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
|
const ArticlesPage = lazyPage(() => import('./pages/ArticlesPage'))
|
||||||
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
const SearchPage = lazyPage(() => import('./pages/SearchPage'))
|
||||||
const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage'))
|
const AnalysisSessionPage = lazyPage(() => import('./pages/AnalysisSessionPage'))
|
||||||
const AiModePage = lazy(() => import('./pages/AiModePage'))
|
const AiModePage = lazyPage(() => import('./pages/AiModePage'))
|
||||||
const MediaPage = lazy(() => import('./pages/MediaPage'))
|
const MediaPage = lazyPage(() => import('./pages/MediaPage'))
|
||||||
const EditorialPage = lazy(() => import('./pages/EditorialPage'))
|
const EditorialPage = lazyPage(() => import('./pages/EditorialPage'))
|
||||||
const AccessPage = lazy(() => import('./pages/AccessPage'))
|
const AccessPage = lazyPage(() => import('./pages/AccessPage'))
|
||||||
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
|
const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage'))
|
||||||
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
|
const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage'))
|
||||||
const StudyPlanBlockPage = lazy(() => import('./pages/StudyPlanBlockPage'))
|
const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage'))
|
||||||
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
|
const ArticlePage = lazyPage(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
|
||||||
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
|
const PublicQuizPage = lazyPage(() => import('./pages/PublicQuizPage'))
|
||||||
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
|
const FlashcardStudyPage = lazyPage(() => import('./pages/FlashcardStudyPage'))
|
||||||
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
|
const CoursesPage = lazyPage(() => import('./pages/CoursesPage'))
|
||||||
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
|
const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage'))
|
||||||
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
|
const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage'))
|
||||||
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
|
const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage'))
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
return <div className="loading"><div className="spinner" /></div>
|
return <div className="loading"><div className="spinner" /></div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,47 @@
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) { .exam-switcher-label { display: none; } }
|
@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 ───────────────────────────────────────────────────── */
|
/* ── The dialog ───────────────────────────────────────────────────── */
|
||||||
.exo-overlay {
|
.exo-overlay {
|
||||||
position: fixed; inset: 0; z-index: 1100; display: flex;
|
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 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 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 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
|
/* Shown, so an educator can see it exists; not selectable, because choosing it
|
||||||
would scope the bank to nothing. */
|
would scope the bank to nothing. */
|
||||||
.exo-option.is-empty { opacity: 0.55; cursor: not-allowed; }
|
.exo-option.is-empty { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,40 @@ import './ExamSwitcher.css'
|
||||||
* and confirmed rather than applied the instant the pointer passes over an
|
* 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
|
* option. The choice is stored on the user, so it follows them between
|
||||||
* devices rather than living in this browser.
|
* 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 }) {
|
export default function ExamSwitcher({ onChange }) {
|
||||||
const [exams, setExams] = useState([])
|
const [exams, setExams] = useState([])
|
||||||
const [activeId, setActiveId] = useState(null)
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
const [menu, setMenu] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [chosen, setChosen] = useState(null)
|
const [chosen, setChosen] = useState(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const search = useRef(null)
|
const search = useRef(null)
|
||||||
|
const wrap = useRef(null)
|
||||||
|
|
||||||
const load = () => api.get('/exams/')
|
const load = () => api.get('/exams/')
|
||||||
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
|
.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(() => { 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined
|
if (!open) return undefined
|
||||||
setChosen(activeId)
|
setChosen(activeId)
|
||||||
|
|
@ -55,28 +91,73 @@ export default function ExamSwitcher({ onChange }) {
|
||||||
return [...groups.entries()]
|
return [...groups.entries()]
|
||||||
}, [exams, query])
|
}, [exams, query])
|
||||||
|
|
||||||
const save = async () => {
|
const apply = async (examId) => {
|
||||||
setBusy(true); setError('')
|
setBusy(true); setError('')
|
||||||
try {
|
try {
|
||||||
await api.put('/exams/active', { exam_id: chosen })
|
await api.put('/exams/active', { exam_id: examId })
|
||||||
setActiveId(chosen)
|
rememberRecent(examId)
|
||||||
|
setActiveId(examId)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onChange?.(chosen)
|
setMenu(false)
|
||||||
|
onChange?.(examId)
|
||||||
} catch { setError('Could not change your study objective') }
|
} catch { setError('Could not change your study objective') }
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exams.length === 0) return null
|
if (exams.length === 0) return null
|
||||||
const active = exams.find(e => e.id === activeId)
|
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 (
|
return (
|
||||||
<>
|
<div className="exam-switcher" ref={wrap}>
|
||||||
<button type="button" className="exam-switcher-button" onClick={() => setOpen(true)}>
|
<button type="button" className="exam-switcher-button" aria-haspopup="menu"
|
||||||
|
aria-expanded={menu} onClick={() => setMenu(v => !v)}>
|
||||||
<span className="exam-switcher-label">Studying for</span>
|
<span className="exam-switcher-label">Studying for</span>
|
||||||
<span className="exam-switcher-name">{active ? active.name : 'All content'}</span>
|
<span className="exam-switcher-name">{active ? active.name : 'Choose an objective'}</span>
|
||||||
<span aria-hidden="true">⌄</span>
|
<span aria-hidden="true">⌄</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{menu && (
|
||||||
|
<div className="exm" role="menu">
|
||||||
|
{active ? (
|
||||||
|
<div className="exm-current">
|
||||||
|
<small>Current objective</small>
|
||||||
|
<strong>{active.name}</strong>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="exm-current">
|
||||||
|
<small>No objective set</small>
|
||||||
|
<strong>Everything is in scope until you pick one</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="exm-heading">Recently</p>
|
||||||
|
{recent.map(exam => (
|
||||||
|
<button key={exam.id} type="button" className="exm-item" role="menuitem"
|
||||||
|
disabled={busy} onClick={() => apply(exam.id)}>
|
||||||
|
{exam.name}
|
||||||
|
<small>{exam.question_count} questions</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="exm-item is-more" role="menuitem"
|
||||||
|
onClick={() => { setMenu(false); setOpen(true) }}>
|
||||||
|
Choose a new study objective
|
||||||
|
</button>
|
||||||
|
{error && !open && <p className="exm-error" role="alert">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
|
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
|
||||||
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
|
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
|
||||||
|
|
@ -94,15 +175,9 @@ export default function ExamSwitcher({ onChange }) {
|
||||||
onChange={e => setQuery(e.target.value)} />
|
onChange={e => setQuery(e.target.value)} />
|
||||||
|
|
||||||
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
|
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
|
||||||
<label className={`exo-option is-all${chosen === null ? ' is-on' : ''}`}>
|
{/* There is no unscoped choice. Studying for nothing in
|
||||||
<input type="radio" name="objective" checked={chosen === null}
|
particular is not an objective, and the whole bank at once
|
||||||
onChange={() => setChosen(null)} />
|
makes the filters and the analysis mean less, not more. */}
|
||||||
<span>
|
|
||||||
<strong>All content</strong>
|
|
||||||
<small>Every question and article, unscoped.</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{families.map(([family, list]) => (
|
{families.map(([family, list]) => (
|
||||||
<section key={family}>
|
<section key={family}>
|
||||||
<h3 className="exo-family">{family}</h3>
|
<h3 className="exo-family">{family}</h3>
|
||||||
|
|
@ -133,12 +208,13 @@ export default function ExamSwitcher({ onChange }) {
|
||||||
{error && <p className="exo-error" role="alert">{error}</p>}
|
{error && <p className="exo-error" role="alert">{error}</p>}
|
||||||
<div className="exo-foot">
|
<div className="exo-foot">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
<button type="button" className="btn btn-primary" disabled={busy || chosen === activeId}
|
<button type="button" className="btn btn-primary"
|
||||||
onClick={save}>{busy ? 'Saving…' : 'Save changes'}</button>
|
disabled={busy || chosen === null || chosen === activeId}
|
||||||
|
onClick={() => apply(chosen)}>{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
frontend/src/components/ExamSwitcher.test.jsx
Normal file
55
frontend/src/components/ExamSwitcher.test.jsx
Normal file
|
|
@ -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(<ExamSwitcher />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/* Question authoring modals shared by the question bank and the question manager. */
|
/* 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 api from '../api/client'
|
||||||
import CategoryTree from './CategoryTree'
|
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']
|
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Link } from 'react-router-dom'
|
||||||
import RichText from './RichText'
|
import RichText from './RichText'
|
||||||
import QuestionReadingLinks from './QuestionReadingLinks'
|
import QuestionReadingLinks from './QuestionReadingLinks'
|
||||||
import { uploadUrl } from '../utils/uploads'
|
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.
|
* One question, shown whole, without leaving the page you found it on.
|
||||||
|
|
|
||||||
|
|
@ -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 { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ConfirmButton from '../components/ConfirmButton'
|
import ConfirmButton from '../components/ConfirmButton'
|
||||||
|
|
||||||
const RichEditor = lazy(() => import('../components/RichEditor'))
|
const RichEditor = lazyPage(() => import('../components/RichEditor'))
|
||||||
|
|
||||||
function stripHtml(html) {
|
function stripHtml(html) {
|
||||||
if (!html) return ''
|
if (!html) return ''
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { uploadUrl } from '../utils/uploads'
|
import { uploadUrl } from '../utils/uploads'
|
||||||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
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 { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import RichText from '../components/RichText'
|
import RichText from '../components/RichText'
|
||||||
import { mergeTextRanges } from '../utils/highlightOffsets'
|
import { mergeTextRanges } from '../utils/highlightOffsets'
|
||||||
|
|
@ -14,7 +15,7 @@ import '../components/Feedback.css'
|
||||||
import QuizTools, { QuizDialog } from '../components/QuizTools'
|
import QuizTools, { QuizDialog } from '../components/QuizTools'
|
||||||
import './QuizPlayer.css'
|
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 OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
||||||
const QUESTION_HIGHLIGHT_WORDS = 8
|
const QUESTION_HIGHLIGHT_WORDS = 8
|
||||||
|
|
@ -508,6 +509,11 @@ export default function QuizPage() {
|
||||||
}, [id, manualHighlights])
|
}, [id, manualHighlights])
|
||||||
|
|
||||||
const [leaveTarget, setLeaveTarget] = useState(null)
|
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 questions = quiz?.questions || []
|
||||||
const current = questions[currentIdx]
|
const current = questions[currentIdx]
|
||||||
const isStudy = quizMode === 'study'
|
const isStudy = quizMode === 'study'
|
||||||
|
|
@ -1240,7 +1246,7 @@ const timerStarted = timeLeft !== null
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
|
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(exitTarget())} title="Save progress and exit">
|
||||||
⏸ Suspend
|
⏸ Suspend
|
||||||
</button>
|
</button>
|
||||||
{restartConfirm ? (
|
{restartConfirm ? (
|
||||||
|
|
@ -1638,7 +1644,7 @@ const timerStarted = timeLeft !== null
|
||||||
scrolls inside it, rather than the whole page scrolling. */}
|
scrolls inside it, rather than the whole page scrolling. */}
|
||||||
<div className="quiz-footbar">
|
<div className="quiz-footbar">
|
||||||
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
|
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
|
||||||
onClick={() => setLeaveTarget(returnTo || '/')}>Exit session</button>
|
onClick={() => setLeaveTarget(exitTarget())}>Exit session</button>
|
||||||
{quizNavigation('bottom')}
|
{quizNavigation('bottom')}
|
||||||
{answeredCount > 0 && (
|
{answeredCount > 0 && (
|
||||||
<button className="btn btn-secondary btn-sm quiz-review-link"
|
<button className="btn btn-secondary btn-sm quiz-review-link"
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,16 @@ describe('quiz player', () => {
|
||||||
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
|
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('suspends onto the session analysis rather than the session list', async () => {
|
||||||
|
await begin(false)
|
||||||
|
fireEvent.keyDown(window, { key: '1' })
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
|
||||||
|
// Leaving a session part-way through should land where what you answered
|
||||||
|
// is scored and Resume sits — not on a list of every session you own.
|
||||||
|
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => {
|
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => {
|
||||||
await begin()
|
await begin()
|
||||||
fireEvent.keyDown(window, { key: '1' })
|
fireEvent.keyDown(window, { key: '1' })
|
||||||
|
|
|
||||||
48
frontend/src/utils/lazyPage.js
Normal file
48
frontend/src/utils/lazyPage.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { lazy } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lazily-loaded page that survives a deploy.
|
||||||
|
*
|
||||||
|
* Every build fingerprints the chunk filenames. A tab that is still holding
|
||||||
|
* the previous index.html asks for a file the new image does not contain, and
|
||||||
|
* the import fails with "Failed to fetch dynamically imported module" — which
|
||||||
|
* the user meets as a broken screen for a page that is perfectly fine.
|
||||||
|
*
|
||||||
|
* The fix is the new index.html, and one reload fetches it. Done once per tab:
|
||||||
|
* if the reload does not help, the failure is real and is shown rather than
|
||||||
|
* hidden behind a loop.
|
||||||
|
*/
|
||||||
|
const RELOAD_FLAG = 'pedshub.chunkReload'
|
||||||
|
|
||||||
|
const MISSING_CHUNK = /failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|dynamically imported module.*(404|not found)/i
|
||||||
|
|
||||||
|
export function isMissingChunk(err) {
|
||||||
|
return MISSING_CHUNK.test(`${err?.message || ''} ${err?.name || ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage is a convenience here; a browser that refuses it just never reloads.
|
||||||
|
const readFlag = () => {
|
||||||
|
try { return sessionStorage.getItem(RELOAD_FLAG) === '1' } catch { return false }
|
||||||
|
}
|
||||||
|
const writeFlag = (value) => {
|
||||||
|
try {
|
||||||
|
if (value) sessionStorage.setItem(RELOAD_FLAG, '1')
|
||||||
|
else sessionStorage.removeItem(RELOAD_FLAG)
|
||||||
|
} catch { /* private browsing, or storage turned off */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryOnStaleChunk(load, reload = () => window.location.reload()) {
|
||||||
|
return load()
|
||||||
|
.then(mod => { writeFlag(false); return mod })
|
||||||
|
.catch(err => {
|
||||||
|
if (!isMissingChunk(err) || readFlag()) throw err
|
||||||
|
writeFlag(true)
|
||||||
|
reload()
|
||||||
|
// Deliberately never settles: this document is on its way out.
|
||||||
|
return new Promise(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function lazyPage(load) {
|
||||||
|
return lazy(() => retryOnStaleChunk(load))
|
||||||
|
}
|
||||||
45
frontend/src/utils/lazyPage.test.js
Normal file
45
frontend/src/utils/lazyPage.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { isMissingChunk, retryOnStaleChunk } from './lazyPage'
|
||||||
|
|
||||||
|
describe('lazyPage', () => {
|
||||||
|
beforeEach(() => sessionStorage.clear())
|
||||||
|
|
||||||
|
it('recognises a chunk that a deploy has renamed away', () => {
|
||||||
|
expect(isMissingChunk(new Error(
|
||||||
|
'Failed to fetch dynamically imported module: https://pedshub.com/assets/QuestionManagerPage-C-Rd727e.js'
|
||||||
|
))).toBe(true)
|
||||||
|
// Safari words it differently.
|
||||||
|
expect(isMissingChunk(new TypeError('Importing a module script failed.'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an ordinary error alone', async () => {
|
||||||
|
const reload = vi.fn()
|
||||||
|
const boom = new TypeError('x is not a function')
|
||||||
|
await expect(retryOnStaleChunk(() => Promise.reject(boom), reload)).rejects.toBe(boom)
|
||||||
|
expect(reload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads once when the chunk is gone, then gives up', async () => {
|
||||||
|
const reload = vi.fn()
|
||||||
|
const stale = () => Promise.reject(new Error('Failed to fetch dynamically imported module: /assets/a.js'))
|
||||||
|
|
||||||
|
// First failure: fetch the new index rather than show a broken screen. The
|
||||||
|
// promise never settles, because the document is leaving.
|
||||||
|
let settled = false
|
||||||
|
retryOnStaleChunk(stale, reload).then(() => { settled = true }, () => { settled = true })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1)
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
|
||||||
|
// The reload did not help, so the failure is real and is shown.
|
||||||
|
await expect(retryOnStaleChunk(stale, reload)).rejects.toThrow(/dynamically imported/)
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forgets the reload once a chunk loads again', async () => {
|
||||||
|
const reload = vi.fn()
|
||||||
|
sessionStorage.setItem('pedshub.chunkReload', '1')
|
||||||
|
await retryOnStaleChunk(() => Promise.resolve({ default: 'page' }), reload)
|
||||||
|
expect(sessionStorage.getItem('pedshub.chunkReload')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue