chore: remove QuestionEditModal

Nothing had imported it since editing moved to the full page; the only thing
that still referred to it was its own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 08:18:12 +02:00
parent 1d55b76998
commit 1df520c95d
11 changed files with 673 additions and 475 deletions

View file

@ -271,13 +271,8 @@ Captured so nothing is lost while the article writing runs.
MediaAsset already stores one path per image, so a `thumb_path` beside it
plus a Caddy cache rule is the shape.
- [ ] **QuestionEditModal is dead code** — nothing has imported it since Edit
moved to the full page. Its sibling CreateQuestionModal was removed when
its last two callers were replaced; this one was already unreferenced, so
it is left for a deliberate decision rather than swept up.
## Quiz runner
- [x] **QuestionEditModal is dead code** — removed 2026-09-12, with its
test. Nothing had imported it since Edit moved to the full page.
- [x] **Per-question notes** — done. The notes themselves were already built
(`question_notes`, saved on blur); what was missing was removing the
global notes tab that floated over the same screen, so it was never clear

View file

@ -2,7 +2,7 @@ import { Suspense, useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { SessionDrawerProvider } from './context/SessionDrawer'
import { SessionChromeProvider, useInSession } from './context/SessionChrome'
import { SessionChromeProvider, useFullBleed, useInSession } from './context/SessionChrome'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
@ -65,6 +65,7 @@ function AppLayout() {
const location = useLocation()
const [searching, setSearching] = useState(false)
const inSession = useInSession()
const fullBleed = useFullBleed()
useEffect(() => {
const onKey = (event) => {
@ -98,8 +99,9 @@ function AppLayout() {
{/* A session takes the window. The page's own gutters and 1200px cap
are for reading; a player is a fixed-height box whose columns scroll
inside it, and every pixel the container reserves is a pixel the
question does not get. */}
<div className={`app-main${inSession ? ' is-session' : ' container'}`}>
question does not get. An article asks for the same width without
the rest of the session chrome hence two classes rather than one. */}
<div className={`app-main${fullBleed ? ' is-bleed' : ' container'}${inSession ? ' is-session' : ''}`}>
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import RichText from './RichText'
/**
* Article prose goes through the same renderer as everything else.
@ -15,9 +15,17 @@ export function Markdown({ children, attemptId }) {
* An article as it reads: contents rail, summary, and sections that expand
* where they sit.
*
* One renderer serves both the page and the second pane beside it, so a
* cross-reference opened in split view behaves like the article it came from
* rather than like a cut-down copy of it.
* The reading page hands the window to this component the rail meets the
* left edge and the prose takes everything to the right of it. Capping the
* page at 1080px and centring it left a thin ribbon of text in the middle of a
* wide screen with the rail floating somewhere in the margin, which is the
* complaint this layout answers.
*
* `bare` is the second article read beside the first. That pane used to render
* the whole of this component, rail and all, inside a column half the width of
* the page: two contents lists and two scrollbars for one paragraph of prose,
* each cross-reference pushing the text further right. A pane is a column to
* read, not a page to navigate, so it gets the prose and nothing else.
*
* `activeSection` is the caller's, because the page keeps it in the URL for deep
* links and the pane keeps it to itself; everything else what is open, the
@ -36,17 +44,77 @@ const VIEWS = [
{ key: 'clinical', label: 'Clinical' },
]
export default function ArticleReader({ article, activeSection = '', onOpenSection, idPrefix = '', landmark = true, children }) {
// Remembered rather than reset on every article, the way the session player
// remembers its own rail: hiding the contents is a statement about how you
// read, not about the page you happened to be on when you said it.
const RAIL_KEY = 'pedshub.articleRail'
const SIZE_KEY = 'pedshub.articleText'
const SIZES = ['s', 'm', 'l']
const stored = (key, fallback) => {
try { return localStorage.getItem(key) ?? fallback } catch { return fallback }
}
const remember = (key, value) => {
try { localStorage.setItem(key, value) } catch { /* private browsing: the choice lasts the visit */ }
}
/**
* How far down the window the sticky furniture has to start.
*
* Measured off the header rather than written down as a number, because the
* navbar's section strip collapses as you scroll and a hard-coded offset would
* either leave a band of page showing above the rail or hide the first line of
* it behind the bar. A second copy of the navbar's scroll logic would only be
* a guess about somebody else's component; its height is the fact itself.
*/
function useHeaderOffset() {
const [top, setTop] = useState(0)
useLayoutEffect(() => {
if (typeof document === 'undefined') return undefined
const bar = document.querySelector('.navbar')
if (!bar) return undefined
const measure = () => setTop(Math.round(bar.getBoundingClientRect().height))
measure()
if (typeof ResizeObserver === 'undefined') return undefined
const observer = new ResizeObserver(measure)
observer.observe(bar)
return () => observer.disconnect()
}, [])
return top
}
export default function ArticleReader({
article, activeSection = '', onOpenSection, idPrefix = '', landmark = true,
// No rail, no bar, no chrome: the reader used as a second column.
bare = false,
// The trail back to the library, and the article's own icon toolbar. Owned
// by the page because publishing and editing are the page's business, drawn
// here because they belong above the title rather than above the layout.
breadcrumbs = null, toolbar = null,
// A second column to the right of the prose, inside the same grid so the
// rail stays flush left and the pane can never push the article sideways.
aside = null,
children,
}) {
// Which sections are open. Everything starts closed: an article is a reference
// you consult, and a wall of prose hides the one heading you came for.
const [openIds, setOpenIds] = useState({})
const [drawerOpen, setDrawerOpen] = useState(false)
// Collapsing the contents rail hands its width to the prose.
const [railOpen, setRailOpen] = useState(true)
const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed')
const [size, setSize] = useState(() => (SIZES.includes(stored(SIZE_KEY, 'm')) ? stored(SIZE_KEY, 'm') : 'm'))
// The section under the reader's eye, which is not the same thing as the one
// they last clicked: the bar names where you are, so it has to follow the
// scroll rather than the last deep link.
const [reading, setReading] = useState('')
const root = useRef(null)
const headerTop = useHeaderOffset()
// A page has one main landmark, so the second reader on it is not one.
const Content = landmark ? 'main' : 'div'
useEffect(() => { remember(RAIL_KEY, railOpen ? 'open' : 'closed') }, [railOpen])
useEffect(() => { remember(SIZE_KEY, size) }, [size])
// Older articles have no variant on their sections; they are the full article.
const everySection = (article.sections || []).map(
sec => ({ ...sec, variant: sec.variant || 'long' }))
@ -59,10 +127,12 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
// space which read as the view having no content at all.
useEffect(() => {
const tops = allSections.filter(sec => !sec.parent_id)
if (tops.length === 1) {
// A pane has no contents list to navigate by, so a wall of chevrons there
// is a wall of dead ends: the cross-reference opens as prose.
if (bare || tops.length === 1) {
setOpenIds(Object.fromEntries(allSections.map(sec => [sec.id, true])))
}
}, [view, article])
}, [view, article, bare])
// Element ids are prefixed because two readers can share a page, and a
// duplicate id would point the pane's contents at the article behind it.
const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`)
@ -108,12 +178,40 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
// shown as prose rather than as a contents page of one entry.
const soleSection = topSections.length === 1 && allSections.length === 1 ? topSections[0] : null
/**
* Which heading the reader is under.
*
* The bar names it once the title has scrolled away, and the rail marks the
* same one a rail pointing at a heading you left three screens ago is
* worse than a rail pointing at nothing.
*/
const openKey = Object.keys(openIds).filter(id => openIds[id]).sort().join(',')
useEffect(() => {
if (bare || typeof IntersectionObserver === 'undefined') return undefined
const nodes = [...(root.current?.querySelectorAll('[data-section-id]') || [])]
if (!nodes.length) return undefined
const seen = new Map()
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => seen.set(entry.target, entry))
const live = [...seen.values()].filter(entry => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
// Nothing in the band means the reader is above the first heading or
// below the last; either way the previous answer is still the best one.
if (live.length) setReading(live[0].target.dataset.sectionId)
}, { rootMargin: `-${headerTop + 56}px 0px -62% 0px` })
nodes.forEach(node => observer.observe(node))
return () => observer.disconnect()
}, [article, view, openKey, headerTop, bare])
const marked = reading || activeSection
const readingTitle = allSections.find(sec => sec.id === reading)?.title || ''
const renderSection = (sec, depth) => {
const isOpen = !!openIds[sec.id]
const kids = kidsOf(sec.id)
const Heading = depth === 0 ? 'h2' : 'h3'
return (
<section key={sec.id} id={`section-${idPrefix}${sec.id}`}
<section key={sec.id} id={`section-${idPrefix}${sec.id}`} data-section-id={sec.id}
className={`asec asec-depth-${depth}${activeSection === sec.id ? ' is-target' : ''}${isReferences(sec) ? ' is-references' : ''}`}>
<Heading className="asec-heading">
<button type="button" className="asec-head" aria-expanded={isOpen}
@ -132,115 +230,191 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
)
}
return (
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}`} ref={root}>
<button className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)}
aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
{drawerOpen ? '✕ Close sections' : '☰ Sections'}
</button>
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
<button type="button" className="article-rail-toggle" aria-expanded={railOpen}
aria-label={railOpen ? 'Collapse contents' : 'Show contents'}
onClick={() => setRailOpen(v => !v)}>
<span aria-hidden="true">{railOpen ? '' : ''}</span>
const prose = (
<>
{/* The summary is prose, so it is rendered as prose. Printed raw it put
`[[288|eczema]]` in front of the reader on 58 articles the syntax
an educator writes a cross-reference in, shown to the person it was
written for. */}
{article.summary && <RichText className="article-summary" value={article.summary} linkArticles />}
{article.content && <Markdown>{article.content}</Markdown>}
{!allSections.length && (!article.summary && !article.content) && (
<div className="empty-state">Content is being prepared by educators.</div>
)}
<div className="asec-list">
{/* A view with one section does not need a heading over it, or a
control to collapse the only thing there is. The tab already
names it "Short" then a heading reading "In short" says the
same word twice and hides the content behind a chevron. */}
{soleSection ? (
<div className="asec-sole" id={`section-${idPrefix}${soleSection.id}`}>
<Markdown>{soleSection.content}</Markdown>
</div>
) : topSections.map(sec => renderSection(sec, 0))}
</div>
{(article.references || []).length > 0 && (
<section className="article-references">
<h2>References</h2>
{/* Sources for the whole article, not markers in the prose: a
learner checking a claim wants the book and the page, and a
sentence peppered with superscripts is harder to read. */}
<ol>
{article.references.map((ref, index) => (
<li key={`${ref.title}-${index}`}>
<span className="article-ref-title">{ref.title}</span>
{ref.author && <span className="article-ref-author"> {ref.author}</span>}
{(ref.pages || []).length > 0 && (
<span className="article-ref-pages"> · p. {ref.pages.join(', ')}</span>
)}
</li>
))}
</ol>
</section>
)}
{children}
</>
)
// A pane is a column of reading. Everything the page around it already
// provides the rail, the controls, the title bar would be a second copy
// in half the width.
if (bare) {
return (
<div className={`article-bare article-text-${size}`} ref={root}>{prose}</div>
)
}
/**
* How much of the topic to read, where AMBOSS puts the same choice: in the
* row above the sections, at the right. It used to sit below the summary
* with the expand-all button, which is inside the article rather than over
* it and the rail is filtered by the same choice, so a reader who cannot
* see the switch cannot explain why half the contents just went away.
*/
const viewSwitch = present.length > 1 && (
<div className="aview-switch" role="tablist" aria-label="How much of this topic to read">
{present.map(option => (
<button key={option.key} type="button" role="tab"
aria-selected={view === option.key}
className={`aview${view === option.key ? ' is-active' : ''}`}
onClick={() => { setView(option.key); setOpenIds({}); setReading('') }}>
{option.label}
</button>
<h4>{article.title}</h4>
{/* A contents list of one entry is not a contents list. */}
{!soleSection && (
<ul className="atoc">
{topSections.map(sec => (
<li key={sec.id}>
<button className={activeSection === sec.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(sec.id)}>
{sec.title}
</button>
{/* A sub-section is listed under its parent, not alongside it,
so the contents show the shape of the article. */}
{kidsOf(sec.id).length > 0 && (
<ul className="atoc-sub">
{kidsOf(sec.id).map(kid => (
<li key={kid.id}>
<button className={activeSection === kid.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(kid.id)}>
{kid.title}
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
))}
</div>
)
return (
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}${aside ? ' has-aside' : ''}`}
ref={root} style={{ '--article-top': `${headerTop}px` }}>
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
<div className="article-rail-scroll">
<div className="article-rail-head">
<h4>{article.title}</h4>
{/* On the boundary between the rail and the prose, which is where
the reader is looking when they decide they want the width. */}
<button type="button" className="article-rail-hide" aria-expanded={railOpen}
aria-label="Collapse contents" onClick={() => setRailOpen(false)}>
<span aria-hidden="true"></span>
</button>
</div>
{/* A contents list of one entry is not a contents list. */}
{!soleSection && (
<ul className="atoc">
{topSections.map(sec => (
<li key={sec.id}>
<button className={marked === sec.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(sec.id)}>
{sec.title}
</button>
{/* A sub-section is listed under its parent, not alongside it,
so the contents show the shape of the article. */}
{kidsOf(sec.id).length > 0 && (
<ul className="atoc-sub">
{kidsOf(sec.id).map(kid => (
<li key={kid.id}>
<button className={marked === kid.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(kid.id)}>
{kid.title}
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</div>
</aside>
<Content className="article-content">
{/* Where the rail was, so putting it back is where you last saw it. */}
{!railOpen && (
<button type="button" className="article-rail-reopen" aria-expanded={railOpen}
aria-label="Show contents" onClick={() => setRailOpen(true)}>
<span aria-hidden="true"></span>
</button>
)}
<Content className={`article-content article-text-${size}`}>
<div className="article-top">
<div className="article-trail">{breadcrumbs}</div>
<div className="article-tools">
{toolbar}
{/* Three steps, not a slider: a reader wants bigger or smaller,
and a control with twelve answers to that is a control you
have to think about mid-paragraph. */}
<button type="button" className="article-tool"
aria-label={`Text size: ${{ s: 'small', m: 'medium', l: 'large' }[size]}`}
onClick={() => setSize(SIZES[(SIZES.indexOf(size) + 1) % SIZES.length])}>
<span aria-hidden="true">A</span><span aria-hidden="true" className="article-tool-a">a</span>
</button>
</div>
</div>
{article.updated_at && (
<p className="article-updated">Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}</p>
)}
{article.summary && <p className="article-summary">{article.summary}</p>}
{article.content && <Markdown>{article.content}</Markdown>}
{!allSections.length && (!article.summary && !article.content) && (
<div className="empty-state">Content is being prepared by educators.</div>
)}
<h1 className="article-title">
{article.title}
{/* Drawn here rather than passed in, because a draft badge belongs
to the title and the title now lives with the reader. */}
{article.status && article.status !== 'published' && (
<span className="article-status-draft">Draft</span>
)}
</h1>
{/* Headings first, prose on request: the article opens as a contents
page you can scan, and each section expands where it sits rather
than in a modal that loses the thread. */}
than in a modal that loses the thread. The row sticks under the
header, so the controls stay put and the trail can say where in the
article you have got to once the title has gone. */}
<div className="asec-controls">
{/* Only the views this article actually has: an empty tab is a
promise the article cannot keep. */}
{present.length > 1 && (
<div className="aview-switch" role="tablist" aria-label="How to read this article">
{present.map(option => (
<button key={option.key} type="button" role="tab"
aria-selected={view === option.key}
className={`aview${view === option.key ? ' is-active' : ''}`}
onClick={() => { setView(option.key); setOpenIds({}) }}>
{option.label}
</button>
))}
</div>
)}
{allSections.length > 0 && !soleSection && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
{allOpen ? 'Collapse all' : 'Expand all'}
<div className="asec-controls-left">
<button type="button" className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)}
aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
{drawerOpen ? '✕ Close contents' : '☰ Contents'}
</button>
)}
{readingTitle && (
<span className="asec-where">
<span className="asec-where-article">{article.title}</span>
<span aria-hidden="true"> </span>
<span className="asec-where-section">{readingTitle}</span>
</span>
)}
</div>
<div className="asec-controls-right">
{allSections.length > 0 && !soleSection && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
{allOpen ? 'Collapse all' : 'Expand all'}
</button>
)}
{/* Only the views this article actually has: an empty tab is a
promise the article cannot keep. */}
{viewSwitch}
</div>
</div>
<div className="asec-list">
{/* A view with one section does not need a heading over it, or a
control to collapse the only thing there is. The tab already
names it "Short" then a heading reading "In short" says the
same word twice and hides the content behind a chevron. */}
{soleSection ? (
<div className="asec-sole" id={`section-${idPrefix}${soleSection.id}`}>
<Markdown>{soleSection.content}</Markdown>
</div>
) : topSections.map(sec => renderSection(sec, 0))}
</div>
{(article.references || []).length > 0 && (
<section className="article-references">
<h2>References</h2>
{/* Sources for the whole article, not markers in the prose: a
learner checking a claim wants the book and the page, and a
sentence peppered with superscripts is harder to read. */}
<ol>
{article.references.map((ref, index) => (
<li key={`${ref.title}-${index}`}>
<span className="article-ref-title">{ref.title}</span>
{ref.author && <span className="article-ref-author"> {ref.author}</span>}
{(ref.pages || []).length > 0 && (
<span className="article-ref-pages"> · p. {ref.pages.join(', ')}</span>
)}
</li>
))}
</ol>
</section>
)}
{children}
{prose}
</Content>
{aside}
</div>
)
}

View file

@ -0,0 +1,45 @@
/* Keeping a topic: the star in the article's toolbar, and the panel behind it. */
.asave { position: relative; display: inline-flex; }
.asave-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 40;
width: min(280px, calc(100vw - 32px));
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(15, 23, 42, .16);
text-align: left;
}
.asave-title { margin: 0 0 2px; font-size: .84rem; font-weight: 700; }
.asave-note, .asave-empty { margin: 0 0 8px; font-size: .76rem; color: var(--text-muted); line-height: 1.45; }
.asave-empty { margin-bottom: 0; }
.asave-find {
width: 100%; padding: 7px 9px; margin-bottom: 8px;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: .84rem;
}
.asave-find:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.asave-new {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
width: 100%; margin-bottom: 8px; padding: 8px 10px;
border: 1px dashed var(--border); border-radius: 8px; background: none;
font: inherit; font-size: .84rem; color: var(--text); cursor: pointer;
}
.asave-new:hover { border-color: var(--primary); color: var(--primary); }
.asave-new span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asave-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 210px; overflow-y: auto; }
.asave-list button {
width: 100%; text-align: left; padding: 7px 9px;
border: 0; border-radius: 7px; background: none;
font: inherit; font-size: .84rem; color: var(--text); cursor: pointer;
}
.asave-list button:hover { background: var(--option-hover, #eef4fb); }
.asave-list button.is-in { color: var(--text-muted); cursor: default; }

View file

@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client'
import './ArticleSaveButton.css'
/**
* Keep this topic.
*
* Libraries hold questions `user_collection_questions` has a question_id and
* nothing else so saving a topic saves the questions written against it,
* which is what you would want it for: the reading and the practice on one
* subject, filed together, ready to sit later. Nothing here invents a second
* place to save things; it is the same endpoint the session player writes to
* when you file a question mid-attempt.
*
* The consequence is that the server cannot be asked "is this article saved?",
* only "is this question in that library?", and answering it properly would
* mean fetching every library's contents on every article which also stamps
* each one as used and reorders the reader's own list. So the answer is kept
* on the device that gave it. It is honest about a save that happened; it will
* not know about one made on another machine. An `article_id` on the join
* table would settle it, and is the right fix when the API can change.
*/
const SAVED_KEY = 'pedshub.articleLibraries'
const readSaved = () => {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '{}') } catch { return {} }
}
const writeSaved = (next) => {
try { localStorage.setItem(SAVED_KEY, JSON.stringify(next)) } catch { /* the visit still knows */ }
}
export default function ArticleSaveButton({ articleId, questionIds = [] }) {
const [open, setOpen] = useState(false)
const [libraries, setLibraries] = useState([])
const [query, setQuery] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(() => readSaved()[String(articleId)] || [])
const wrap = useRef(null)
useEffect(() => { setSaved(readSaved()[String(articleId)] || []) }, [articleId])
useEffect(() => {
if (!open) return
api.get('/collections/').then(res => setLibraries(Array.isArray(res.data) ? res.data : []))
.catch(() => setLibraries([]))
}, [open])
// A popover that outlives the click that dismissed it is a popover you have
// to close twice.
useEffect(() => {
if (!open) return undefined
const away = (event) => { if (!wrap.current?.contains(event.target)) setOpen(false) }
document.addEventListener('mousedown', away)
return () => document.removeEventListener('mousedown', away)
}, [open])
const record = useCallback((collectionId) => {
setSaved(prev => {
if (prev.includes(collectionId)) return prev
const next = [...prev, collectionId]
writeSaved({ ...readSaved(), [String(articleId)]: next })
return next
})
}, [articleId])
const saveInto = async (collectionId) => {
setBusy(true)
try {
// Sequential rather than parallel: a topic with forty questions firing
// forty writes at once is a burst the rate limiter reads as abuse.
for (const questionId of questionIds) {
await api.put(`/collections/${collectionId}/questions/${questionId}`)
}
record(collectionId)
setQuery('')
} catch { /* nothing is recorded, which is the honest signal */ }
finally { setBusy(false) }
}
const createAndSave = async (title) => {
const name = title.trim()
if (!name) return
setBusy(true)
try {
const made = await api.post('/collections/', { title: name })
setLibraries(list => [...list, made.data])
setBusy(false)
await saveInto(made.data.id)
} catch { setBusy(false) }
}
const needle = query.trim().toLowerCase()
const found = libraries.filter(row => row.title.toLowerCase().includes(needle))
// Offered only when nothing you already have is called this two libraries
// of the same name is a filing system nobody can use.
const canCreate = !!needle && !libraries.some(row => row.title.trim().toLowerCase() === needle)
const isSaved = saved.length > 0
return (
<span className="asave" ref={wrap}>
<button type="button" className={`article-tool${isSaved ? ' is-on' : ''}`}
aria-expanded={open} aria-haspopup="dialog"
aria-label={isSaved ? `Saved to ${saved.length} librar${saved.length === 1 ? 'y' : 'ies'}` : 'Save this topic to a library'}
onClick={() => setOpen(v => !v)}>
<span aria-hidden="true">{isSaved ? '★' : '☆'}</span>
</button>
{open && (
<div className="asave-pop" role="dialog" aria-label="Save this topic to a library">
<p className="asave-title">Save this topic</p>
{questionIds.length === 0 ? (
/* Said plainly rather than offering a control that would file
nothing: a library holds questions, and this topic has none yet. */
<p className="asave-empty">No questions are linked to this topic yet, so there is nothing to file. Link one and it can be saved with the reading.</p>
) : (
<>
<p className="asave-note">Files its {questionIds.length} linked question{questionIds.length === 1 ? '' : 's'} so you can sit them later.</p>
{/* One box for both. Searching what you have and naming what you
do not are the same act you type the name of the library
you want and it either exists or it doesn't. */}
<input className="asave-find" value={query} disabled={busy}
placeholder="Create or find a library" aria-label="Create or find a library"
onChange={event => setQuery(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && canCreate) { event.preventDefault(); createAndSave(query) }
}} />
{canCreate && (
<button type="button" className="asave-new" disabled={busy} onClick={() => createAndSave(query)}>
<span>{query.trim()}</span><span aria-hidden="true">+</span>
</button>
)}
{found.length > 0 && (
<ul className="asave-list">
{found.map(row => {
const inIt = saved.includes(row.id)
return (
<li key={row.id}>
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt || busy}
onClick={() => saveInto(row.id)}>
{inIt ? '✓ ' : '+ '}{row.title}
</button>
</li>
)
})}
</ul>
)}
{!found.length && !canCreate && (
<p className="asave-empty">{libraries.length ? 'No library by that name.' : 'Type a name to make your first library.'}</p>
)}
</>
)}
</div>
)}
</span>
)
}

View file

@ -11,18 +11,28 @@ import './ArticleSplitPane.css'
* The point of split view is that the article you came from stays put, so this
* pane carries only the reading no comments, no practice, no editing and
* the full article is one click away in the heading for anything more.
*
* It is one column of a grid the reading page already owns, not a second page
* nested inside the first. It used to render the whole reader, contents rail
* included, into a box half the width of the screen: two rails, two
* scrollbars, and prose that started further right with every cross-reference
* followed. A fixed column and a `bare` reader mean the article on the left
* reflows once, when the pane opens, and never again however deep you go.
*
* Depth is a trail rather than a stack of panes. Following a reference from
* inside the pane replaces what is in it and offers the way back, which is the
* thing that was actually wanted: read the aside, then return to the sentence
* that sent you.
*/
export default function ArticleSplitPane({ slug, onClose }) {
export default function ArticleSplitPane({ slug, onClose, onBack, depth = 0 }) {
const [article, setArticle] = useState(null)
const [error, setError] = useState('')
const [activeSection, setActiveSection] = useState('')
const pane = useRef(null)
useEffect(() => {
let live = true
setArticle(null)
setError('')
setActiveSection('')
// The slug's id has already been fetched by the preview card that offered
// the split, so opening the pane costs one request, not two.
resolveArticleId(slug)
@ -42,6 +52,12 @@ export default function ArticleSplitPane({ slug, onClose }) {
aria-label={article ? `Split view: ${article.title}` : 'Split view'}
onKeyDown={event => { if (event.key === 'Escape') onClose() }}>
<header className="asplit-head">
{depth > 0 && (
<button type="button" className="asplit-back" onClick={onBack}
aria-label="Back to the previous cross-reference">
<span aria-hidden="true"></span>
</button>
)}
<Link className="asplit-title" to={`/articles/s/${slug}`}>{article ? article.title : 'Opening…'}</Link>
<button type="button" className="asplit-close" onClick={onClose} aria-label="Close split view">
<span aria-hidden="true"></span>
@ -50,8 +66,7 @@ export default function ArticleSplitPane({ slug, onClose }) {
<div className="asplit-body">
{error ? <div className="empty-state">{error}</div>
: !article ? <div className="loading"><div className="spinner" /></div>
: <ArticleReader article={article} idPrefix="pane-" landmark={false}
activeSection={activeSection} onOpenSection={setActiveSection} />}
: <ArticleReader article={article} idPrefix="pane-" landmark={false} bare />}
</div>
</aside>
)

View file

@ -1,203 +0,0 @@
/* Question authoring modals shared by the question bank and the question manager. */
import { useState, useEffect, Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import api from '../api/client'
import CategoryTree from './CategoryTree'
const RichEditor = lazyPage(() => import('../components/RichEditor'))
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
function apiError(err, fallback) {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map(item => typeof item?.msg === 'string' ? item.msg : '').filter(Boolean).join('; ') || fallback
return fallback
}
export function QuestionEditModal({ question, categories, onSaved, onClose }) {
const [form, setForm] = useState({
question_text: question.question_text,
question_type: question.question_type,
options: question.options ? [...question.options] : [],
correct_answer: question.correct_answer,
explanation: question.explanation || '',
question_category_id: question.question_category_id || '',
extraCategoryIds: (question.category_ids || []).filter(id => id !== (question.question_category_id || null)),
option_explanations: { ...(question.option_explanations || {}) },
key_points: (question.key_points || []).map(p => ({ text: p.text, article_id: p.article_id || '', article_section_id: p.article_section_id || '' })),
difficulty: question.difficulty || '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
const oldValue = updated[i]
updated[i] = val
setForm(f => {
const explanations = { ...f.option_explanations }
if (oldValue in explanations) {
explanations[val] = explanations[oldValue]
delete explanations[oldValue]
}
return { ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer, option_explanations: explanations }
})
}
const setKeyPoint = (index, field, value) => {
setForm(f => ({ ...f, key_points: f.key_points.map((p, i) => i === index ? { ...p, [field]: value } : p) }))
}
const setOptionExplanation = (option, value) => {
setForm(f => {
const explanations = { ...f.option_explanations }
if (value.trim()) explanations[option] = value
else delete explanations[option]
return { ...f, option_explanations: explanations }
})
}
const toggleExtra = (categoryId) => {
setForm(f => ({
...f,
extraCategoryIds: f.extraCategoryIds.includes(categoryId)
? f.extraCategoryIds.filter(id => id !== categoryId)
: [...f.extraCategoryIds, categoryId],
}))
}
const save = async () => {
if (!form.question_text.trim()) return setError('Question text is required')
if (form.options.length > 0 && !form.options.includes(form.correct_answer))
return setError('Correct answer must match one of the options')
setSaving(true); setError('')
try {
const primary = form.question_category_id ? parseInt(form.question_category_id) : null
const payload = {
...form,
question_category_id: primary,
additional_category_ids: form.extraCategoryIds.filter(id => id !== primary),
option_explanations: Object.keys(form.option_explanations).length ? form.option_explanations : null,
key_points: form.key_points.filter(p => p.text.trim()).map(p => ({
text: p.text.trim(),
article_id: p.article_id ? parseInt(p.article_id, 10) : null,
article_section_id: p.article_section_id || null,
})),
difficulty: form.difficulty || null,
}
const res = await api.patch(`/questions/${question.id}`, payload)
onSaved({ ...question, ...res.data,
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
onClose()
} catch (err) { setError(apiError(err, 'Save failed')) }
finally { setSaving(false) }
}
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div role="dialog" aria-label="Edit Question" style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginBottom: 16 }}>
This question is shared changes apply to all quizzes using it.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
{form.options.length > 0 && (
<div className="form-group">
<label>Options select the correct one</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="correct" checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</div>
<details open={!!form.option_explanations[opt]} style={{ marginLeft: 32 }}>
<summary style={{ fontSize: '0.74rem', color: 'var(--text-muted)', cursor: 'pointer' }}>
Explain this option{form.option_explanations[opt] ? ' ✓' : ''}
</summary>
<textarea rows={2} placeholder="Why this option is wrong or right — shown in study mode"
value={form.option_explanations[opt] || ''} aria-label={`Explanation for option ${opt}`}
onChange={e => setOptionExplanation(opt, e.target.value)}
style={{ width: '100%', marginTop: 4, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</details>
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Categories</label>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
Primary category drives the main listing; additional categories place the question in every selected branch.
</p>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
<option value=""> Uncategorized </option>
{categories.map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}</option>)}
</select>
<CategoryTree
categories={categories}
selectedIds={form.extraCategoryIds}
excludedId={form.question_category_id ? parseInt(form.question_category_id) : null}
onToggle={toggleExtra}
/>
</div>
<div className="form-group">
<label>Key points (smart links to reading)</label>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
Short take-aways shown in study feedback; link a point to an article or a section to open it directly.
</p>
{form.key_points.map((point, i) => (
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
<input type="text" value={point.text} placeholder="Key point" aria-label={`Key point ${i + 1}`}
onChange={e => setKeyPoint(i, 'text', e.target.value)}
style={{ flex: 2, minWidth: 160, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<input type="text" value={point.article_id} placeholder="Article ID" aria-label={`Key point ${i + 1} article`}
onChange={e => setKeyPoint(i, 'article_id', e.target.value)}
style={{ width: 90, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<input type="text" value={point.article_section_id} placeholder="Section ID" aria-label={`Key point ${i + 1} section`}
onChange={e => setKeyPoint(i, 'article_section_id', e.target.value)}
style={{ width: 110, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({ ...f, key_points: f.key_points.filter((_, j) => j !== i) }))}></button>
</div>
))}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({ ...f, key_points: [...f.key_points, { text: '', article_id: '', article_section_id: '' }] }))}>+ Add key point</button>
</div>
<div className="form-group">
<label>Difficulty</label>
<select value={form.difficulty || ''} onChange={e => setForm(f => ({ ...f, difficulty: e.target.value || null }))}>
<option value=""> Not set </option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<div className="form-group">
<label>Explanation</label>
<textarea rows={6} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}

View file

@ -1,62 +0,0 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QuestionEditModal } from './QuestionEditors'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
const CATS = [
{ id: 1, name: 'Neonatology', parent_id: null, breadcrumbs: [{ id: 1, name: 'Neonatology' }] },
{ id: 2, name: 'Cardiology', parent_id: null, breadcrumbs: [{ id: 2, name: 'Cardiology' }] },
{ id: 3, name: 'Renal', parent_id: null, breadcrumbs: [{ id: 3, name: 'Renal' }] },
]
const question = (overrides = {}) => ({
id: 7, question_text: 'Edit me', question_type: 'mcq', options: ['A', 'B'],
correct_answer: 'A', explanation: '', question_category_id: 1, category_ids: [1], ...overrides,
})
// The quick modal the question manager uses for a small correction; the full
// page at /questions/:id is where a question is edited properly.
const mount = (q = question()) => render(
<MemoryRouter>
<QuestionEditModal question={q} categories={CATS} onSaved={() => {}} onClose={() => {}} />
</MemoryRouter>)
describe('quick question edit', () => {
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: [] })
api.patch.mockResolvedValue({ data: {} })
})
it('saves additional subcategories and excludes the primary from extras', async () => {
mount(question({ category_ids: [1, 2] }))
const modal = await screen.findByRole('dialog', { name: 'Edit Question' })
expect(within(modal).getByLabelText(/Cardiology/)).toBeChecked()
expect(within(modal).getByLabelText(/Renal/)).not.toBeChecked()
// The primary category cannot also be an extra, so it is not offered as one.
expect(within(modal).getByLabelText(/Neonatology/)).toBeDisabled()
await userEvent.click(within(modal).getByLabelText(/Renal/))
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
additional_category_ids: [2, 3],
question_category_id: 1,
})))
})
it('saves per-option explanations keyed by option text', async () => {
mount()
await screen.findByRole('dialog', { name: 'Edit Question' })
await userEvent.click(screen.getAllByText('Explain this option')[0])
await userEvent.type(screen.getByLabelText('Explanation for option A'), 'Right because of this')
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
option_explanations: { A: 'Right because of this' },
})))
})
})

View file

@ -36,6 +36,23 @@ const expandWikiLinks = (text) => (text || '').replace(
: `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`),
)
/**
* The same cross-references, reduced to the words an educator wrote.
*
* A summary is shown in two places that want different things from it. At the
* head of an article it is prose, so it goes through the renderer and its
* references are live. In a card or a list row it is a one-line description
* inside something that is already a link, and an `ArticleLink` nested there
* would swallow the click that was meant to open the card so there the
* markup is flattened to its labels rather than rendered at all.
*
* Either way `[[288|eczema]]` never reaches a reader as brackets and a number.
*/
export const plainWikiText = (text) => (text || '').replace(
WIKI_LINK,
(_m, _id, idLabel, label) => (idLabel || label || '').trim(),
)
const internalArticle = (href) => {
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '')
return match ? match[1] : null

View file

@ -13,32 +13,53 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'
* 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.
*
* Two claims, not one. Being in a session did two things at once: it gave the
* page the whole window, and it took the navbar's section strip away. A
* reading page wants the first and not the second every link on that strip
* leaves a session you are sitting, which is the argument for hiding it, and
* none of that is true of an article you can wander away from at any time. So
* width and chrome are claimed separately, and a player claims both.
*/
const Context = createContext({ inSession: false, enter: () => () => {} })
const Context = createContext({ inSession: false, fullBleed: false, enter: () => () => {} })
export function SessionChromeProvider({ children }) {
const [depth, setDepth] = useState(0)
const [depth, setDepth] = useState({ session: 0, bleed: 0 })
const value = useMemo(() => ({
inSession: depth > 0,
enter: () => {
setDepth(n => n + 1)
return () => setDepth(n => Math.max(0, n - 1))
inSession: depth.session > 0,
// A session takes the window too, so it need not ask for it twice.
fullBleed: depth.session > 0 || depth.bleed > 0,
enter: (kind) => {
setDepth(d => ({ ...d, [kind]: d[kind] + 1 }))
return () => setDepth(d => ({ ...d, [kind]: Math.max(0, d[kind] - 1) }))
},
}), [depth])
return <Context.Provider value={value}>{children}</Context.Provider>
}
/** Read by the shell and the navbar. */
/** Read by the navbar: is a session open that its section strip would break? */
export const useInSession = () => useContext(Context).inSession
/** Called by a player while it is mounted. */
export function useClaimSessionChrome(active = true) {
/** Read by the shell: does anything on screen want the window without gutters? */
export const useFullBleed = () => useContext(Context).fullBleed
function useClaim(kind, active) {
const { enter } = useContext(Context)
useEffect(() => {
if (!active) return undefined
return enter()
return enter(kind)
// `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])
}, [kind, active])
}
/** Called by a player while it is mounted: the window, and no section strip. */
export function useClaimSessionChrome(active = true) {
useClaim('session', active)
}
/** The window without the gutters, with the site's navigation left alone. */
export function useClaimFullBleed(active = true) {
useClaim('bleed', active)
}

View file

@ -9,8 +9,11 @@ import ArticleRevisions from '../components/ArticleRevisions'
import CategoryColumns from '../components/CategoryColumns'
import { resolveArticleId } from '../components/ArticleLink'
import ArticleReader from '../components/ArticleReader'
import ArticleSaveButton from '../components/ArticleSaveButton'
import ArticleSplitPane from '../components/ArticleSplitPane'
import PractiseTopic from '../components/PractiseTopic'
import { plainWikiText } from '../components/RichText'
import { useClaimFullBleed } from '../context/SessionChrome'
import './ArticlesPage.css'
const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
@ -140,7 +143,11 @@ export default function ArticlesPage() {
{articles.map(article => (
<Link key={article.id} to={`/articles/${article.id}`} className="article-card">
<h3>{article.title} <DraftBadge status={article.status} /></h3>
{article.summary && <p>{article.summary}</p>}
{/* Flattened, not rendered: the card is itself a link, and a
live cross-reference inside one would eat the click that
was meant to open the article. Printed raw it showed the
reader `[[288|eczema]]`. */}
{article.summary && <p>{plainWikiText(article.summary)}</p>}
<span className="article-card-meta">{article.section_count ?? 0} sections</span>
</Link>
))}
@ -175,8 +182,11 @@ export function ArticlePage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeSection, setActiveSection] = useState('')
// The cross-reference being read beside this article, by slug.
const [splitSlug, setSplitSlug] = useState(null)
// The cross-references being read beside this article, oldest first. A trail
// rather than a single slug: following a reference from inside the pane used
// to overwrite the one that sent you there, so the way back was the browser
// and the browser leaves the article behind.
const [splitTrail, setSplitTrail] = useState([])
const [questions, setQuestions] = useState([])
const [cards, setCards] = useState([])
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
@ -228,9 +238,13 @@ export function ArticlePage() {
}
// Both readers open a cross-reference into the same slot, so one followed
// from inside the pane replaces what is there instead of splitting again.
const splitView = useMemo(() => ({ open: setSplitSlug, inPane: false }), [])
const paneView = useMemo(() => ({ open: setSplitSlug, inPane: true }), [])
// from inside the pane replaces what is there instead of splitting again
// there is never more than one pane, whatever the depth of the trail.
const pushSplit = useCallback((slug) => setSplitTrail(
trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit])
const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit])
const splitSlug = splitTrail.at(-1) || null
const save = async (publish = null) => {
setSaving(true)
@ -291,36 +305,35 @@ export function ArticlePage() {
const canEdit = user?.is_moderator || article.user_id === user?.id
return (
<div className={`article-page${splitSlug ? ' is-split' : ''}`}>
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/articles">Reading</Link>
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
<span> / {article.title}</span>
</nav>
<div className="article-header">
<div>
<h1>{article.title} <DraftBadge status={article.status} /></h1>
<p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p>
</div>
<div className="article-header-actions">
{user?.is_moderator && !editing && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => runAi('cards')} disabled={!!aiJob}>Generate cards</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(v => !v)} disabled={!!aiJob}>AI refine</button>
</>
)}
{canEdit && !editing && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
<button className="btn btn-secondary btn-sm" onClick={() => save(article.status === 'published' ? false : true)}>
{article.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</>
)}
{editing && <button className="btn btn-primary btn-sm" onClick={() => save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>}
</div>
</div>
const breadcrumbs = (
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/articles">Reading</Link>
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
<span> / {article.title}</span>
</nav>
)
const editorActions = (
<>
{user?.is_moderator && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => runAi('cards')} disabled={!!aiJob}>Generate cards</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(v => !v)} disabled={!!aiJob}>AI refine</button>
</>
)}
{canEdit && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
<button className="btn btn-secondary btn-sm" onClick={() => save(article.status === 'published' ? false : true)}>
{article.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</>
)}
</>
)
const notices = (
<>
{error && <div className="form-error" role="alert">{error}</div>}
{showRefine && user?.is_moderator && (
<div className="card" style={{ marginBottom: 12 }}>
@ -334,55 +347,81 @@ export function ArticlePage() {
</div>
</div>
)}
</>
)
{editing && form ? (
<div>
<ArticleEditor form={form} setForm={setForm} />
<div className="card" style={{ marginTop: 16 }}>
<h4>Link a question</h4>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Enter a bank question ID, optionally scoped to one section.</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input className="input" style={{ maxWidth: 180 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
<select className="input" value={linkSection} onChange={e => setLinkSection(e.target.value)} aria-label="Section scope">
<option value="">Whole article</option>
{form.sections.map(s => <option key={s.id} value={s.id}>{s.title || `Section ${s.slug}`}</option>)}
</select>
<button className="btn btn-secondary btn-sm" onClick={linkQuestion}>Link</button>
</div>
{linkError && <div className="form-error" role="alert">{linkError}</div>}
if (editing && form) {
return (
<div className="article-page">
{breadcrumbs}
<div className="article-header">
<div><h1>{article.title} <DraftBadge status={article.status} /></h1></div>
<div className="article-header-actions">
<button className="btn btn-primary btn-sm" onClick={() => save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
<ArticleRevisions articleId={article.id} canRestore={!!user?.is_moderator}
onRestored={() => { setEditing(false); load() }} />
</div>
) : (
<div className={`article-split${splitSlug ? ' is-open' : ''}`}>
<div className="article-split-main">
<SplitViewProvider value={splitView}>
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}>
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
{cards.length > 0 && (
<div className="article-linked">
<h3>Related cards</h3>
{cards.map(card => (
<div key={card.card_id} className="linked-card">
<div><strong>{card.front}</strong> <span className="article-card-meta"> {card.back}</span></div>
<Link className="btn btn-sm btn-secondary" to={`/flashcards/${card.deck_id}/study`}>Study deck</Link>
</div>
))}
</div>
)}
</ArticleReader>
</SplitViewProvider>
{notices}
<ArticleEditor form={form} setForm={setForm} />
<div className="card" style={{ marginTop: 16 }}>
<h4>Link a question</h4>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Enter a bank question ID, optionally scoped to one section.</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input className="input" style={{ maxWidth: 180 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
<select className="input" value={linkSection} onChange={e => setLinkSection(e.target.value)} aria-label="Section scope">
<option value="">Whole article</option>
{form.sections.map(s => <option key={s.id} value={s.id}>{s.title || `Section ${s.slug}`}</option>)}
</select>
<button className="btn btn-secondary btn-sm" onClick={linkQuestion}>Link</button>
</div>
{splitSlug && (
{linkError && <div className="form-error" role="alert">{linkError}</div>}
</div>
<ArticleRevisions articleId={article.id} canRestore={!!user?.is_moderator}
onRestored={() => { setEditing(false); load() }} />
</div>
)
}
return (
/* Reading takes the window. The rail meets the left edge of the screen and
the prose takes the rest, which is what the 1080px cap and the auto
margins were preventing on a wide monitor the article was a ribbon
down the middle with empty page either side. The editor keeps the
gutters: a form field two thousand pixels wide is nobody's idea of an
improvement. */
<div className="article-page is-reading">
{notices && <div className="article-notices">{notices}</div>}
<SplitViewProvider value={splitView}>
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}
breadcrumbs={breadcrumbs}
toolbar={
<>
<ArticleSaveButton articleId={article.id} questionIds={questions.map(q => q.question_id)} />
{editorActions}
</>
}
aside={splitSlug ? (
<SplitViewProvider value={paneView}>
<ArticleSplitPane slug={splitSlug} onClose={() => setSplitSlug(null)} />
<ArticleSplitPane slug={splitSlug} depth={splitTrail.length - 1}
onBack={() => setSplitTrail(trail => trail.slice(0, -1))}
onClose={() => setSplitTrail([])} />
</SplitViewProvider>
) : null}>
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
{cards.length > 0 && (
<div className="article-linked">
<h3>Related cards</h3>
{cards.map(card => (
<div key={card.card_id} className="linked-card">
<div><strong>{card.front}</strong> <span className="article-card-meta"> {card.back}</span></div>
<Link className="btn btn-sm btn-secondary" to={`/flashcards/${card.deck_id}/study`}>Study deck</Link>
</div>
))}
</div>
)}
</div>
)}
</ArticleReader>
</SplitViewProvider>
</div>
)
}