feat: browse the library in columns, read articles section by section

Three things, all from how AMBOSS actually behaves rather than from a
description of it.

Row by row, two menus
The articles page is now the column browser itself rather than a grid behind a
"▼ All categories" toggle. Opening a topic opens its contents in the next
column, so the trail you took stays on screen and you can step back a level
without losing your place. Topics and the articles filed under them share a
column, because to a reader those are the same list — things this heading
contains — and only the icon separates a folder you can open from a page you can
read. Articles filed nowhere sit in the root column instead of being unreachable
for want of a heading. Under 720px it is one column plus a back button. Search
is a different question from browsing — you already know the name — so it still
answers with a flat list of matches.

Sections that collapse
An article is a reference you consult, so it opens as a contents page: headings
only, each expanding where it sits. A section may now sit under an earlier
top-level one (`parent_id` on the section JSON, absent on every article written
before this), which is how "ROS questionnaire" belongs to "Review of systems"
rather than standing alongside it. The contents rail nests the same way. Nesting
is refused where it could not render: its own parent, a parent later in the
article, a parent outside it, or a sub-section of a sub-section. A deep link
opens the target section and its parent — landing on a collapsed heading looks
like the link went nowhere. References are pinned last however they were
written; a reader scrolling for content should not hit the bibliography halfway
down.

Links that show where they go
`[[febrile-seizures]]` or `[[Febrile seizures|febrile-seizures]]` in article
prose becomes an in-app link that previews the target on hover: title, a couple
of sentences of actual prose with the markup taken out, and how much is there.
Following a link to find out whether it was worth following is the thing that
breaks a train of thought. Slugs, not ids, because that is what an educator
writes and it outlives a renumbering. One fetch per article for the life of the
page, a 350ms delay so crossing a link summons nothing, and no card at all on
touch, where a card would sit between the finger and the link.

Dead CSS for the old section modal and the always-open section block is gone —
nothing rendered those class names any more, and stale rules winning on source
order has bitten this page before.

146 backend, 152 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
This commit is contained in:
Daniel 2026-09-10 11:42:33 +02:00
parent df15a14983
commit 362c48926b
12 changed files with 753 additions and 96 deletions

View file

@ -32,6 +32,10 @@ class ArticleSection(BaseModel):
slug: str slug: str
title: str title: str
content: str = "" content: str = ""
# A section may sit under another one, which is how "ROS questionnaire"
# belongs to "Review of systems" rather than standing alongside it. Absent
# or null means top level, so every article written before this stays valid.
parent_id: str | None = None
class ArticleWrite(BaseModel): class ArticleWrite(BaseModel):
@ -117,6 +121,21 @@ def _validate_sections(sections: list[ArticleSection]):
ids.add(section.id) ids.add(section.id)
slugs.add(section.slug.strip()) slugs.add(section.slug.strip())
# Nesting is one level deep and points backwards: a parent has to be a
# section of this article that was already listed, which rules out a cycle
# and a sub-section that renders before the heading it belongs to.
parents = set()
seen: set[str] = set()
for section in sections:
if section.parent_id is not None:
if section.parent_id == section.id or section.parent_id not in seen:
raise HTTPException(400, "A sub-section must sit under an earlier section of this article")
parents.add(section.parent_id)
seen.add(section.id)
nested = {s.id for s in sections if s.parent_id is not None}
if parents & nested:
raise HTTPException(400, "Sub-sections cannot themselves hold sub-sections")
def _section_ids(article): def _section_ids(article):
return {section["id"] for section in (article.sections or [])} return {section["id"] for section in (article.sections or [])}
@ -300,6 +319,49 @@ def recently_viewed(
for article, viewed_at in rows] for article, viewed_at in rows]
def _plain_excerpt(article: Article, limit: int = 260) -> str:
"""First readable prose in an article, with the markup taken out.
A preview shows what the link leads to, so it wants the opening sentences
not a heading, a table pipe, or an image tag rendered as `![]()`.
"""
source = article.summary or article.content or ""
if not source.strip() and article.sections:
source = (article.sections[0] or {}).get("content") or ""
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", source) # images
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # links keep their words
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) # headings
text = re.sub(r"[*_`>|]|^\s*[-+]\s", " ", text, flags=re.M)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit].rstrip() + "" if len(text) > limit else text
@router.get("/preview/{slug}")
def preview_article(
slug: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""What a cross-reference points at, for a hover card.
Deliberately small: a link preview that fetched whole articles would pull
down the library a paragraph at a time as somebody reads.
"""
article = db.query(Article).filter(Article.slug == slug.lower()).first()
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"excerpt": _plain_excerpt(article),
"section_count": len(article.sections or []),
"status": article.status,
}
@router.get("/{article_id}") @router.get("/{article_id}")
def get_article( def get_article(
article_id: int, article_id: int,

View file

@ -203,3 +203,86 @@ class ArticlesCardsTests(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class ArticleReadingTests(unittest.TestCase):
"""Nested sections and the preview a cross-reference hover shows."""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(articles.router, prefix='/articles')
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def make(self, sections, slug="nested-topic", **extra):
payload = {"title": "Nested topic", "slug": slug, "summary": "Summary",
"content": "Intro", "sections": sections, **extra}
return self.client.post('/articles/', json=payload)
def section(self, letter, title, parent=None):
body = {"id": letter * 32, "slug": f"sec-{letter}", "title": title, "content": f"{title} body"}
if parent:
body["parent_id"] = parent * 32
return body
def test_a_section_may_sit_under_an_earlier_one(self):
response = self.make([self.section('a', 'Review of systems'),
self.section('b', 'ROS questionnaire', parent='a')])
self.assertEqual(response.status_code, 200, response.text)
sections = response.json()['sections']
self.assertEqual([s.get('parent_id') for s in sections], [None, 'a' * 32])
def test_articles_written_before_nesting_stay_valid(self):
# No parent_id at all is the shape every existing article has.
response = self.make([{"id": "a" * 32, "slug": "s", "title": "S", "content": "B"}])
self.assertEqual(response.status_code, 200, response.text)
self.assertIsNone(response.json()['sections'][0].get('parent_id'))
def test_nesting_that_would_not_render_is_refused(self):
cases = {
"its own parent": [self.section('a', 'A', parent='a')],
"a parent that comes later": [self.section('a', 'A', parent='b'), self.section('b', 'B')],
"a parent outside the article": [self.section('a', 'A', parent='f')],
# Two levels would need a heading level the reading view does not have.
"a sub-section of a sub-section": [self.section('a', 'A'),
self.section('b', 'B', parent='a'),
self.section('c', 'C', parent='b')],
}
for label, sections in cases.items():
with self.subTest(label):
self.assertEqual(self.make(sections, slug=f"case-{len(label)}").status_code, 400)
def test_preview_is_small_and_respects_who_may_read_it(self):
article = self.make([self.section('a', 'A')], slug='preview-topic').json()
# A draft is not previewable by someone who cannot open it, or the hover
# card would leak the title of unpublished work.
self.bank.user = self.bank.owner
self.assertEqual(self.client.get('/articles/preview/preview-topic').status_code, 404)
self.bank.user = self.bank.mod
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
self.bank.user = self.bank.owner
preview = self.client.get('/articles/preview/preview-topic').json()
self.assertEqual(preview['title'], 'Nested topic')
self.assertEqual(preview['section_count'], 1)
self.assertEqual(preview['excerpt'], 'Summary')
# Deliberately not the whole article: a preview that carried the body
# would pull the library down a paragraph at a time as somebody reads.
self.assertNotIn('sections', preview)
self.assertNotIn('content', preview)
self.assertEqual(self.client.get('/articles/preview/no-such-topic').status_code, 404)
def test_excerpt_reads_as_prose_not_markup(self):
self.make([self.section('a', 'A')], slug='marked-up', summary='',
content='## Heading\n\n![scan](/uploads/x.png) See [the workup](/articles/workup) **now**.')
self.client.post('/articles/1/publish', json={'published': True})
excerpt = self.client.get('/articles/preview/marked-up').json()['excerpt']
self.assertNotIn('!', excerpt)
self.assertNotIn('##', excerpt)
self.assertNotIn('/uploads/', excerpt)
self.assertIn('the workup', excerpt) # a link keeps its words

View file

@ -51,10 +51,21 @@ Updated 2026-09-10.
## Article reading ## Article reading
- [ ] **Nested sections** — sub-sections under a section, with a breadcrumb - [x] **Nested sections and per-section collapse** — done 2026-09-10. A section
(`Article Section`) and per-section collapse. may sit under an earlier top-level one (`parent_id` on the section JSON),
- [ ] **References** — numbered list per article, with superscript markers in the the contents rail lists sub-sections under their parent, and an article
body linking down to them. opens as headings only, each expanding where it sits. Deep links open the
target section and its parent.
- [x] **Cross-references with previews** — done 2026-09-10. `[[slug]]` or
`[[Label|slug]]` in article prose becomes an in-app link that shows title,
excerpt and section count on hover, from `GET /articles/preview/{slug}`.
One fetch per article per page; no card on touch, where there is no hover.
- [x] **Library browsed column by column** — done 2026-09-10. The articles page
is now the column browser itself: topics and the articles filed under them
share a column, separated by icon. Search still answers with a flat list.
- [ ] **References** — a section titled "References" is pinned last and styled,
but the numbered list with superscript markers linking down to it is not
built.
- [ ] **Per-section notes and feedback** — a learner's own note attached to a - [ ] **Per-section notes and feedback** — a learner's own note attached to a
section, and a feedback channel to the educator. section, and a feedback channel to the educator.
- [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show - [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show

View file

@ -101,6 +101,9 @@ function AppRoutes() {
<Route path="/flashcards" element={<FlashcardsPage />} /> <Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/articles" element={<ArticlesPage />} /> <Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} /> <Route path="/articles/:id" element={<ArticlePage />} />
{/* Cross-references in article prose address a topic by slug, which
outlives a numeric id and is what an educator actually writes. */}
<Route path="/articles/s/:slug" element={<ArticlePage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} /> <Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} /> <Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} /> <Route path="/courses/:courseId" element={<CourseDetailPage />} />

View file

@ -0,0 +1,40 @@
/* Cross-reference links and their hover cards. */
.al-wrap { position: relative; display: inline; }
.al-link {
color: var(--primary);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--primary) 35%, transparent);
}
.al-link:hover { border-bottom-color: var(--primary); }
.al-card {
position: absolute;
z-index: 40;
left: 0;
top: calc(100% + 8px);
display: flex;
flex-direction: column;
gap: 6px;
width: min(340px, 78vw);
padding: 12px 14px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16);
font-size: 0.84rem;
line-height: 1.5;
text-align: left;
/* The card is a hint, not a target: it must never sit between the pointer and
the link it describes. */
pointer-events: none;
}
.al-card.is-above { top: auto; bottom: calc(100% + 8px); }
.al-card-title { font-weight: 700; font-size: 0.92rem; color: var(--text); }
.al-card-excerpt { color: var(--text-muted); }
.al-card-meta { font-size: 0.75rem; color: var(--text-subtle); }
/* No hover on touch, so the card never appears there and the link is just a link. */
@media (hover: none) {
.al-card { display: none; }
}

View file

@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import './ArticleLink.css'
// One fetch per article for the life of the page. A reader hovers the same
// cross-reference several times while deciding whether to follow it, and each
// one firing a request would make reading cost more than clicking.
const cache = new Map()
const HOVER_DELAY = 350 // Long enough that crossing a link does not summon a card.
export function fetchPreview(slug) {
if (!cache.has(slug)) {
cache.set(slug, api.get(`/articles/preview/${slug}`)
.then(res => res.data)
.catch(() => null))
}
return cache.get(slug)
}
/**
* A cross-reference to another article, with a preview on hover.
*
* Following a link to find out whether it was worth following is the thing that
* breaks a train of thought, so the preview answers that in place: title, a
* couple of sentences, and how much is there.
*
* Touch has no hover, so on a phone this is just a link a card that appears on
* tap would sit between the finger and the thing it was about to open.
*/
/** The numeric id behind a slug, reusing whatever the hover card already fetched. */
export const resolveArticleId = (slug) => fetchPreview(slug).then(data => data?.id ?? null)
export default function ArticleLink({ slug, children, className = '' }) {
const [preview, setPreview] = useState(null)
const [open, setOpen] = useState(false)
const [above, setAbove] = useState(false)
const timer = useRef(null)
const anchor = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const show = useCallback(() => {
clearTimeout(timer.current)
timer.current = setTimeout(async () => {
const data = await fetchPreview(slug)
if (!data) return
// Flip the card above the link when there is no room beneath it.
const box = anchor.current?.getBoundingClientRect?.()
if (box) setAbove(window.innerHeight - box.bottom < 220)
setPreview(data)
setOpen(true)
}, HOVER_DELAY)
}, [slug])
const hide = useCallback(() => {
clearTimeout(timer.current)
setOpen(false)
}, [])
return (
<span className="al-wrap" onMouseEnter={show} onMouseLeave={hide}>
<Link ref={anchor} to={`/articles/s/${slug}`} className={`al-link ${className}`}
onFocus={show} onBlur={hide} onClick={hide}>
{children}
</Link>
{open && preview && (
<span className={`al-card${above ? ' is-above' : ''}`} role="tooltip">
<span className="al-card-title">{preview.title}</span>
{preview.excerpt && <span className="al-card-excerpt">{preview.excerpt}</span>}
<span className="al-card-meta">
{preview.section_count} section{preview.section_count === 1 ? '' : 's'}
{preview.status !== 'published' && ' · draft'}
</span>
</span>
)}
</span>
)
}

View file

@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import ArticleLink from './ArticleLink'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
const preview = {
id: 7, slug: 'febrile-seizures', title: 'Febrile seizures',
excerpt: 'A seizure with fever in a child aged 6 months to 5 years…',
section_count: 4, status: 'published',
}
const mount = (slug = 'febrile-seizures') => render(
<MemoryRouter><ArticleLink slug={slug}>febrile seizures</ArticleLink></MemoryRouter>)
describe('cross-reference previews', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
api.get.mockResolvedValue({ data: preview })
})
it('links by slug, which outlives a numeric id', () => {
mount()
expect(screen.getByRole('link', { name: 'febrile seizures' }))
.toHaveAttribute('href', '/articles/s/febrile-seizures')
})
it('shows what the link leads to on hover, and takes it away again', async () => {
mount()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
await userEvent.hover(screen.getByRole('link'))
const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
expect(card).toHaveTextContent('Febrile seizures')
expect(card).toHaveTextContent('A seizure with fever')
expect(card).toHaveTextContent('4 sections')
await userEvent.unhover(screen.getByRole('link'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('fetches a given article once however often it is hovered', async () => {
mount('one-off-topic')
const link = screen.getByRole('link')
await userEvent.hover(link)
await screen.findByRole('tooltip', {}, { timeout: 2000 })
await userEvent.unhover(link)
await userEvent.hover(link)
await screen.findByRole('tooltip', {}, { timeout: 2000 })
expect(api.get).toHaveBeenCalledTimes(1)
})
it('crossing a link is not the same as pausing on it', async () => {
mount('quickly-passed')
await userEvent.hover(screen.getByRole('link'))
await userEvent.unhover(screen.getByRole('link'))
// The timer is cancelled, so no request goes out and no card appears.
await new Promise(resolve => setTimeout(resolve, 450))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(api.get).not.toHaveBeenCalled()
})
it('says nothing rather than guessing when the article is gone', async () => {
api.get.mockRejectedValue({ response: { status: 404 } })
mount('deleted-topic')
await userEvent.hover(screen.getByRole('link'))
await waitFor(() => expect(api.get).toHaveBeenCalled())
await new Promise(resolve => setTimeout(resolve, 100))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})

View file

@ -72,3 +72,18 @@
.cc-columns { overflow-x: visible; } .cc-columns { overflow-x: visible; }
.cc-column ul { max-height: none; } .cc-column ul { max-height: none; }
} }
/* Folder or page the icon is what separates something you can open from
something you can read. */
.cc-icon { flex-shrink: 0; width: 18px; height: 18px; fill: var(--text-subtle); }
.cc-icon-article { fill: none; stroke: var(--primary); stroke-width: 1.3; stroke-linejoin: round; }
.cc-icon-lines { stroke: var(--primary); stroke-width: 1.1; stroke-linecap: round; opacity: 0.75; }
.cc-row:hover .cc-icon { fill: var(--primary); }
.cc-row-article:hover .cc-icon-article { fill: none; }
.cc-draft {
flex-shrink: 0; font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; padding: 1px 7px; border-radius: 10px;
background: #fef3c7; color: #92400e;
}
.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }

View file

@ -1,18 +1,40 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import './CategoryColumns.css' import './CategoryColumns.css'
/** A folder of further topics. */
const FolderIcon = () => (
<svg className="cc-icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M3 5.5A1.5 1.5 0 0 1 4.5 4h3.2c.4 0 .78.16 1.06.44l.8.8h5.94A1.5 1.5 0 0 1 17 6.74V14.5A1.5 1.5 0 0 1 15.5 16h-11A1.5 1.5 0 0 1 3 14.5v-9Z" />
</svg>
)
/** A thing you can actually read. */
const ArticleIcon = () => (
<svg className="cc-icon cc-icon-article" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M5 3.5h7.2L16 7.2v9.3A1.2 1.2 0 0 1 14.8 17.7H5A1.2 1.2 0 0 1 3.8 16.5v-11.8A1.2 1.2 0 0 1 5 3.5Z" />
<path className="cc-icon-lines" d="M6.4 9h7M6.4 11.4h7M6.4 13.8h4.6" />
</svg>
)
/** /**
* Browse the category tree as side-by-side columns. * Browse the library as side-by-side columns.
* *
* Picking a row in one column opens its children in the next, so the trail you * Picking a row in one column opens its contents in the next, so the trail you
* took stays on screen you can see where you are and step back a level without * took stays on screen you can see where you are and step back a level without
* losing your place. A single dropdown of 780 names shows none of that. * losing your place. A single dropdown of 780 names shows none of that.
* *
* A column holds both the topics beneath a category and the articles filed in
* it, because to a reader those are the same list: things this heading contains.
* Only the icon separates a folder you can open from a page you can read.
*
* On a phone there is no room for two columns, so it becomes one column with a * On a phone there is no room for two columns, so it becomes one column with a
* back button: the same navigation, drawn for the width available, rather than * back button: the same navigation, drawn for the width available, rather than
* two columns squeezed until neither is readable. * two columns squeezed until neither is readable.
*/ */
export default function CategoryColumns({ categories, selectedId, onSelect, allLabel = 'All categories' }) { export default function CategoryColumns({
categories, selectedId, onSelect, allLabel = 'All categories',
articlesOf, onOpenArticle, openArticleId,
}) {
// The chain of opened parents, root first. // The chain of opened parents, root first.
const [path, setPath] = useState([]) const [path, setPath] = useState([])
@ -37,17 +59,22 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
const byId = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c])), [categories]) const byId = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c])), [categories])
// One column per opened level: the roots, then each opened parent's children. const articlesIn = (categoryId) => (articlesOf ? articlesOf(categoryId) : [])
const columns = [{ parentId: 0, items: childrenOf[0] || [] }]
// One column per opened level: the roots, then each opened parent's contents.
// Anything filed nowhere is listed in the root column rather than being
// unreachable because it has no heading to sit under.
const columns = [{ parentId: 0, items: childrenOf[0] || [], articles: articlesIn(null) }]
for (const id of path) { for (const id of path) {
const kids = childrenOf[id] || [] const kids = childrenOf[id] || []
if (!kids.length) break const articles = articlesIn(id)
columns.push({ parentId: id, items: kids }) if (!kids.length && !articles.length) break
columns.push({ parentId: id, items: kids, articles })
} }
const openAt = (level, cat) => { const openAt = (level, cat) => {
const next = [...path.slice(0, level), cat.id] const hasContents = (childrenOf[cat.id] || []).length > 0 || articlesIn(cat.id).length > 0
setPath(childrenOf[cat.id]?.length ? next : path.slice(0, level)) setPath(hasContents ? [...path.slice(0, level), cat.id] : path.slice(0, level))
onSelect(cat.id) onSelect(cat.id)
} }
@ -57,14 +84,6 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
{level === 0 ? allLabel : byId[column.parentId]?.name} {level === 0 ? allLabel : byId[column.parentId]?.name}
</div> </div>
<ul> <ul>
{level === 0 && (
<li>
<button type="button" className={`cc-row${selectedId == null ? ' is-selected' : ''}`}
onClick={() => { setPath([]); onSelect(null) }}>
<span className="cc-name">{allLabel}</span>
</button>
</li>
)}
{column.items.map(cat => { {column.items.map(cat => {
const kids = childrenOf[cat.id] || [] const kids = childrenOf[cat.id] || []
const isOpen = path[level] === cat.id const isOpen = path[level] === cat.id
@ -74,13 +93,32 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
className={`cc-row${isOpen ? ' is-open' : ''}${selectedId === cat.id ? ' is-selected' : ''}`} className={`cc-row${isOpen ? ' is-open' : ''}${selectedId === cat.id ? ' is-selected' : ''}`}
aria-current={selectedId === cat.id ? 'true' : undefined} aria-current={selectedId === cat.id ? 'true' : undefined}
onClick={() => openAt(level, cat)}> onClick={() => openAt(level, cat)}>
<FolderIcon />
<span className="cc-name">{cat.name}</span> <span className="cc-name">{cat.name}</span>
<span className="cc-count">{rollup[cat.id] ?? cat.question_count}</span> <span className="cc-count">{rollup[cat.id] ?? cat.question_count}</span>
{kids.length > 0 && <span className="cc-chevron" aria-hidden="true"></span>} {(kids.length > 0 || articlesIn(cat.id).length > 0) && (
<span className="cc-chevron" aria-hidden="true"></span>
)}
</button> </button>
</li> </li>
) )
})} })}
{/* Articles come after the folders: a heading's own reading is the end
of the line, and the folders are where there is further to go. */}
{column.articles.map(article => (
<li key={`a-${article.id}`}>
<button type="button"
className={`cc-row cc-row-article${openArticleId === article.id ? ' is-selected' : ''}`}
onClick={() => onOpenArticle?.(article)}>
<ArticleIcon />
<span className="cc-name">{article.title}</span>
{article.status !== 'published' && <span className="cc-draft">Draft</span>}
</button>
</li>
))}
{level > 0 && !column.items.length && !column.articles.length && (
<li className="cc-empty">Nothing filed here yet.</li>
)}
</ul> </ul>
</div> </div>
) )

View file

@ -28,9 +28,6 @@
.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; } .article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; } .article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; }
.article-updated { font-size: .76rem; color: var(--text-subtle); margin: 0 0 10px; } .article-updated { font-size: .76rem; color: var(--text-subtle); margin: 0 0 10px; }
.article-section { scroll-margin-top: 84px; padding-top: 6px; margin-top: 26px; border-top: 1px solid var(--border); }
.article-section > h2 { margin: 16px 0 8px; font-size: 1.15rem; }
.article-section.is-target > h2 { box-shadow: -10px 0 0 var(--primary); }
.article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; } .article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; }
.article-drawer-toggle { display: none; margin-bottom: 10px; } .article-drawer-toggle { display: none; margin-bottom: 10px; }
.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; } .article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
@ -85,19 +82,7 @@
.comment-content p { margin: 0 0 4px; } .comment-content p { margin: 0 0 4px; }
.comment-actions { display: flex; gap: 6px; margin-top: 8px; } .comment-actions { display: flex; gap: 6px; margin-top: 8px; }
.comment-load-more { margin-top: 10px; } .comment-load-more { margin-top: 10px; }
.article-section-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; }
.article-section-panel { background: var(--card-bg); width: min(560px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); animation: article-slide-in .18s ease; }
@keyframes article-slide-in { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes article-slide-in { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } }
.article-section-panel-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.article-section-panel-header h2 { margin: 0; font-size: 1.05rem; }
.article-section-panel-header button { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-muted); }
.article-section-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.article-section-panel-footer { padding: 10px 18px; border-top: 1px solid var(--border); }
.article-section-panel-footer a { color: var(--primary); text-decoration: none; font-size: .85rem; }
@media (max-width: 640px) {
.article-section-overlay { align-items: flex-end; }
.article-section-panel { width: 100%; height: 82vh; border-radius: 16px 16px 0 0; }
}
/* ── Practise this topic ──────────────────────────────────────────── */ /* ── Practise this topic ──────────────────────────────────────────── */
.article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; } .article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
@ -133,10 +118,6 @@
.article-practise-controls .btn { width: 100%; } .article-practise-controls .btn { width: 100%; }
} }
@media (max-width: 640px) {
.article-section { margin-top: 20px; }
.article-section > h2 { font-size: 1.06rem; }
}
/* Category browser on the articles page */ /* Category browser on the articles page */
.articles-browse { .articles-browse {
@ -147,3 +128,51 @@
.articles-browse:hover { border-color: var(--primary); color: var(--primary); } .articles-browse:hover { border-color: var(--primary); color: var(--primary); }
.articles-browse[aria-expanded='true'] { border-color: var(--primary); color: var(--primary); } .articles-browse[aria-expanded='true'] { border-color: var(--primary); color: var(--primary); }
.articles-browser { margin-bottom: 16px; } .articles-browser { margin-bottom: 16px; }
/* Collapsible sections
An article is a reference you consult, so it opens as a contents page:
headings only, each expanding where it sits. */
.asec-controls { display: flex; justify-content: flex-end; margin: 18px 0 4px; }
.asec-list { border-top: 1px solid var(--border); }
.asec { scroll-margin-top: 84px; border-bottom: 1px solid var(--border); }
.asec-heading { margin: 0; font-size: 1rem; }
.asec-head {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 52px; padding: 13px 4px 13px 8px;
background: none; border: 0; border-radius: 8px;
font: inherit; font-size: 1rem; font-weight: 700; color: var(--text);
text-align: left; cursor: pointer;
}
.asec-head:hover { background: var(--bg); }
.asec-head:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.asec-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.asec-chevron {
flex-shrink: 0; font-size: 1.1rem; line-height: 1; color: var(--text-muted);
transition: transform 0.16s ease;
}
.asec-head[aria-expanded='true'] .asec-chevron { transform: rotate(180deg); }
.asec-body { padding: 0 8px 18px; font-size: 0.95rem; line-height: 1.65; }
.asec-body > :first-child { margin-top: 0; }
/* A sub-section is drawn inside its parent, indented and quieter, so the
hierarchy is visible without a second heading level shouting. */
.asec-depth-1 { margin: 10px 0 0 4px; border-left: 2px solid var(--border); border-bottom: 0; padding-left: 10px; }
.asec-depth-1 .asec-head { min-height: 44px; font-size: 0.92rem; font-weight: 600; padding: 10px 4px 10px 6px; }
.asec-depth-1 .asec-body { padding-bottom: 10px; }
.asec.is-target > .asec-heading > .asec-head { box-shadow: inset 3px 0 0 var(--primary); color: var(--primary); }
.asec.is-references .asec-body { font-size: 0.86rem; color: var(--text-muted); }
/* Contents list, with sub-sections under the section they belong to. */
.atoc-sub { margin: 2px 0 4px 10px !important; padding-left: 8px !important; border-left: 1px solid var(--border); }
.atoc-sub .section-link { font-size: 0.8rem; color: var(--text-muted); }
.atoc-sub .section-link.active { color: var(--primary); }
@media (max-width: 640px) {
.asec-head { font-size: 0.95rem; }
.asec-body { font-size: 0.92rem; }
}

View file

@ -6,6 +6,7 @@ import api from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor' import RichEditor from '../components/RichEditor'
import CategoryColumns from '../components/CategoryColumns' import CategoryColumns from '../components/CategoryColumns'
import ArticleLink, { resolveArticleId } from '../components/ArticleLink'
import CommentSection from '../components/CommentSection' import CommentSection from '../components/CommentSection'
import PractiseTopic from '../components/PractiseTopic' import PractiseTopic from '../components/PractiseTopic'
import { markdownImageUrl } from '../utils/uploads' import { markdownImageUrl } from '../utils/uploads'
@ -13,14 +14,32 @@ import './ArticlesPage.css'
const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('') const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
// educator writes a cross-reference without having to know an article's numeric
// id, which changes nothing for them and everything for a link that has to last.
const WIKI_LINK = /\[\[([^\]|]+?)(?:\|([a-z0-9-]+))?\]\]/g
const expandWikiLinks = (text) => (text || '').replace(WIKI_LINK, (_m, label, slug) =>
`[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`)
const internalSlug = (href) => {
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+)\/?$/.exec(href || '')
return match ? match[1] : null
}
function Markdown({ children, attemptId }) { function Markdown({ children, attemptId }) {
// Educator content renders as Markdown only; raw HTML is escaped, not executed. // Educator content renders as Markdown only; raw HTML is escaped, not executed.
return ( return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ <ReactMarkdown remarkPlugins={[remarkGfm]} components={{
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />, img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />, a: ({ node, href, children: kids, ...props }) => {
const slug = internalSlug(href)
// A link into the library stays in the app and shows what it leads to;
// only links off the site get a new tab.
if (slug) return <ArticleLink slug={slug}>{kids}</ArticleLink>
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{kids}</a>
},
}}> }}>
{children || ''} {expandWikiLinks(children)}
</ReactMarkdown> </ReactMarkdown>
) )
} }
@ -35,7 +54,6 @@ export default function ArticlesPage() {
const [articles, setArticles] = useState([]) const [articles, setArticles] = useState([])
const [categories, setCategories] = useState([]) const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('') const [categoryId, setCategoryId] = useState('')
const [browseOpen, setBrowseOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@ -46,15 +64,20 @@ export default function ArticlesPage() {
const [aiTopic, setAiTopic] = useState('') const [aiTopic, setAiTopic] = useState('')
const [aiInstructions, setAiInstructions] = useState('') const [aiInstructions, setAiInstructions] = useState('')
const [aiStatus, setAiStatus] = useState('') const [aiStatus, setAiStatus] = useState('')
const categoryName = categories.find(c => String(c.id) === String(categoryId))?.name
const navigate = useNavigate() const navigate = useNavigate()
// Articles filed directly under a heading. An article with no category is
// reachable from the top row rather than being invisible.
const articlesIn = useCallback((catId) => articles.filter(
a => (catId === null ? !a.category_id : a.category_id === catId)), [articles])
// The whole library at once: the column browser has to group articles under
// every heading, and a per-category fetch would be a request per click.
const load = useCallback(() => { const load = useCallback(() => {
const params = {} const params = {}
if (categoryId) params.category_id = categoryId
if (query.trim()) params.q = query.trim() if (query.trim()) params.q = query.trim()
api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false)) api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
}, [categoryId, query]) }, [query])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { useEffect(() => {
@ -134,31 +157,36 @@ export default function ArticlesPage() {
)} )}
<div className="articles-toolbar"> <div className="articles-toolbar">
<input className="input" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" /> <input className="input" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" />
<button type="button" className="articles-browse" aria-expanded={browseOpen}
onClick={() => setBrowseOpen(v => !v)}>
{categoryName || 'All categories'} <span aria-hidden="true">{browseOpen ? '▲' : '▼'}</span>
</button>
</div> </div>
{/* Columns rather than one long dropdown: the trail you took stays on {loading ? <div className="loading"><div className="spinner" /></div> : query.trim() ? (
screen, so you can see where you are and step back a level. */} // Searching is a different question from browsing: you already know the
{browseOpen && ( // name, so the answer is the list of matches, not the shelf they sit on.
<div className="articles-browser"> articles.length === 0 ? (
<CategoryColumns categories={categories} selectedId={categoryId === '' ? null : Number(categoryId)} <div className="card empty-state">Nothing matches {query.trim()}.</div>
onSelect={id => setCategoryId(id == null ? '' : String(id))} /> ) : (
</div> <div className="articles-grid">
)} {articles.map(article => (
{loading ? <div className="loading"><div className="spinner" /></div> : articles.length === 0 ? ( <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>}
<span className="article-card-meta">{article.sections?.length || 0} sections</span>
</Link>
))}
</div>
)
) : articles.length === 0 ? (
<div className="card empty-state">No articles yet. Educators add and refine articles gradually.</div> <div className="card empty-state">No articles yet. Educators add and refine articles gradually.</div>
) : ( ) : (
<div className="articles-grid"> /* Columns rather than one long dropdown: the trail you took stays on
{articles.map(article => ( screen, so you can see where you are and step back a level. */
<Link key={article.id} to={`/articles/${article.id}`} className="article-card"> <div className="articles-browser">
<h3>{article.title} <DraftBadge status={article.status} /></h3> <CategoryColumns categories={categories}
{article.summary && <p>{article.summary}</p>} selectedId={categoryId === '' ? null : Number(categoryId)}
<span className="article-card-meta">{article.sections?.length || 0} sections</span> onSelect={id => setCategoryId(id == null ? '' : String(id))}
</Link> articlesOf={articlesIn}
))} onOpenArticle={article => navigate(`/articles/${article.id}`)}
allLabel="Library" />
</div> </div>
)} )}
</div> </div>
@ -167,12 +195,18 @@ export default function ArticlesPage() {
export function ArticlePage() { export function ArticlePage() {
const { user } = useAuth() const { user } = useAuth()
const { id } = useParams() const { id: idParam, slug } = useParams()
// A slug URL resolves to an id once and then behaves like any other article
// page, so there is one loading path rather than two.
const [id, setId] = useState(idParam || null)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [article, setArticle] = useState(null) const [article, setArticle] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [activeSection, setActiveSection] = useState('') const [activeSection, setActiveSection] = useState('')
// 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) const [drawerOpen, setDrawerOpen] = useState(false)
const [questions, setQuestions] = useState([]) const [questions, setQuestions] = useState([])
const [cards, setCards] = useState([]) const [cards, setCards] = useState([])
@ -188,7 +222,16 @@ export function ArticlePage() {
const [refineText, setRefineText] = useState('') const [refineText, setRefineText] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => {
if (idParam) { setId(idParam); return }
if (!slug) return
let live = true
resolveArticleId(slug).then(resolved => { if (live) setId(resolved) })
return () => { live = false }
}, [idParam, slug])
const load = useCallback(() => { const load = useCallback(() => {
if (!id) return
api.get(`/articles/${id}`).then(res => { api.get(`/articles/${id}`).then(res => {
const from = searchParams.get('section') const from = searchParams.get('section')
const sections = res.data.sections || [] const sections = res.data.sections || []
@ -204,10 +247,16 @@ export function ArticlePage() {
}, [id, searchParams]) }, [id, searchParams])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => {
if (id === null && slug) { setError('Article not found'); setLoading(false) }
}, [id, slug])
// Deep link: scroll to the section once it has rendered. // Deep link: open the section (and the one it sits under) and scroll to it.
// Landing on a collapsed heading would look like the link had gone nowhere.
useEffect(() => { useEffect(() => {
if (!activeSection || !article) return if (!activeSection || !article) return
const parentId = (article.sections || []).find(s => s.id === activeSection)?.parent_id
setOpenIds(prev => ({ ...prev, [activeSection]: true, ...(parentId ? { [parentId]: true } : {}) }))
const target = document.getElementById(`section-${activeSection}`) const target = document.getElementById(`section-${activeSection}`)
target?.scrollIntoView?.({ block: 'start' }) target?.scrollIntoView?.({ block: 'start' })
}, [activeSection, article]) }, [activeSection, article])
@ -218,11 +267,16 @@ export function ArticlePage() {
setSearchParams(secId ? { section: secId } : {}) setSearchParams(secId ? { section: secId } : {})
setDrawerOpen(false) setDrawerOpen(false)
if (secId) { if (secId) {
const parentId = (article?.sections || []).find(s => s.id === secId)?.parent_id
setOpenIds(prev => ({ ...prev, [secId]: true, ...(parentId ? { [parentId]: true } : {}) }))
const target = document.getElementById(`section-${secId}`) const target = document.getElementById(`section-${secId}`)
target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }) target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
} }
} }
/** Open or close one section without moving the page. */
const toggleSection = (secId) => setOpenIds(prev => ({ ...prev, [secId]: !prev[secId] }))
const save = async (publish = null) => { const save = async (publish = null) => {
setSaving(true) setSaving(true)
setError('') setError('')
@ -282,6 +336,45 @@ export function ArticlePage() {
const canEdit = user?.is_moderator || article.user_id === user?.id const canEdit = user?.is_moderator || article.user_id === user?.id
const allSections = article.sections || []
const childSections = allSections.filter(sec => sec.parent_id)
const kidsOf = (secId) => childSections.filter(sec => sec.parent_id === secId)
// References belong at the end whatever order they were written in a reader
// scrolling for content should not hit the bibliography halfway down.
const isReferences = (sec) => /^references$/i.test(sec.title || '') || sec.slug === 'references'
const topSections = [
...allSections.filter(sec => !sec.parent_id && !isReferences(sec)),
...allSections.filter(sec => !sec.parent_id && isReferences(sec)),
]
const setAll = (open) => setOpenIds(open
? Object.fromEntries(allSections.map(sec => [sec.id, true]))
: {})
const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
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-${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}
aria-controls={`asec-body-${sec.id}`} onClick={() => toggleSection(sec.id)}>
<span className="asec-title">{sec.title}</span>
<span className="asec-chevron" aria-hidden="true"></span>
</button>
</Heading>
{isOpen && (
<div className="asec-body" id={`asec-body-${sec.id}`}>
<Markdown>{sec.content}</Markdown>
{kids.map(kid => renderSection(kid, depth + 1))}
</div>
)}
</section>
)
}
return ( return (
<div className="article-page"> <div className="article-page">
<nav className="breadcrumbs" aria-label="Breadcrumb"> <nav className="breadcrumbs" aria-label="Breadcrumb">
@ -344,6 +437,15 @@ export function ArticlePage() {
onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, title: e.target.value } : s) }))} /> onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, title: e.target.value } : s) }))} />
<input className="input" style={{ flex: 1 }} value={sec.slug} aria-label="Section slug" <input className="input" style={{ flex: 1 }} value={sec.slug} aria-label="Section slug"
onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, slug: e.target.value } : s) }))} /> onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, slug: e.target.value } : s) }))} />
{/* Only an earlier top-level section can be a parent, which is what
keeps nesting one level deep and a sub-section below its heading. */}
<select className="input" style={{ flex: 1 }} value={sec.parent_id || ''} aria-label={`Parent section for section ${i + 1}`}
onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, parent_id: e.target.value || null } : s) }))}>
<option value="">Top-level section</option>
{form.sections.slice(0, i).filter(candidate => !candidate.parent_id).map(candidate => (
<option key={candidate.id} value={candidate.id}>Under: {candidate.title || candidate.slug}</option>
))}
</select>
<button className="btn btn-sm btn-danger" onClick={() => setForm(f => ({ ...f, sections: f.sections.filter((_, j) => j !== i) }))} <button className="btn btn-sm btn-danger" onClick={() => setForm(f => ({ ...f, sections: f.sections.filter((_, j) => j !== i) }))}
title="Removing a section also removes links to it">Remove</button> title="Removing a section also removes links to it">Remove</button>
</div> </div>
@ -351,7 +453,7 @@ export function ArticlePage() {
</div> </div>
))} ))}
<button className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({ <button className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({
...f, sections: [...f.sections, { id: sectionId(), slug: `section-${f.sections.length + 1}`, title: '', content: '' }], ...f, sections: [...f.sections, { id: sectionId(), slug: `section-${f.sections.length + 1}`, title: '', content: '', parent_id: null }],
}))}>Add section</button> }))}>Add section</button>
<div className="card" style={{ marginTop: 16 }}> <div className="card" style={{ marginTop: 16 }}>
<h4>Link a question</h4> <h4>Link a question</h4>
@ -373,14 +475,28 @@ export function ArticlePage() {
{drawerOpen ? '✕ Close sections' : '☰ Sections'} {drawerOpen ? '✕ Close sections' : '☰ Sections'}
</button> </button>
<aside id="article-sections" className={`article-sections ${drawerOpen ? 'open' : ''}`}> <aside id="article-sections" className={`article-sections ${drawerOpen ? 'open' : ''}`}>
<h4>In this article</h4> <h4>{article.title}</h4>
<ul> <ul className="atoc">
{(article.sections || []).map(sec => ( {topSections.map(sec => (
<li key={sec.id}> <li key={sec.id}>
<button className={activeSection === sec.id ? 'section-link active' : 'section-link'} <button className={activeSection === sec.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(sec.id)}> onClick={() => openSection(sec.id)}>
{sec.title} {sec.title}
</button> </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> </li>
))} ))}
</ul> </ul>
@ -391,20 +507,23 @@ export function ArticlePage() {
)} )}
{article.summary && <p className="article-summary">{article.summary}</p>} {article.summary && <p className="article-summary">{article.summary}</p>}
{article.content && <Markdown>{article.content}</Markdown>} {article.content && <Markdown>{article.content}</Markdown>}
{!article.sections?.length && (!article.summary && !article.content) && ( {!allSections.length && (!article.summary && !article.content) && (
<div className="empty-state">Content is being prepared by educators.</div> <div className="empty-state">Content is being prepared by educators.</div>
)} )}
{/* Sections read straight through, as one page. Opening each in a {/* Headings first, prose on request: the article opens as a contents
modal made a topic impossible to read end to end. */} page you can scan, and each section expands where it sits rather
{(article.sections || []).map(sec => ( than in a modal that loses the thread. */}
<section key={sec.id} id={`section-${sec.id}`} {allSections.length > 0 && (
className={`article-section${activeSection === sec.id ? ' is-target' : ''}`} <div className="asec-controls">
aria-labelledby={`heading-${sec.id}`}> <button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
<h2 id={`heading-${sec.id}`}>{sec.title}</h2> {allOpen ? 'Collapse all' : 'Expand all'}
<Markdown>{sec.content}</Markdown> </button>
</section> </div>
))} )}
<div className="asec-list">
{topSections.map(sec => renderSection(sec, 0))}
</div>
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} /> <PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
{cards.length > 0 && ( {cards.length > 0 && (

View file

@ -39,20 +39,60 @@ describe('topic reading', () => {
expect(screen.queryByText('No articles yet. Educators add and refine articles gradually.')).not.toBeInTheDocument() expect(screen.queryByText('No articles yet. Educators add and refine articles gradually.')).not.toBeInTheDocument()
}) })
it('browses the library column by column, articles beside the topics', async () => {
const cats = [
{ id: 2, name: 'Neurology', parent_id: null, question_count: 10 },
{ id: 3, name: 'Seizures', parent_id: 2, question_count: 4 },
{ id: 4, name: 'Cardiology', parent_id: null, question_count: 7 },
]
const filed = { ...article, category_id: 3 }
const loose = { id: 9, title: 'Unfiled note', slug: 'unfiled', status: 'draft', category_id: null, sections: [] }
api.get.mockImplementation(url => {
if (url === '/question-categories/') return Promise.resolve({ data: cats })
if (url === '/articles/') return Promise.resolve({ data: [filed, loose] })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles']}><Routes><Route path="/articles" element={<ArticlesPage />} /></Routes></MemoryRouter>)
// The root column holds the top-level topics and anything filed nowhere,
// which would otherwise have no heading to be reachable from.
expect(await screen.findByRole('button', { name: /Neurology/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Unfiled note/ })).toBeInTheDocument()
expect(screen.getByText('Draft')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Seizures/ })).not.toBeInTheDocument()
// Opening a topic opens its contents in the next column; the trail stays.
await userEvent.click(screen.getByRole('button', { name: /Neurology/ }))
expect(screen.getByRole('button', { name: /Seizures/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Cardiology/ })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /Seizures/ }))
expect(screen.getByRole('button', { name: /Febrile seizures/ })).toBeInTheDocument()
})
it('searching answers with the matches, not the shelf they sit on', async () => {
render(<MemoryRouter initialEntries={['/articles']}><Routes><Route path="/articles" element={<ArticlesPage />} /></Routes></MemoryRouter>)
await screen.findByText('Febrile seizures')
await userEvent.type(screen.getByLabelText('Search articles'), 'febrile')
expect(await screen.findByRole('link', { name: /Febrile seizures/ })).toBeInTheDocument()
expect(document.querySelector('.cc-columns')).toBeNull()
})
it('renders sections and breadcrumbs, and practises rather than revealing questions', async () => { it('renders sections and breadcrumbs, and practises rather than revealing questions', async () => {
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>) render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument() expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology') expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
expect(screen.getByText('Introduction markdown')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
// Sections read inline as one page, so the body is present without opening anything. // Headings show; the prose waits to be asked for. Nothing opens in a modal.
expect(screen.getByRole('heading', { name: 'Initial workup' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
expect(screen.getByText('Section markdown')).toBeInTheDocument() expect(screen.queryByText('Section markdown')).not.toBeInTheDocument()
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// The contents rail jumps to a section and marks it active. // The contents rail opens a section, marks it active, and shows its body.
await userEvent.click(screen.getByRole('button', { name: 'Initial workup' })) const toc = document.querySelector('.article-sections')
expect(screen.getByRole('button', { name: 'Initial workup' })).toHaveClass('active') await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' }))
expect(within(toc).getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
expect(screen.getByText('Section markdown')).toBeInTheDocument()
// Reading a topic offers a test; it never prints the stem, answer or explanation. // Reading a topic offers a test; it never prints the stem, answer or explanation.
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument() expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
@ -92,11 +132,73 @@ describe('topic reading', () => {
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}) })
render(<MemoryRouter initialEntries={[`/articles/1?section=${'b'.repeat(32)}`]}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>) render(<MemoryRouter initialEntries={[`/articles/1?section=${'b'.repeat(32)}`]}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
// A deep link marks the section without hiding the rest of the article. await screen.findByText('Introduction markdown')
expect(await screen.findByRole('button', { name: 'Second section' })).toHaveClass('active') const toc = document.querySelector('.article-sections')
expect(within(toc).getByRole('button', { name: 'Second section' })).toHaveClass('active')
// The linked section opens; landing on a collapsed heading would look like
// the link had gone nowhere. The rest stay closed.
expect(screen.getByText('Second body')).toBeInTheDocument() expect(screen.getByText('Second body')).toBeInTheDocument()
expect(screen.getByText('First body')).toBeInTheDocument() expect(screen.queryByText('First body')).not.toBeInTheDocument()
expect(screen.getByText('Introduction markdown')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
expect(document.querySelector(`#section-${'b'.repeat(32)}`)).toHaveClass('is-target') expect(document.querySelector(`#section-${'b'.repeat(32)}`)).toHaveClass('is-target')
}) })
it('opens as a contents page and expands a section where it sits', async () => {
const twoSections = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'first', title: 'First section', content: 'First body' },
{ id: 'b'.repeat(32), slug: 'second', title: 'Second section', content: 'Second body' },
] }
api.get.mockImplementation(url => {
if (url === '/articles/1') return Promise.resolve({ data: twoSections })
if (url === '/articles/1/questions') return Promise.resolve({ data: [] })
if (url === '/articles/1/cards') return Promise.resolve({ data: [] })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
await screen.findByText('Introduction markdown')
const body = document.querySelector('.asec-list')
expect(within(body).getByRole('button', { name: /First section/ })).toBeInTheDocument()
expect(screen.queryByText('First body')).not.toBeInTheDocument()
expect(screen.queryByText('Second body')).not.toBeInTheDocument()
await userEvent.click(within(body).getByRole('button', { name: /First section/ }))
expect(screen.getByText('First body')).toBeInTheDocument()
expect(screen.queryByText('Second body')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Expand all' }))
expect(screen.getByText('Second body')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Collapse all' }))
expect(screen.queryByText('First body')).not.toBeInTheDocument()
})
it('nests a sub-section under its parent and keeps references last', async () => {
const nested = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'ros', title: 'Review of systems', content: 'ROS body' },
{ id: 'c'.repeat(32), slug: 'references', title: 'References', content: '1. Nelson' },
{ id: 'b'.repeat(32), slug: 'ros-questionnaire', title: 'ROS questionnaire', content: 'Questionnaire body', parent_id: 'a'.repeat(32) },
] }
api.get.mockImplementation(url => {
if (url === '/articles/1') return Promise.resolve({ data: nested })
if (url === '/articles/1/questions') return Promise.resolve({ data: [] })
if (url === '/articles/1/cards') return Promise.resolve({ data: [] })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
// The contents list the sub-section under its parent, not alongside it.
await screen.findByText('Introduction markdown')
const toc = document.querySelector('.article-sections')
expect(within(toc).getByRole('button', { name: 'ROS questionnaire' }).closest('.atoc-sub')).toBeTruthy()
// References sit last however they were written.
const headings = [...document.querySelectorAll('.asec-list > .asec > .asec-heading .asec-title')].map(el => el.textContent)
expect(headings).toEqual(['Review of systems', 'References'])
// The sub-section is inside the parent, so it only appears once the parent opens.
expect(within(document.querySelector('.asec-list')).queryByRole('button', { name: /ROS questionnaire/ })).not.toBeInTheDocument()
await userEvent.click(within(document.querySelector('.asec-list')).getByRole('button', { name: /Review of systems/ }))
expect(document.querySelector('.asec-depth-1 .asec-title').textContent).toBe('ROS questionnaire')
})
}) })