+ question does not get. An article asks for the same width without
+ the rest of the session chrome — hence two classes rather than one. */}
+
diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx
index cac5b50..16cfee5 100644
--- a/frontend/src/components/ArticleReader.jsx
+++ b/frontend/src/components/ArticleReader.jsx
@@ -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 (
-
- setDrawerOpen(v => !v)}
- aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
- {drawerOpen ? '✕ Close sections' : '☰ Sections'}
-
-
- setRailOpen(v => !v)}>
- {railOpen ? '‹' : '›'}
+ 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 && }
+ {article.content && {article.content} }
+ {!allSections.length && (!article.summary && !article.content) && (
+ Content is being prepared by educators.
+ )}
+
+ {/* 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 ? (
+
+ {soleSection.content}
+
+ ) : topSections.map(sec => renderSection(sec, 0))}
+
+
+ {(article.references || []).length > 0 && (
+
+ References
+ {/* 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. */}
+
+ {article.references.map((ref, index) => (
+
+ {ref.title}
+ {ref.author && — {ref.author} }
+ {(ref.pages || []).length > 0 && (
+ · p. {ref.pages.join(', ')}
+ )}
+
+ ))}
+
+
+ )}
+ {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 (
+ {prose}
+ )
+ }
+
+ /**
+ * 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 && (
+
+ {present.map(option => (
+
{ setView(option.key); setOpenIds({}); setReading('') }}>
+ {option.label}
-
{article.title}
- {/* A contents list of one entry is not a contents list. */}
- {!soleSection && (
-
- {topSections.map(sec => (
-
- openSection(sec.id)}>
- {sec.title}
-
- {/* 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 && (
-
- {kidsOf(sec.id).map(kid => (
-
- openSection(kid.id)}>
- {kid.title}
-
-
- ))}
-
- )}
-
- ))}
-
- )}
+ ))}
+
+ )
+
+ return (
+
+
+
+
+
{article.title}
+ {/* On the boundary between the rail and the prose, which is where
+ the reader is looking when they decide they want the width. */}
+ setRailOpen(false)}>
+ ‹
+
+
+ {/* A contents list of one entry is not a contents list. */}
+ {!soleSection && (
+
+ {topSections.map(sec => (
+
+ openSection(sec.id)}>
+ {sec.title}
+
+ {/* 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 && (
+
+ {kidsOf(sec.id).map(kid => (
+
+ openSection(kid.id)}>
+ {kid.title}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
-
+ {/* Where the rail was, so putting it back is where you last saw it. */}
+ {!railOpen && (
+ setRailOpen(true)}>
+ ›
+
+ )}
+
+
+
{breadcrumbs}
+
+ {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. */}
+ setSize(SIZES[(SIZES.indexOf(size) + 1) % SIZES.length])}>
+ A a
+
+
+
{article.updated_at && (
Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
)}
- {article.summary && {article.summary}
}
- {article.content && {article.content} }
- {!allSections.length && (!article.summary && !article.content) && (
- Content is being prepared by educators.
- )}
+
+ {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' && (
+ Draft
+ )}
+
{/* 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. */}
- {/* Only the views this article actually has: an empty tab is a
- promise the article cannot keep. */}
- {present.length > 1 && (
-
- {present.map(option => (
- { setView(option.key); setOpenIds({}) }}>
- {option.label}
-
- ))}
-
- )}
- {allSections.length > 0 && !soleSection && (
-
setAll(!allOpen)}>
- {allOpen ? 'Collapse all' : 'Expand all'}
+
+ setDrawerOpen(v => !v)}
+ aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
+ {drawerOpen ? '✕ Close contents' : '☰ Contents'}
- )}
+ {readingTitle && (
+
+ {article.title}
+ ›
+ {readingTitle}
+
+ )}
+
+
+ {allSections.length > 0 && !soleSection && (
+ setAll(!allOpen)}>
+ {allOpen ? 'Collapse all' : 'Expand all'}
+
+ )}
+ {/* Only the views this article actually has: an empty tab is a
+ promise the article cannot keep. */}
+ {viewSwitch}
+
-
- {/* 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 ? (
-
- {soleSection.content}
-
- ) : topSections.map(sec => renderSection(sec, 0))}
-
-
- {(article.references || []).length > 0 && (
-
- References
- {/* 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. */}
-
- {article.references.map((ref, index) => (
-
- {ref.title}
- {ref.author && — {ref.author} }
- {(ref.pages || []).length > 0 && (
- · p. {ref.pages.join(', ')}
- )}
-
- ))}
-
-
- )}
-
- {children}
+ {prose}
+ {aside}
)
}
diff --git a/frontend/src/components/ArticleSaveButton.css b/frontend/src/components/ArticleSaveButton.css
new file mode 100644
index 0000000..2ea6f9c
--- /dev/null
+++ b/frontend/src/components/ArticleSaveButton.css
@@ -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; }
diff --git a/frontend/src/components/ArticleSaveButton.jsx b/frontend/src/components/ArticleSaveButton.jsx
new file mode 100644
index 0000000..c8cad8d
--- /dev/null
+++ b/frontend/src/components/ArticleSaveButton.jsx
@@ -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 (
+
+ setOpen(v => !v)}>
+ {isSaved ? '★' : '☆'}
+
+ {open && (
+
+
Save this topic
+ {questionIds.length === 0 ? (
+ /* Said plainly rather than offering a control that would file
+ nothing: a library holds questions, and this topic has none yet. */
+
No questions are linked to this topic yet, so there is nothing to file. Link one and it can be saved with the reading.
+ ) : (
+ <>
+
Files its {questionIds.length} linked question{questionIds.length === 1 ? '' : 's'} so you can sit them later.
+ {/* 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. */}
+
setQuery(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter' && canCreate) { event.preventDefault(); createAndSave(query) }
+ }} />
+ {canCreate && (
+
createAndSave(query)}>
+ {query.trim()} +
+
+ )}
+ {found.length > 0 && (
+
+ {found.map(row => {
+ const inIt = saved.includes(row.id)
+ return (
+
+ saveInto(row.id)}>
+ {inIt ? '✓ ' : '+ '}{row.title}
+
+
+ )
+ })}
+
+ )}
+ {!found.length && !canCreate && (
+
{libraries.length ? 'No library by that name.' : 'Type a name to make your first library.'}
+ )}
+ >
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ArticleSplitPane.jsx b/frontend/src/components/ArticleSplitPane.jsx
index 5c241ef..cce88f9 100644
--- a/frontend/src/components/ArticleSplitPane.jsx
+++ b/frontend/src/components/ArticleSplitPane.jsx
@@ -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() }}>
+ {depth > 0 && (
+
+ ‹
+
+ )}
{article ? article.title : 'Opening…'}
✕
@@ -50,8 +66,7 @@ export default function ArticleSplitPane({ slug, onClose }) {
{error ?
{error}
: !article ?
- :
}
+ :
}
)
diff --git a/frontend/src/components/QuestionEditors.jsx b/frontend/src/components/QuestionEditors.jsx
deleted file mode 100644
index 652d9b3..0000000
--- a/frontend/src/components/QuestionEditors.jsx
+++ /dev/null
@@ -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 (
- e.target === e.currentTarget && onClose()}>
-
-
-
Edit Question
- ✕
-
-
- This question is shared — changes apply to all quizzes using it.
-
- {error &&
{error}
}
-
- Question Text
-
- {form.options.length > 0 && (
-
-
Options — select the correct one
-
- {form.options.map((opt, i) => (
-
-
- setForm(f => ({ ...f, correct_answer: opt }))}
- style={{ width: 'auto', accentColor: 'var(--primary)' }} />
- {LETTERS[i]}
- 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' }} />
-
-
-
- Explain this option{form.option_explanations[opt] ? ' ✓' : ''}
-
-
-
- ))}
-
-
- )}
-
-
Categories
-
- Primary category drives the main listing; additional categories place the question in every selected branch.
-
-
setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
- — Uncategorized —
- {categories.map(c => {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name} )}
-
-
-
-
-
Key points (smart links to reading)
-
- Short take-aways shown in study feedback; link a point to an article or a section to open it directly.
-
- {form.key_points.map((point, i) => (
-
- 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)' }} />
- 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)' }} />
- 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)' }} />
- setForm(f => ({ ...f, key_points: f.key_points.filter((_, j) => j !== i) }))}>✕
-
- ))}
-
setForm(f => ({ ...f, key_points: [...f.key_points, { text: '', article_id: '', article_section_id: '' }] }))}>+ Add key point
-
-
- Difficulty
- setForm(f => ({ ...f, difficulty: e.target.value || null }))}>
- — Not set —
- Easy
- Medium
- Hard
-
-
-
- Explanation
-
-
- {saving ? 'Saving…' : 'Save Changes'}
- Cancel
-
-
-
- )
-}
diff --git a/frontend/src/components/QuestionEditors.test.jsx b/frontend/src/components/QuestionEditors.test.jsx
deleted file mode 100644
index 928452c..0000000
--- a/frontend/src/components/QuestionEditors.test.jsx
+++ /dev/null
@@ -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(
-
- {}} onClose={() => {}} />
- )
-
-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' },
- })))
- })
-})
diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
index fed3715..ecb815d 100644
--- a/frontend/src/components/RichText.jsx
+++ b/frontend/src/components/RichText.jsx
@@ -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
diff --git a/frontend/src/context/SessionChrome.jsx b/frontend/src/context/SessionChrome.jsx
index aca7ef6..05147b9 100644
--- a/frontend/src/context/SessionChrome.jsx
+++ b/frontend/src/context/SessionChrome.jsx
@@ -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 {children}
}
-/** 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)
}
diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx
index 6f06098..d0df7b7 100644
--- a/frontend/src/pages/ArticlesPage.jsx
+++ b/frontend/src/pages/ArticlesPage.jsx
@@ -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 => (
{article.title}
- {article.summary && {article.summary}
}
+ {/* 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 && {plainWikiText(article.summary)}
}
{article.section_count ?? 0} sections
))}
@@ -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 (
-
-
- Reading
- {(article.category_breadcrumbs || []).map(crumb => / {crumb.name} )}
- / {article.title}
-
-
-
-
{article.title}
-
{questions.length} linked questions · {cards.length} linked cards
-
-
- {user?.is_moderator && !editing && (
- <>
- runAi('cards')} disabled={!!aiJob}>Generate cards
- setShowRefine(v => !v)} disabled={!!aiJob}>AI refine
- >
- )}
- {canEdit && !editing && (
- <>
- setEditing(true)}>Edit
- save(article.status === 'published' ? false : true)}>
- {article.status === 'published' ? 'Unpublish' : 'Publish'}
-
- >
- )}
- {editing && save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'} }
-
-
+ const breadcrumbs = (
+
+ Reading
+ {(article.category_breadcrumbs || []).map(crumb => / {crumb.name} )}
+ / {article.title}
+
+ )
+
+ const editorActions = (
+ <>
+ {user?.is_moderator && (
+ <>
+
runAi('cards')} disabled={!!aiJob}>Generate cards
+
setShowRefine(v => !v)} disabled={!!aiJob}>AI refine
+ >
+ )}
+ {canEdit && (
+ <>
+
setEditing(true)}>Edit
+
save(article.status === 'published' ? false : true)}>
+ {article.status === 'published' ? 'Unpublish' : 'Publish'}
+
+ >
+ )}
+ >
+ )
+
+ const notices = (
+ <>
{error &&
{error}
}
{showRefine && user?.is_moderator && (
@@ -334,55 +347,81 @@ export function ArticlePage() {
)}
+ >
+ )
- {editing && form ? (
-
-
-
-
-
Link a question
-
Enter a bank question ID, optionally scoped to one section.
-
- setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
- setLinkSection(e.target.value)} aria-label="Section scope">
- Whole article
- {form.sections.map(s => {s.title || `Section ${s.slug}`} )}
-
- Link
-
- {linkError &&
{linkError}
}
+ if (editing && form) {
+ return (
+
+ {breadcrumbs}
+
+
{article.title}
+
+ save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}
-
-
{ setEditing(false); load() }} />
- ) : (
-
-
-
-
-
- {cards.length > 0 && (
-
-
Related cards
- {cards.map(card => (
-
-
{card.front} → {card.back}
-
Study deck
-
- ))}
-
- )}
-
-
+ {notices}
+
+
+
+
Link a question
+
Enter a bank question ID, optionally scoped to one section.
+
+ setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
+ setLinkSection(e.target.value)} aria-label="Section scope">
+ Whole article
+ {form.sections.map(s => {s.title || `Section ${s.slug}`} )}
+
+ Link
- {splitSlug && (
+ {linkError &&
{linkError}
}
+
+
+
{ setEditing(false); load() }} />
+
+ )
+ }
+
+ 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. */
+
+ {notices &&
{notices}
}
+
+
+ q.question_id)} />
+ {editorActions}
+ >
+ }
+ aside={splitSlug ? (
- setSplitSlug(null)} />
+ setSplitTrail(trail => trail.slice(0, -1))}
+ onClose={() => setSplitTrail([])} />
+ ) : null}>
+
+ {cards.length > 0 && (
+
+
Related cards
+ {cards.map(card => (
+
+
{card.front} → {card.back}
+
Study deck
+
+ ))}
+
)}
-
- )}
+
+
)
}