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'}