fix: exam chrome you can read, a lab panel you can use, and Next that ends the block
The exam bar had a navy background and dark navy text on it, so the two numbers it exists for — which item, which block — were invisible, and the rail's own hide handle was a white disc with a white chevron inside it. The round arrows were drawn by the chrome while the labels still carried chevrons of their own, giving every control two. All of it is flush now: the player's page gutters were still there, framing a full-window exam in white. The block's tools are the block's. Labs moved to the bar across the top, so the copy of it under the stem is gone, and reading a paper aloud is not something an exam offers — Listen, Listen through and the highlight clear are study's. Next carries on being Next at the last item. There is nowhere further to go, so where it goes is out: it ends the block. Disabling it there left the hand that had pressed it four times with nothing under it. End Block stays where it is — two controls, one outcome, because what you reach for at the end of a block is not always what you have been pressing all the way through it. The lab panel was two components sharing one class name, so the list's layout leaked onto the column holding it and its controls — written for a dialog — came out as bare browser buttons. Now it is its own thing, wide enough that the reference range no longer runs off the right-hand edge, and stacked so every value is legible at any width. The disclaimer about ringing your own laboratory is gone; nobody sitting a board question is going to. And nobody edits the reference table from inside a session. An educator answering a question was one keystroke from changing a range every learner reads. It lives in Settings → Tools now. Also: the full-window session shell is claimed by the player rather than guessed from the URL — /study/42 is also the screen that asks whether to start one, and that screen is an ordinary card that was being left marooned in an empty window with its own navigation stripped. And "Multiple choice" above five lettered options labelled the obvious. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
b2a353cf58
commit
e2e558c294
12 changed files with 275 additions and 72 deletions
|
|
@ -2,6 +2,7 @@ import { Suspense, useEffect, useState } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import { SessionDrawerProvider } from './context/SessionDrawer'
|
import { SessionDrawerProvider } from './context/SessionDrawer'
|
||||||
|
import { SessionChromeProvider, useInSession } from './context/SessionChrome'
|
||||||
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'
|
||||||
|
|
@ -9,7 +10,6 @@ import ChooseObjective from './components/ChooseObjective'
|
||||||
import SearchOverlay from './components/SearchOverlay'
|
import SearchOverlay from './components/SearchOverlay'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
import lazyPage from './utils/lazyPage'
|
import lazyPage from './utils/lazyPage'
|
||||||
import { isSessionPath } from './utils/session'
|
|
||||||
|
|
||||||
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
|
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
|
||||||
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
|
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
|
||||||
|
|
@ -64,7 +64,7 @@ function AppLayout() {
|
||||||
// Keyed by path so navigating away from a broken page clears the error.
|
// Keyed by path so navigating away from a broken page clears the error.
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const inSession = isSessionPath(location.pathname)
|
const inSession = useInSession()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (event) => {
|
const onKey = (event) => {
|
||||||
|
|
@ -293,9 +293,9 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionDrawerProvider>
|
<SessionDrawerProvider><SessionChromeProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</SessionDrawerProvider>
|
</SessionChromeProvider></SessionDrawerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom'
|
||||||
import ScrollStrip from './ScrollStrip'
|
import ScrollStrip from './ScrollStrip'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||||
import { isSessionPath } from '../utils/session'
|
import { useInSession } from '../context/SessionChrome'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ExamSwitcher from './ExamSwitcher'
|
import ExamSwitcher from './ExamSwitcher'
|
||||||
import GlobalSearch from './GlobalSearch'
|
import GlobalSearch from './GlobalSearch'
|
||||||
|
|
@ -191,7 +191,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
|
||||||
const sessionDrawer = useSessionDrawer()
|
const sessionDrawer = useSessionDrawer()
|
||||||
const [jobs, setJobs] = useState([])
|
const [jobs, setJobs] = useState([])
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const inSession = isSessionPath(location.pathname)
|
const inSession = useInSession()
|
||||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
// Educators granted a category also manage questions, without a moderator role.
|
// Educators granted a category also manage questions, without a moderator role.
|
||||||
const [canManageQuestions, setCanManageQuestions] = useState(false)
|
const [canManageQuestions, setCanManageQuestions] = useState(false)
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,18 @@ const labFields = [
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Exported so the player can dock it beside a question instead of over it. */
|
/** Exported so the player can dock it beside a question instead of over it. */
|
||||||
export function LabValues() {
|
/**
|
||||||
|
* Reference ranges, as a lookup.
|
||||||
|
*
|
||||||
|
* `editable` is off everywhere it is opened from a session. An educator
|
||||||
|
* sitting a block was being offered an "Edit values" switch beside the
|
||||||
|
* question they were answering — a control that changes what every learner on
|
||||||
|
* the site sees, one keystroke from a stem they were reading. Maintaining the
|
||||||
|
* table is a settings job and lives in Settings → Tools.
|
||||||
|
*/
|
||||||
|
export function LabValues({ editable = false }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isEducator = ['admin', 'moderator'].includes(user?.role)
|
const isEducator = editable && ['admin', 'moderator'].includes(user?.role)
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [group, setGroup] = useState('All')
|
const [group, setGroup] = useState('All')
|
||||||
|
|
@ -117,7 +126,6 @@ export function LabValues() {
|
||||||
const groups = ['All', ...new Set(rows.map(row => row.group))]
|
const groups = ['All', ...new Set(rows.map(row => row.group))]
|
||||||
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
|
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
|
||||||
return <>
|
return <>
|
||||||
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.</p>
|
|
||||||
{/* "Manage references and drafts" said nothing useful: "references" here
|
{/* "Manage references and drafts" said nothing useful: "references" here
|
||||||
means lab values, not citations, and "drafts" means unpublished ones.
|
means lab values, not citations, and "drafts" means unpublished ones.
|
||||||
This is an editing switch inside a lookup, so it says so. */}
|
This is an editing switch inside a lookup, so it says so. */}
|
||||||
|
|
@ -210,7 +218,7 @@ export default function QuizTools({ tool, onClose }) {
|
||||||
const title = { calculator: 'Calculator', labs: 'Lab values', shortcuts: 'Keyboard shortcuts' }[tool]
|
const title = { calculator: 'Calculator', labs: 'Lab values', shortcuts: 'Keyboard shortcuts' }[tool]
|
||||||
if (!title) return null
|
if (!title) return null
|
||||||
return <QuizDialog title={title} onClose={onClose}>
|
return <QuizDialog title={title} onClose={onClose}>
|
||||||
{tool === 'calculator' ? <Calculator /> : tool === 'labs' ? <LabValues /> : <>
|
{tool === 'calculator' ? <Calculator /> : tool === 'labs' ? <LabValues editable={false} /> : <>
|
||||||
<p>Shortcuts pause while typing or using a dialog.</p>
|
<p>Shortcuts pause while typing or using a dialog.</p>
|
||||||
<table><thead><tr><th>Action</th><th>Key</th></tr></thead><tbody>{shortcuts.map(([action, key]) => <tr key={action}><td>{action}</td><td><kbd>{key}</kbd></td></tr>)}</tbody></table>
|
<table><thead><tr><th>Action</th><th>Key</th></tr></thead><tbody>{shortcuts.map(([action, key]) => <tr key={action}><td>{action}</td><td><kbd>{key}</kbd></td></tr>)}</tbody></table>
|
||||||
</>}
|
</>}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import QuizTools from './QuizTools'
|
import QuizTools, { LabValues } from './QuizTools'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
||||||
|
|
@ -37,6 +37,13 @@ function renderLabs() {
|
||||||
return render(<MemoryRouter><QuizTools tool="labs" onClose={() => {}} /></MemoryRouter>)
|
return render(<MemoryRouter><QuizTools tool="labs" onClose={() => {}} /></MemoryRouter>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Editing is not offered from a session — an educator answering a question was
|
||||||
|
// one keystroke from changing a range every learner reads. It lives in
|
||||||
|
// Settings → Tools, which renders the same component with `editable`.
|
||||||
|
function renderLabEditor() {
|
||||||
|
return render(<MemoryRouter><LabValues editable /></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
describe('lab values panel', () => {
|
describe('lab values panel', () => {
|
||||||
it('groups values by panel and keeps card links, without a sources footer', async () => {
|
it('groups values by panel and keeps card links, without a sources footer', async () => {
|
||||||
renderLabs()
|
renderLabs()
|
||||||
|
|
@ -51,9 +58,15 @@ describe('lab values panel', () => {
|
||||||
expect(screen.queryByRole('link', { name: 'PediRounds' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('link', { name: 'PediRounds' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets an educator link and unlink a card per reference', async () => {
|
it('offers no way to edit the table from inside a session', async () => {
|
||||||
renderLabs()
|
renderLabs()
|
||||||
await screen.findByRole('heading', { name: 'CSF' })
|
await screen.findByRole('heading', { name: 'CSF' })
|
||||||
|
expect(screen.queryByRole('button', { name: 'Edit values' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets an educator link and unlink a card per reference, where it is editable', async () => {
|
||||||
|
renderLabEditor()
|
||||||
|
await screen.findByRole('heading', { name: 'CSF' })
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Edit values' }))
|
await userEvent.click(screen.getByRole('button', { name: 'Edit values' }))
|
||||||
const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section')
|
const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section')
|
||||||
const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count')
|
const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count')
|
||||||
|
|
|
||||||
44
frontend/src/context/SessionChrome.jsx
Normal file
44
frontend/src/context/SessionChrome.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a session player is actually on screen.
|
||||||
|
*
|
||||||
|
* This used to be read off the URL — `/study/…` meant "in a session" — which
|
||||||
|
* is wrong at both ends of one. `/study/42` is also the screen that asks
|
||||||
|
* whether you want to start, and that screen is an ordinary card that wants
|
||||||
|
* the page's gutters and its section bar; stripping them left it marooned in
|
||||||
|
* the middle of an empty window. The player is the thing that needs the whole
|
||||||
|
* window, so the player is what says so.
|
||||||
|
*
|
||||||
|
* Counted rather than flagged, so a player unmounting as another mounts — a
|
||||||
|
* route change from one attempt straight into another — cannot leave the shell
|
||||||
|
* stuck in the wrong state.
|
||||||
|
*/
|
||||||
|
const Context = createContext({ inSession: false, enter: () => () => {} })
|
||||||
|
|
||||||
|
export function SessionChromeProvider({ children }) {
|
||||||
|
const [depth, setDepth] = useState(0)
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
inSession: depth > 0,
|
||||||
|
enter: () => {
|
||||||
|
setDepth(n => n + 1)
|
||||||
|
return () => setDepth(n => Math.max(0, n - 1))
|
||||||
|
},
|
||||||
|
}), [depth])
|
||||||
|
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read by the shell and the navbar. */
|
||||||
|
export const useInSession = () => useContext(Context).inSession
|
||||||
|
|
||||||
|
/** Called by a player while it is mounted. */
|
||||||
|
export function useClaimSessionChrome(active = true) {
|
||||||
|
const { enter } = useContext(Context)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return undefined
|
||||||
|
return enter()
|
||||||
|
// `enter` is stable in intent but changes identity with the depth it sets;
|
||||||
|
// depending on it here would release and re-take on every change.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active])
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { mergeTextRanges } from '../utils/highlightOffsets'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import useMediaQuery from '../hooks/useMediaQuery'
|
import useMediaQuery from '../hooks/useMediaQuery'
|
||||||
|
import { useClaimSessionChrome } from '../context/SessionChrome'
|
||||||
import useAwayDetector from '../hooks/useAwayDetector'
|
import useAwayDetector from '../hooks/useAwayDetector'
|
||||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||||
import FigureStrip from '../components/FigureStrip'
|
import FigureStrip from '../components/FigureStrip'
|
||||||
|
|
@ -428,6 +429,9 @@ export default function QuizPage() {
|
||||||
// 1150px breakpoint in QuizPlayer.css that hides the rail.
|
// 1150px breakpoint in QuizPlayer.css that hides the rail.
|
||||||
const sessionDrawer = useSessionDrawer()
|
const sessionDrawer = useSessionDrawer()
|
||||||
const hasRail = useMediaQuery('(min-width: 1151px)')
|
const hasRail = useMediaQuery('(min-width: 1151px)')
|
||||||
|
// Only once a session is actually being sat. The screen that asks
|
||||||
|
// whether to start one is an ordinary card and wants the ordinary page.
|
||||||
|
useClaimSessionChrome(!!quizMode)
|
||||||
|
|
||||||
// The site's burger opens this session's questions while there is no rail.
|
// The site's burger opens this session's questions while there is no rail.
|
||||||
// Only while there is no rail: on a desktop the list is already beside the
|
// Only while there is no rail: on a desktop the list is already beside the
|
||||||
|
|
@ -1366,6 +1370,18 @@ const timerStarted = timeLeft !== null
|
||||||
// Block — belongs to a block being sat. Study mode never had any of it, and
|
// Block — belongs to a block being sat. Study mode never had any of it, and
|
||||||
// a closed block has nothing left to pause or hand in.
|
// a closed block has nothing left to pause or hand in.
|
||||||
const examChrome = !isStudy && !reviewing
|
const examChrome = !isStudy && !reviewing
|
||||||
|
/**
|
||||||
|
* Ending the block.
|
||||||
|
*
|
||||||
|
* In an exam the dialog names how many items are still unanswered before
|
||||||
|
* anything is handed in — the one warning worth giving. A study session with
|
||||||
|
* everything answered has nothing left to warn about, so it finishes rather
|
||||||
|
* than asking a question whose answer is already known.
|
||||||
|
*/
|
||||||
|
const endBlock = () => (isStudy && answeredCount >= totalCount
|
||||||
|
? handleSubmit(false)
|
||||||
|
: setShowReview(true))
|
||||||
|
|
||||||
const quizNavigation = (position = 'bottom') => (
|
const quizNavigation = (position = 'bottom') => (
|
||||||
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||||||
<button className="btn btn-secondary"
|
<button className="btn btn-secondary"
|
||||||
|
|
@ -1377,27 +1393,33 @@ const timerStarted = timeLeft !== null
|
||||||
site's own menu button — repeating them here crowded the one row
|
site's own menu button — repeating them here crowded the one row
|
||||||
that has to stay legible at the foot of every question. */}
|
that has to stay legible at the foot of every question. */}
|
||||||
|
|
||||||
{isLast && !reviewing ? (
|
{/* Skip and Next are not the same decision. Moving on from a question
|
||||||
// The end of the block. In an exam the dialog names how many are still
|
you have not answered is a choice, and the button says which one it
|
||||||
// unanswered before anything is handed in — the warning worth giving.
|
is rather than calling both of them Next. In review everything is
|
||||||
// A study session with everything answered has nothing left to warn
|
already marked, so there is nothing to skip — it is only a way
|
||||||
// about, so it finishes rather than asking twice.
|
through.
|
||||||
<button className="btn btn-primary" disabled={submitting}
|
|
||||||
onClick={() => (isStudy && answeredCount >= totalCount
|
On the last question Next carries on being Next. There is nowhere
|
||||||
? handleSubmit(false)
|
further to go, so where it goes is out: it ends the block, which is
|
||||||
: setShowReview(true))}>
|
what "next" means at the end of a paper. Disabling it there left the
|
||||||
|
hand that had pressed it four times with nothing under it. */}
|
||||||
|
<button className="btn btn-primary" disabled={submitting}
|
||||||
|
onClick={() => (isLast && !reviewing
|
||||||
|
? endBlock()
|
||||||
|
: safeNavigate(Math.min(totalCount - 1, currentIdx + 1)))}
|
||||||
|
title={isLast && !reviewing
|
||||||
|
? (isStudy ? 'Finish this session' : 'End this block') : undefined}>
|
||||||
|
{reviewing || answers[current?.id] || isLast ? 'Next →' : 'Skip →'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* And the end is still its own button, named for what it does. Two
|
||||||
|
controls, one outcome, because the one you reach for at the end of a
|
||||||
|
block is not always the one you have been pressing all the way
|
||||||
|
through it. */}
|
||||||
|
{isLast && !reviewing && (
|
||||||
|
<button className="btn btn-secondary" disabled={submitting} onClick={endBlock}>
|
||||||
{isStudy ? 'Finish session' : 'End block'}
|
{isStudy ? 'Finish session' : 'End block'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
/* Skip and Next are not the same decision. Moving on from a question
|
|
||||||
you have not answered is a choice, and the button says which one it
|
|
||||||
is rather than calling both of them Next. In review everything is
|
|
||||||
already marked, so there is nothing to skip — it is only a way
|
|
||||||
through. */
|
|
||||||
<button className="btn btn-primary" disabled={isLast}
|
|
||||||
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
|
|
||||||
{reviewing || answers[current?.id] ? 'Next →' : 'Skip →'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -1721,11 +1743,14 @@ const timerStarted = timeLeft !== null
|
||||||
sits in the middle with the count between the two arrows
|
sits in the middle with the count between the two arrows
|
||||||
rather than tucked in beside the tools. */}
|
rather than tucked in beside the tools. */}
|
||||||
<div className="quiz-item-nav">
|
<div className="quiz-item-nav">
|
||||||
|
{/* No chevron in the text: the round arrow above each label
|
||||||
|
is the chevron, and printing a second one beside it read
|
||||||
|
as two controls stuck together. */}
|
||||||
<button type="button" disabled={currentIdx === 0}
|
<button type="button" disabled={currentIdx === 0}
|
||||||
onClick={() => safeNavigate(currentIdx - 1)}>‹ Previous</button>
|
onClick={() => safeNavigate(currentIdx - 1)}>Previous</button>
|
||||||
<span className="quiz-item-count">{currentIdx + 1} / {totalCount}</span>
|
<span className="quiz-item-count">{currentIdx + 1} / {totalCount}</span>
|
||||||
<button type="button" disabled={isLast}
|
<button type="button" disabled={submitting}
|
||||||
onClick={() => safeNavigate(currentIdx + 1)}>Next ›</button>
|
onClick={() => (isLast ? endBlock() : safeNavigate(currentIdx + 1))}>Next</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : hasRail ? (
|
) : hasRail ? (
|
||||||
|
|
@ -1782,9 +1807,15 @@ const timerStarted = timeLeft !== null
|
||||||
{answerRevealed && current.difficulty && (
|
{answerRevealed && current.difficulty && (
|
||||||
<span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>
|
<span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>
|
||||||
)}
|
)}
|
||||||
<span className="quiz-meta-pill">
|
{/* Only when it is not the ordinary kind. A pill reading
|
||||||
{current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
|
"Multiple choice" above five lettered options is a label
|
||||||
</span>
|
for something already obvious; True/False and a blank to
|
||||||
|
type in are worth saying because they change what you do. */}
|
||||||
|
{current.question_type && current.question_type !== 'mcq' && (
|
||||||
|
<span className="quiz-meta-pill">
|
||||||
|
{current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* A stem carrying a lab table cannot live inside a heading —
|
{/* A stem carrying a lab table cannot live inside a heading —
|
||||||
the table would be invalid markup there — so the heading is the
|
the table would be invalid markup there — so the heading is the
|
||||||
|
|
@ -1803,11 +1834,16 @@ const timerStarted = timeLeft !== null
|
||||||
{/* Beside the tip rather than up in the toolbar: reference
|
{/* Beside the tip rather than up in the toolbar: reference
|
||||||
ranges are read against the case in front of you, and a
|
ranges are read against the case in front of you, and a
|
||||||
control at the top of the screen is a different place from
|
control at the top of the screen is a different place from
|
||||||
where the numbers are. */}
|
where the numbers are.
|
||||||
<button type="button" className={labsOpen ? 'is-on' : ''}
|
|
||||||
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
|
Except in an exam, where the chrome across the top is that
|
||||||
⚗ <span>Labs</span>
|
control and repeating it here is the same button twice. */}
|
||||||
</button>
|
{!examChrome && (
|
||||||
|
<button type="button" className={labsOpen ? 'is-on' : ''}
|
||||||
|
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
|
||||||
|
⚗ <span>Labs</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{current.attending_tip && (
|
{current.attending_tip && (
|
||||||
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
||||||
aria-pressed={panel === 'tip'}
|
aria-pressed={panel === 'tip'}
|
||||||
|
|
@ -1845,7 +1881,7 @@ const timerStarted = timeLeft !== null
|
||||||
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
|
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
|
||||||
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
|
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
|
||||||
</button>
|
</button>
|
||||||
{voices.length > 0 && (
|
{voices.length > 0 && !examChrome && (
|
||||||
<TTSButton
|
<TTSButton
|
||||||
key={`${current.id}_${selectedVoice || 'default'}`}
|
key={`${current.id}_${selectedVoice || 'default'}`}
|
||||||
text={buildQuestionSpeechText(current, currentIdx)}
|
text={buildQuestionSpeechText(current, currentIdx)}
|
||||||
|
|
@ -1867,7 +1903,7 @@ const timerStarted = timeLeft !== null
|
||||||
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
|
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{voices.length > 0 && (
|
{voices.length > 0 && !examChrome && (
|
||||||
<button type="button" className={readThrough ? 'is-on' : ''}
|
<button type="button" className={readThrough ? 'is-on' : ''}
|
||||||
onClick={() => setReadThrough(v => !v)}
|
onClick={() => setReadThrough(v => !v)}
|
||||||
title="Read each question aloud and advance automatically">
|
title="Read each question aloud and advance automatically">
|
||||||
|
|
@ -2163,13 +2199,13 @@ const timerStarted = timeLeft !== null
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{labsOpen && (
|
{labsOpen && (
|
||||||
<aside className="quiz-labs" aria-label="Lab values">
|
<aside className="quiz-labpanel" aria-label="Lab values">
|
||||||
<div className="quiz-labs-head">
|
<div className="quiz-labpanel-head">
|
||||||
<strong>Lab values</strong>
|
<strong>Lab values</strong>
|
||||||
<button type="button" aria-label="Close lab values"
|
<button type="button" aria-label="Close lab values"
|
||||||
onClick={() => setLabsOpen(false)}>✕</button>
|
onClick={() => setLabsOpen(false)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="quiz-labs-body"><LabValues /></div>
|
<div className="quiz-labpanel-body"><LabValues /></div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,9 @@ describe('quiz player', () => {
|
||||||
// "hard" narrows the answer before the stem has been read, so it waits
|
// "hard" narrows the answer before the stem has been read, so it waits
|
||||||
// until the answer is in.
|
// until the answer is in.
|
||||||
expect(within(meta).queryByText('hard')).not.toBeInTheDocument()
|
expect(within(meta).queryByText('hard')).not.toBeInTheDocument()
|
||||||
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
|
// And "Multiple choice" is not said at all: every question is, and a pill
|
||||||
|
// repeating it above five lettered options labels the obvious.
|
||||||
|
expect(within(meta).queryByText('Multiple choice')).not.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.keyDown(window, { key: '1' })
|
fireEvent.keyDown(window, { key: '1' })
|
||||||
expect(await within(meta).findByText('hard')).toBeInTheDocument()
|
expect(await within(meta).findByText('hard')).toBeInTheDocument()
|
||||||
|
|
@ -693,7 +695,11 @@ describe('quiz player', () => {
|
||||||
await userEvent.click(within(nav).getByRole('button', { name: /Next/ }))
|
await userEvent.click(within(nav).getByRole('button', { name: /Next/ }))
|
||||||
await findStem('Full second clinical question.')
|
await findStem('Full second clinical question.')
|
||||||
expect(document.querySelector('.quiz-block-meta').textContent).toContain('Item: 2 of 2')
|
expect(document.querySelector('.quiz-block-meta').textContent).toContain('Item: 2 of 2')
|
||||||
expect(within(document.querySelector('.quiz-item-nav')).getByRole('button', { name: /Next/ })).toBeDisabled()
|
// Next stays live at the last item and ends the block, which is what
|
||||||
|
// "next" means at the end of a paper. End Block is still its own control.
|
||||||
|
await userEvent.click(within(document.querySelector('.quiz-item-nav')).getByRole('button', { name: /Next/ }))
|
||||||
|
expect(screen.getByRole('dialog', { name: /This block is incomplete/ })).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Remain in Block' }))
|
||||||
// The tools kept their end of the bar.
|
// The tools kept their end of the bar.
|
||||||
expect(within(document.querySelector('.quiz-top-actions')).getByRole('button', { name: 'Lab values' })).toBeInTheDocument()
|
expect(within(document.querySelector('.quiz-top-actions')).getByRole('button', { name: 'Lab values' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -552,8 +552,11 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
||||||
Both are optional columns; the question takes whatever they leave. */
|
Both are optional columns; the question takes whatever they leave. */
|
||||||
.quiz-player .quiz-layout.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
.quiz-player .quiz-layout.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
||||||
.quiz-player .quiz-layout.is-rail-closed .quiz-sidebar { display: none; }
|
.quiz-player .quiz-layout.is-rail-closed .quiz-sidebar { display: none; }
|
||||||
.quiz-player .quiz-layout.has-labs { grid-template-columns: 260px minmax(0, 1fr) minmax(300px, 360px); }
|
/* Wide enough for the whole row. At 360px the reference range — the one
|
||||||
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); }
|
column anybody opens this for — ran off the right-hand edge and there was
|
||||||
|
nothing to scroll sideways to reach it. */
|
||||||
|
.quiz-player .quiz-layout.has-labs { grid-template-columns: 260px minmax(0, 1fr) minmax(360px, 440px); }
|
||||||
|
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); }
|
||||||
|
|
||||||
.quiz-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
.quiz-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
.quiz-rail-hide, .quiz-rail-reopen {
|
.quiz-rail-hide, .quiz-rail-reopen {
|
||||||
|
|
@ -565,16 +568,61 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
||||||
/* Sits where the rail was, so putting it back is where you last saw it. */
|
/* Sits where the rail was, so putting it back is where you last saw it. */
|
||||||
.quiz-rail-reopen { align-self: start; margin-top: 4px; }
|
.quiz-rail-reopen { align-self: start; margin-top: 4px; }
|
||||||
|
|
||||||
.quiz-labs {
|
.quiz-labpanel {
|
||||||
display: flex; flex-direction: column; min-height: 0;
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
border-left: 1px solid var(--border); background: var(--card-bg);
|
border-left: 1px solid var(--border); background: var(--card-bg);
|
||||||
}
|
}
|
||||||
.quiz-labs-head {
|
.quiz-labpanel-head {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none;
|
padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none;
|
||||||
}
|
}
|
||||||
.quiz-labs-head button { border: 0; background: none; cursor: pointer; color: var(--text-muted); font-size: 0.95rem; }
|
.quiz-labpanel-head button { border: 0; background: none; cursor: pointer; color: var(--text-muted); font-size: 0.95rem; }
|
||||||
.quiz-labs-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; }
|
.quiz-labpanel-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; }
|
||||||
|
|
||||||
|
/* The reference list was written for a dialog, where `.quiz-tool-body` gives
|
||||||
|
its controls a size and a shape. Docked beside the question it had neither,
|
||||||
|
so the group filters came out as bare browser buttons crammed edge to edge
|
||||||
|
and the search box was a default input with a Refresh beside it running off
|
||||||
|
the panel. Same controls, same look, in the column. */
|
||||||
|
.quiz-labpanel-body .quiz-reference-controls { align-items: stretch; gap: 8px; }
|
||||||
|
.quiz-labpanel-body .quiz-reference-controls label {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
font-size: .72rem; font-weight: 600; letter-spacing: .04em;
|
||||||
|
text-transform: uppercase; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.quiz-labpanel-body input:not([type=checkbox]) {
|
||||||
|
width: 100%; padding: 8px 10px; font: inherit; font-size: .85rem;
|
||||||
|
color: var(--text); background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border); border-radius: 7px;
|
||||||
|
}
|
||||||
|
.quiz-labpanel-body button {
|
||||||
|
padding: 7px 11px; font: inherit; font-size: .8rem; cursor: pointer;
|
||||||
|
color: var(--text); background: var(--bg);
|
||||||
|
border: 1px solid var(--border); border-radius: 7px;
|
||||||
|
}
|
||||||
|
.quiz-labpanel-body button:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.quiz-labpanel-body button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* The group filters are a row of pills, and they wrap rather than squeeze. */
|
||||||
|
.quiz-labpanel-body .quiz-tool-tabs { gap: 5px; margin: 12px 0; }
|
||||||
|
.quiz-labpanel-body .quiz-tool-tabs button { padding: 5px 10px; font-size: .75rem; border-radius: 999px; }
|
||||||
|
.quiz-labpanel-body .quiz-tool-tabs button[aria-pressed=true] {
|
||||||
|
background: var(--primary); border-color: var(--primary); color: #fff;
|
||||||
|
}
|
||||||
|
.quiz-labpanel-body .quiz-reference-note { margin: 0 0 12px; font-size: .78rem; line-height: 1.5; }
|
||||||
|
.quiz-labpanel-body .quiz-reference-scroll { max-height: none; overflow-x: visible; }
|
||||||
|
|
||||||
|
/* One column, not two. The dialog is wide enough to put the test and its range
|
||||||
|
side by side; this column is not, and the half that got squeezed was the
|
||||||
|
range. Stacked, every value is legible at any panel width, and the list
|
||||||
|
scrolls rather than the row clipping. */
|
||||||
|
.quiz-labpanel-body .quiz-lab-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding: 10px 12px; row-gap: 4px;
|
||||||
|
}
|
||||||
|
.quiz-labpanel-body .quiz-lab-age-line { grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; }
|
||||||
|
.quiz-labpanel-body .quiz-lab-range { white-space: normal; overflow-wrap: anywhere; text-align: right; }
|
||||||
|
.quiz-labpanel-body .quiz-lab-values { width: 100%; }
|
||||||
|
|
||||||
@media (max-width: 1150px) {
|
@media (max-width: 1150px) {
|
||||||
/* The rail is a drawer down here, so its handle has nothing to reopen. */
|
/* The rail is a drawer down here, so its handle has nothing to reopen. */
|
||||||
|
|
@ -582,7 +630,7 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
||||||
/* No room for a third column; the labs go back to being a dialog. */
|
/* No room for a third column; the labs go back to being a dialog. */
|
||||||
.quiz-player .quiz-layout.has-labs,
|
.quiz-player .quiz-layout.has-labs,
|
||||||
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr); }
|
||||||
.quiz-labs { display: none; }
|
.quiz-labpanel { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -596,8 +644,14 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
||||||
.quiz-player.is-exam-chrome {
|
.quiz-player.is-exam-chrome {
|
||||||
position: fixed; inset: 0; z-index: 400;
|
position: fixed; inset: 0; z-index: 400;
|
||||||
height: 100dvh; max-height: 100dvh;
|
height: 100dvh; max-height: 100dvh;
|
||||||
|
/* Flush. The player's own 24/32/40 gutters are for a page inside a site;
|
||||||
|
this is the whole window, and a white margin around a navy bar reads as a
|
||||||
|
dialog somebody forgot to close. */
|
||||||
|
padding: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
.quiz-player.is-exam-chrome .quiz-layout { gap: 0; }
|
||||||
|
.quiz-player.is-exam-chrome .quiz-main > *:not(.quiz-topbar) { padding: 0 24px 24px; }
|
||||||
body:has(.quiz-player.is-exam-chrome) .navbar,
|
body:has(.quiz-player.is-exam-chrome) .navbar,
|
||||||
body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
||||||
/* The title, the mode pill and the progress bar are the session list's way of
|
/* The title, the mode pill and the progress bar are the session list's way of
|
||||||
|
|
@ -620,7 +674,8 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
||||||
font-size: .82rem; line-height: 1.35; color: #fff;
|
font-size: .82rem; line-height: 1.35; color: #fff;
|
||||||
background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .35);
|
background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .35);
|
||||||
}
|
}
|
||||||
.quiz-player.is-exam-chrome .quiz-block-meta strong { font-weight: 700; }
|
.quiz-player.is-exam-chrome .quiz-block-meta strong { color: #fff; font-weight: 700; }
|
||||||
|
.quiz-player.is-exam-chrome .quiz-item-count { color: #fff; }
|
||||||
|
|
||||||
.quiz-player.is-exam-chrome .quiz-item-nav {
|
.quiz-player.is-exam-chrome .quiz-item-nav {
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 14px;
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 14px;
|
||||||
|
|
@ -655,7 +710,22 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
||||||
|
|
||||||
/* The rail is the exam's question-status list: numbers and how each stands,
|
/* The rail is the exam's question-status list: numbers and how each stands,
|
||||||
without the excerpt a study session shows. */
|
without the excerpt a study session shows. */
|
||||||
.quiz-player.is-exam-chrome .quiz-rail { background: var(--exam-bar); border: 0; border-radius: 0; }
|
.quiz-player.is-exam-chrome .quiz-rail {
|
||||||
|
padding: 14px 12px; background: var(--exam-bar); border: 0; border-radius: 0;
|
||||||
|
}
|
||||||
|
.quiz-player.is-exam-chrome .quiz-rail-hide {
|
||||||
|
background: transparent; border-color: rgba(255, 255, 255, .55); color: #fff;
|
||||||
|
}
|
||||||
|
.quiz-player.is-exam-chrome .quiz-rail-hide:hover {
|
||||||
|
background: rgba(255, 255, 255, .18); border-color: #fff; color: #fff;
|
||||||
|
}
|
||||||
|
/* And the handle that brings it back, which sits on the navy where the rail
|
||||||
|
was rather than on the page. */
|
||||||
|
.quiz-player.is-exam-chrome .quiz-rail-reopen {
|
||||||
|
margin: 10px 0 0 10px;
|
||||||
|
background: var(--exam-bar); border-color: var(--exam-bar); color: #fff;
|
||||||
|
}
|
||||||
|
.quiz-player.is-exam-chrome .quiz-rail-reopen:hover { background: #14304f; border-color: #14304f; }
|
||||||
.quiz-player.is-exam-chrome .quiz-rail,
|
.quiz-player.is-exam-chrome .quiz-rail,
|
||||||
.quiz-player.is-exam-chrome .quiz-rail-head span,
|
.quiz-player.is-exam-chrome .quiz-rail-head span,
|
||||||
.quiz-player.is-exam-chrome .quiz-rail-item { color: #eef2f8; }
|
.quiz-player.is-exam-chrome .quiz-rail-item { color: #eef2f8; }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||||
|
import { useClaimSessionChrome } from '../context/SessionChrome'
|
||||||
import useMediaQuery from '../hooks/useMediaQuery'
|
import useMediaQuery from '../hooks/useMediaQuery'
|
||||||
import QuizTools, { LabValues } from '../components/QuizTools'
|
import QuizTools, { LabValues } from '../components/QuizTools'
|
||||||
import FigureStrip from '../components/FigureStrip'
|
import FigureStrip from '../components/FigureStrip'
|
||||||
|
|
@ -45,6 +46,7 @@ export default function ResultsPage() {
|
||||||
const [responseStats, setResponseStats] = useState(null)
|
const [responseStats, setResponseStats] = useState(null)
|
||||||
const [navOpen, setNavOpen] = useState(false)
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
const hasRail = useMediaQuery('(min-width: 1151px)')
|
const hasRail = useMediaQuery('(min-width: 1151px)')
|
||||||
|
useClaimSessionChrome(true)
|
||||||
// On a phone the site's burger opens this attempt's questions, exactly as it
|
// On a phone the site's burger opens this attempt's questions, exactly as it
|
||||||
// does while one is being sat — the rail has nowhere to live at that width,
|
// does while one is being sat — the rail has nowhere to live at that width,
|
||||||
// and a second hamburger for the same list is not an answer.
|
// and a second hamburger for the same list is not an answer.
|
||||||
|
|
@ -273,12 +275,12 @@ export default function ResultsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{labsOpen && (
|
{labsOpen && (
|
||||||
<aside className="quiz-labs" aria-label="Lab values">
|
<aside className="quiz-labpanel" aria-label="Lab values">
|
||||||
<div className="quiz-labs-head">
|
<div className="quiz-labpanel-head">
|
||||||
<strong>Lab values</strong>
|
<strong>Lab values</strong>
|
||||||
<button type="button" aria-label="Close lab values" onClick={() => setLabsOpen(false)}>✕</button>
|
<button type="button" aria-label="Close lab values" onClick={() => setLabsOpen(false)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="quiz-labs-body"><LabValues /></div>
|
<div className="quiz-labpanel-body"><LabValues /></div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,9 @@
|
||||||
.tools-counts .is-pending { background: var(--option-sel-bg); color: var(--primary); }
|
.tools-counts .is-pending { background: var(--option-sel-bg); color: var(--primary); }
|
||||||
.tools-counts .is-accepted { background: var(--correct-bg); color: var(--correct-fg); }
|
.tools-counts .is-accepted { background: var(--correct-bg); color: var(--correct-fg); }
|
||||||
.tools-counts .is-rejected { background: var(--bg); color: var(--text-subtle); }
|
.tools-counts .is-rejected { background: var(--bg); color: var(--text-subtle); }
|
||||||
|
|
||||||
|
|
||||||
|
/* The reference table inside Tools. It is the same component the session
|
||||||
|
shows, so it brings its own layout; this only gives it room to breathe and
|
||||||
|
somewhere to scroll when the list is long. */
|
||||||
|
.tools-labs { margin-top: 14px; max-height: 60vh; overflow-y: auto; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import './ToolsPage.css'
|
||||||
import BackLink from '../components/BackLink'
|
import BackLink from '../components/BackLink'
|
||||||
|
|
||||||
const DraftBatch = lazy(() => import('../components/DraftBatch'))
|
const DraftBatch = lazy(() => import('../components/DraftBatch'))
|
||||||
|
// Only loaded when the section is opened: the whole reference table and its
|
||||||
|
// editing form are a lot to ship to a page that is mostly about PDFs.
|
||||||
|
const LabValues = lazy(() => import('../components/QuizTools').then(m => ({ default: m.LabValues })))
|
||||||
|
|
||||||
const when = value => (value
|
const when = value => (value
|
||||||
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
|
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
@ -27,6 +30,7 @@ export default function ToolsPage() {
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
const [showDone, setShowDone] = useState(false)
|
const [showDone, setShowDone] = useState(false)
|
||||||
|
const [labsOpen, setLabsOpen] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -152,6 +156,31 @@ export default function ToolsPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{['admin', 'moderator'].includes(user?.role) && (
|
||||||
|
<section className="tools-section">
|
||||||
|
<div className="tools-section-head">
|
||||||
|
<h2>Lab reference values</h2>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setLabsOpen(v => !v)}>
|
||||||
|
{labsOpen ? 'Close' : 'Open the table'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="tools-note">
|
||||||
|
{/* It used to be editable from inside a session, where an educator
|
||||||
|
answering a question was one keystroke from changing a range
|
||||||
|
every learner reads. Looking one up during a block is a lookup;
|
||||||
|
maintaining the table is this. */}
|
||||||
|
The ranges learners see in the lab panel during a session. Editing
|
||||||
|
here changes what everybody sees.
|
||||||
|
</p>
|
||||||
|
{labsOpen && (
|
||||||
|
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
|
||||||
|
<div className="tools-labs"><LabValues editable /></div>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<section className="tools-section">
|
<section className="tools-section">
|
||||||
<div className="tools-section-head">
|
<div className="tools-section-head">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/**
|
|
||||||
* Which routes are "inside a session".
|
|
||||||
*
|
|
||||||
* Sitting a block and reading one back are the same screen with different
|
|
||||||
* things on it, and both want the whole window: no section bar, no centred
|
|
||||||
* column, and on a phone a burger that opens the question list rather than the
|
|
||||||
* site menu. Two files decided this separately and disagreed the moment review
|
|
||||||
* became a player, so they now ask here.
|
|
||||||
*/
|
|
||||||
export const isSessionPath = (pathname = '') =>
|
|
||||||
pathname.startsWith('/study/') || pathname.startsWith('/results/')
|
|
||||||
Loading…
Reference in a new issue