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:
parent
df15a14983
commit
362c48926b
12 changed files with 753 additions and 96 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
|
|
|
|||
19
docs/TODO.md
19
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
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ function AppRoutes() {
|
|||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<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="/courses" element={<CoursesPage />} />
|
||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||
|
|
|
|||
40
frontend/src/components/ArticleLink.css
Normal file
40
frontend/src/components/ArticleLink.css
Normal 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; }
|
||||
}
|
||||
80
frontend/src/components/ArticleLink.jsx
Normal file
80
frontend/src/components/ArticleLink.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/ArticleLink.test.jsx
Normal file
75
frontend/src/components/ArticleLink.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -1,18 +1,40 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
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
|
||||
* 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}
|
||||
</div>
|
||||
<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 => {
|
||||
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)}>
|
||||
<FolderIcon />
|
||||
<span className="cc-name">{cat.name}</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>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
)}
|
||||
<div className="articles-toolbar">
|
||||
<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>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="articles-browser">
|
||||
<CategoryColumns categories={categories} selectedId={categoryId === '' ? null : Number(categoryId)}
|
||||
onSelect={id => setCategoryId(id == null ? '' : String(id))} />
|
||||
</div>
|
||||
)}
|
||||
{loading ? <div className="loading"><div className="spinner" /></div> : articles.length === 0 ? (
|
||||
{loading ? <div className="loading"><div className="spinner" /></div> : 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 ? (
|
||||
<div className="card empty-state">Nothing matches “{query.trim()}”.</div>
|
||||
) : (
|
||||
<div className="articles-grid">
|
||||
{articles.map(article => (
|
||||
<Link key={article.id} to={`/articles/${article.id}`} className="article-card">
|
||||
<h3>{article.title} <DraftBadge status={article.status} /></h3>
|
||||
{article.summary && <p>{article.summary}</p>}
|
||||
<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="articles-grid">
|
||||
{articles.map(article => (
|
||||
<Link key={article.id} to={`/articles/${article.id}`} className="article-card">
|
||||
<h3>{article.title} <DraftBadge status={article.status} /></h3>
|
||||
{article.summary && <p>{article.summary}</p>}
|
||||
<span className="article-card-meta">{article.sections?.length || 0} sections</span>
|
||||
</Link>
|
||||
))}
|
||||
/* 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. */
|
||||
<div className="articles-browser">
|
||||
<CategoryColumns categories={categories}
|
||||
selectedId={categoryId === '' ? null : Number(categoryId)}
|
||||
onSelect={id => setCategoryId(id == null ? '' : String(id))}
|
||||
articlesOf={articlesIn}
|
||||
onOpenArticle={article => navigate(`/articles/${article.id}`)}
|
||||
allLabel="Library" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<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 (
|
||||
<div className="article-page">
|
||||
<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) }))} />
|
||||
<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) }))} />
|
||||
{/* 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) }))}
|
||||
title="Removing a section also removes links to it">Remove</button>
|
||||
</div>
|
||||
|
|
@ -351,7 +453,7 @@ export function ArticlePage() {
|
|||
</div>
|
||||
))}
|
||||
<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>
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h4>Link a question</h4>
|
||||
|
|
@ -373,14 +475,28 @@ export function ArticlePage() {
|
|||
{drawerOpen ? '✕ Close sections' : '☰ Sections'}
|
||||
</button>
|
||||
<aside id="article-sections" className={`article-sections ${drawerOpen ? 'open' : ''}`}>
|
||||
<h4>In this article</h4>
|
||||
<ul>
|
||||
{(article.sections || []).map(sec => (
|
||||
<h4>{article.title}</h4>
|
||||
<ul className="atoc">
|
||||
{topSections.map(sec => (
|
||||
<li key={sec.id}>
|
||||
<button className={activeSection === sec.id ? 'section-link active' : 'section-link'}
|
||||
onClick={() => openSection(sec.id)}>
|
||||
{sec.title}
|
||||
</button>
|
||||
{/* A sub-section is listed under its parent, not alongside it,
|
||||
so the contents show the shape of the article. */}
|
||||
{kidsOf(sec.id).length > 0 && (
|
||||
<ul className="atoc-sub">
|
||||
{kidsOf(sec.id).map(kid => (
|
||||
<li key={kid.id}>
|
||||
<button className={activeSection === kid.id ? 'section-link active' : 'section-link'}
|
||||
onClick={() => openSection(kid.id)}>
|
||||
{kid.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -391,20 +507,23 @@ export function ArticlePage() {
|
|||
)}
|
||||
{article.summary && <p className="article-summary">{article.summary}</p>}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Sections read straight through, as one page. Opening each in a
|
||||
modal made a topic impossible to read end to end. */}
|
||||
{(article.sections || []).map(sec => (
|
||||
<section key={sec.id} id={`section-${sec.id}`}
|
||||
className={`article-section${activeSection === sec.id ? ' is-target' : ''}`}
|
||||
aria-labelledby={`heading-${sec.id}`}>
|
||||
<h2 id={`heading-${sec.id}`}>{sec.title}</h2>
|
||||
<Markdown>{sec.content}</Markdown>
|
||||
</section>
|
||||
))}
|
||||
{/* Headings first, prose on request: the article opens as a contents
|
||||
page you can scan, and each section expands where it sits rather
|
||||
than in a modal that loses the thread. */}
|
||||
{allSections.length > 0 && (
|
||||
<div className="asec-controls">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
|
||||
{allOpen ? 'Collapse all' : 'Expand all'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="asec-list">
|
||||
{topSections.map(sec => renderSection(sec, 0))}
|
||||
</div>
|
||||
|
||||
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
|
||||
{cards.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -39,20 +39,60 @@ describe('topic reading', () => {
|
|||
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 () => {
|
||||
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.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
// Sections read inline as one page, so the body is present without opening anything.
|
||||
expect(screen.getByRole('heading', { name: 'Initial workup' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Section markdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
// Headings show; the prose waits to be asked for. Nothing opens in a modal.
|
||||
expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Section markdown')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
// The contents rail jumps to a section and marks it active.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Initial workup' }))
|
||||
expect(screen.getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
|
||||
// The contents rail opens a section, marks it active, and shows its body.
|
||||
const toc = document.querySelector('.article-sections')
|
||||
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.
|
||||
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
||||
|
|
@ -92,11 +132,73 @@ describe('topic reading', () => {
|
|||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
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.
|
||||
expect(await screen.findByRole('button', { name: 'Second section' })).toHaveClass('active')
|
||||
await screen.findByText('Introduction markdown')
|
||||
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('First body')).toBeInTheDocument()
|
||||
expect(screen.queryByText('First body')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue