From 362c48926b5289bb4fa7148658dab8bc28cd806d Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 11:42:33 +0200 Subject: [PATCH] feat: browse the library in columns, read articles section by section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z --- backend/app/routers/articles.py | 62 ++++++ backend/tests/test_articles_cards.py | 83 ++++++++ docs/TODO.md | 19 +- frontend/src/App.jsx | 3 + frontend/src/components/ArticleLink.css | 40 ++++ frontend/src/components/ArticleLink.jsx | 80 +++++++ frontend/src/components/ArticleLink.test.jsx | 75 +++++++ frontend/src/components/CategoryColumns.css | 15 ++ frontend/src/components/CategoryColumns.jsx | 74 +++++-- frontend/src/pages/ArticlesPage.css | 67 ++++-- frontend/src/pages/ArticlesPage.jsx | 207 +++++++++++++++---- frontend/src/pages/ArticlesPage.test.jsx | 124 ++++++++++- 12 files changed, 753 insertions(+), 96 deletions(-) create mode 100644 frontend/src/components/ArticleLink.css create mode 100644 frontend/src/components/ArticleLink.jsx create mode 100644 frontend/src/components/ArticleLink.test.jsx diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index 912a0fb..796def9 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -32,6 +32,10 @@ class ArticleSection(BaseModel): slug: str title: 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): @@ -117,6 +121,21 @@ def _validate_sections(sections: list[ArticleSection]): ids.add(section.id) 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): return {section["id"] for section in (article.sections or [])} @@ -300,6 +319,49 @@ def recently_viewed( 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}") def get_article( article_id: int, diff --git a/backend/tests/test_articles_cards.py b/backend/tests/test_articles_cards.py index 65a5484..b64a2fd 100644 --- a/backend/tests/test_articles_cards.py +++ b/backend/tests/test_articles_cards.py @@ -203,3 +203,86 @@ class ArticlesCardsTests(unittest.TestCase): if __name__ == '__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 diff --git a/docs/TODO.md b/docs/TODO.md index fe7cb6e..b7753d6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -51,10 +51,21 @@ Updated 2026-09-10. ## Article reading -- [ ] **Nested sections** — sub-sections under a section, with a breadcrumb - (`Article › Section`) and per-section collapse. -- [ ] **References** — numbered list per article, with superscript markers in the - body linking down to them. +- [x] **Nested sections and per-section collapse** — done 2026-09-10. A section + may sit under an earlier top-level one (`parent_id` on the section JSON), + the contents rail lists sub-sections under their parent, and an article + 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 section, and a feedback channel to the educator. - [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index afcc319..cd2afdf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -101,6 +101,9 @@ function AppRoutes() { } /> } /> } /> + {/* Cross-references in article prose address a topic by slug, which + outlives a numeric id and is what an educator actually writes. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css new file mode 100644 index 0000000..574f375 --- /dev/null +++ b/frontend/src/components/ArticleLink.css @@ -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; } +} diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx new file mode 100644 index 0000000..d8ee7fd --- /dev/null +++ b/frontend/src/components/ArticleLink.jsx @@ -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 ( + + + {children} + + {open && preview && ( + + {preview.title} + {preview.excerpt && {preview.excerpt}} + + {preview.section_count} section{preview.section_count === 1 ? '' : 's'} + {preview.status !== 'published' && ' · draft'} + + + )} + + ) +} diff --git a/frontend/src/components/ArticleLink.test.jsx b/frontend/src/components/ArticleLink.test.jsx new file mode 100644 index 0000000..e38bbdb --- /dev/null +++ b/frontend/src/components/ArticleLink.test.jsx @@ -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( + febrile seizures) + +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() + }) +}) diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css index d796a3a..135192a 100644 --- a/frontend/src/components/CategoryColumns.css +++ b/frontend/src/components/CategoryColumns.css @@ -72,3 +72,18 @@ .cc-columns { overflow-x: visible; } .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); } diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx index b55971b..5785461 100644 --- a/frontend/src/components/CategoryColumns.jsx +++ b/frontend/src/components/CategoryColumns.jsx @@ -1,18 +1,40 @@ import { useMemo, useState } from 'react' import './CategoryColumns.css' +/** A folder of further topics. */ +const FolderIcon = () => ( + +) + +/** A thing you can actually read. */ +const ArticleIcon = () => ( + +) + /** - * 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 * 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 * back button: the same navigation, drawn for the width available, rather than * 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. 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]) - // One column per opened level: the roots, then each opened parent's children. - const columns = [{ parentId: 0, items: childrenOf[0] || [] }] + const articlesIn = (categoryId) => (articlesOf ? articlesOf(categoryId) : []) + + // 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) { const kids = childrenOf[id] || [] - if (!kids.length) break - columns.push({ parentId: id, items: kids }) + const articles = articlesIn(id) + if (!kids.length && !articles.length) break + columns.push({ parentId: id, items: kids, articles }) } const openAt = (level, cat) => { - const next = [...path.slice(0, level), cat.id] - setPath(childrenOf[cat.id]?.length ? next : path.slice(0, level)) + const hasContents = (childrenOf[cat.id] || []).length > 0 || articlesIn(cat.id).length > 0 + setPath(hasContents ? [...path.slice(0, level), cat.id] : path.slice(0, level)) onSelect(cat.id) } @@ -57,14 +84,6 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL {level === 0 ? allLabel : byId[column.parentId]?.name}
    - {level === 0 && ( -
  • - -
  • - )} {column.items.map(cat => { const kids = childrenOf[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' : ''}`} aria-current={selectedId === cat.id ? 'true' : undefined} onClick={() => openAt(level, cat)}> + {cat.name} {rollup[cat.id] ?? cat.question_count} - {kids.length > 0 && } + {(kids.length > 0 || articlesIn(cat.id).length > 0) && ( + + )} ) })} + {/* 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 => ( +
  • + +
  • + ))} + {level > 0 && !column.items.length && !column.articles.length && ( +
  • Nothing filed here yet.
  • + )}
) diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 8495fdb..c3a78b1 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -28,9 +28,6 @@ .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-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-drawer-toggle { display: none; margin-bottom: 10px; } .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-actions { display: flex; gap: 6px; margin-top: 8px; } .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; } } -.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 ──────────────────────────────────────────── */ .article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; } @@ -133,10 +118,6 @@ .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 */ .articles-browse { @@ -147,3 +128,51 @@ .articles-browse:hover { border-color: var(--primary); color: var(--primary); } .articles-browse[aria-expanded='true'] { border-color: var(--primary); color: var(--primary); } .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; } +} diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 1642d87..365a3a6 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -6,6 +6,7 @@ import api from '../api/client' import { useAuth } from '../context/AuthContext' import RichEditor from '../components/RichEditor' import CategoryColumns from '../components/CategoryColumns' +import ArticleLink, { resolveArticleId } from '../components/ArticleLink' import CommentSection from '../components/CommentSection' import PractiseTopic from '../components/PractiseTopic' 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('') +// [[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 }) { // Educator content renders as Markdown only; raw HTML is escaped, not executed. return ( , - a: ({ node, ...props }) => , + 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 {kids} + return {kids} + }, }}> - {children || ''} + {expandWikiLinks(children)} ) } @@ -35,7 +54,6 @@ export default function ArticlesPage() { const [articles, setArticles] = useState([]) const [categories, setCategories] = useState([]) const [categoryId, setCategoryId] = useState('') - const [browseOpen, setBrowseOpen] = useState(false) const [query, setQuery] = useState('') const [loading, setLoading] = useState(true) const [showCreate, setShowCreate] = useState(false) @@ -46,15 +64,20 @@ export default function ArticlesPage() { const [aiTopic, setAiTopic] = useState('') const [aiInstructions, setAiInstructions] = useState('') const [aiStatus, setAiStatus] = useState('') - const categoryName = categories.find(c => String(c.id) === String(categoryId))?.name 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 params = {} - if (categoryId) params.category_id = categoryId if (query.trim()) params.q = query.trim() api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false)) - }, [categoryId, query]) + }, [query]) useEffect(() => { load() }, [load]) useEffect(() => { @@ -134,31 +157,36 @@ export default function ArticlesPage() { )}
setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" /> -
- {/* Columns rather than one long dropdown: the trail you took stays on - screen, so you can see where you are and step back a level. */} - {browseOpen && ( -
- setCategoryId(id == null ? '' : String(id))} /> -
- )} - {loading ?
: articles.length === 0 ? ( + {loading ?
: query.trim() ? ( + // Searching is a different question from browsing: you already know the + // name, so the answer is the list of matches, not the shelf they sit on. + articles.length === 0 ? ( +
Nothing matches “{query.trim()}”.
+ ) : ( +
+ {articles.map(article => ( + +

{article.title}

+ {article.summary &&

{article.summary}

} + {article.sections?.length || 0} sections + + ))} +
+ ) + ) : articles.length === 0 ? (
No articles yet. Educators add and refine articles gradually.
) : ( -
- {articles.map(article => ( - -

{article.title}

- {article.summary &&

{article.summary}

} - {article.sections?.length || 0} sections - - ))} + /* Columns rather than one long dropdown: the trail you took stays on + screen, so you can see where you are and step back a level. */ +
+ setCategoryId(id == null ? '' : String(id))} + articlesOf={articlesIn} + onOpenArticle={article => navigate(`/articles/${article.id}`)} + allLabel="Library" />
)}
@@ -167,12 +195,18 @@ export default function ArticlesPage() { export function ArticlePage() { 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 [article, setArticle] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = 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 [questions, setQuestions] = useState([]) const [cards, setCards] = useState([]) @@ -188,7 +222,16 @@ export function ArticlePage() { const [refineText, setRefineText] = useState('') 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(() => { + if (!id) return api.get(`/articles/${id}`).then(res => { const from = searchParams.get('section') const sections = res.data.sections || [] @@ -204,10 +247,16 @@ export function ArticlePage() { }, [id, searchParams]) 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(() => { 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}`) target?.scrollIntoView?.({ block: 'start' }) }, [activeSection, article]) @@ -218,11 +267,16 @@ export function ArticlePage() { setSearchParams(secId ? { section: secId } : {}) setDrawerOpen(false) 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}`) 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) => { setSaving(true) setError('') @@ -282,6 +336,45 @@ export function ArticlePage() { 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 ( +
+ + + + {isOpen && ( +
+ {sec.content} + {kids.map(kid => renderSection(kid, depth + 1))} +
+ )} +
+ ) + } + return (
@@ -351,7 +453,7 @@ export function ArticlePage() {
))}

Link a question

@@ -373,14 +475,28 @@ export function ArticlePage() { {drawerOpen ? '✕ Close sections' : '☰ Sections'}