chore: remove QuestionEditModal
Nothing had imported it since editing moved to the full page; the only thing that still referred to it was its own test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
1d55b76998
commit
1df520c95d
11 changed files with 673 additions and 475 deletions
|
|
@ -271,13 +271,8 @@ Captured so nothing is lost while the article writing runs.
|
|||
MediaAsset already stores one path per image, so a `thumb_path` beside it
|
||||
plus a Caddy cache rule is the shape.
|
||||
|
||||
- [ ] **QuestionEditModal is dead code** — nothing has imported it since Edit
|
||||
moved to the full page. Its sibling CreateQuestionModal was removed when
|
||||
its last two callers were replaced; this one was already unreferenced, so
|
||||
it is left for a deliberate decision rather than swept up.
|
||||
|
||||
## Quiz runner
|
||||
|
||||
- [x] **QuestionEditModal is dead code** — removed 2026-09-12, with its
|
||||
test. Nothing had imported it since Edit moved to the full page.
|
||||
- [x] **Per-question notes** — done. The notes themselves were already built
|
||||
(`question_notes`, saved on blur); what was missing was removing the
|
||||
global notes tab that floated over the same screen, so it was never clear
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Suspense, useEffect, useState } from 'react'
|
|||
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { SessionDrawerProvider } from './context/SessionDrawer'
|
||||
import { SessionChromeProvider, useInSession } from './context/SessionChrome'
|
||||
import { SessionChromeProvider, useFullBleed, useInSession } from './context/SessionChrome'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import SiteFooter from './components/SiteFooter'
|
||||
|
|
@ -65,6 +65,7 @@ function AppLayout() {
|
|||
const location = useLocation()
|
||||
const [searching, setSearching] = useState(false)
|
||||
const inSession = useInSession()
|
||||
const fullBleed = useFullBleed()
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event) => {
|
||||
|
|
@ -98,8 +99,9 @@ function AppLayout() {
|
|||
{/* A session takes the window. The page's own gutters and 1200px cap
|
||||
are for reading; a player is a fixed-height box whose columns scroll
|
||||
inside it, and every pixel the container reserves is a pixel the
|
||||
question does not get. */}
|
||||
<div className={`app-main${inSession ? ' is-session' : ' container'}`}>
|
||||
question does not get. An article asks for the same width without
|
||||
the rest of the session chrome — hence two classes rather than one. */}
|
||||
<div className={`app-main${fullBleed ? ' is-bleed' : ' container'}${inSession ? ' is-session' : ''}`}>
|
||||
<ErrorBoundary key={location.pathname}>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import RichText from './RichText'
|
||||
/**
|
||||
* Article prose goes through the same renderer as everything else.
|
||||
|
|
@ -15,9 +15,17 @@ export function Markdown({ children, attemptId }) {
|
|||
* An article as it reads: contents rail, summary, and sections that expand
|
||||
* where they sit.
|
||||
*
|
||||
* One renderer serves both the page and the second pane beside it, so a
|
||||
* cross-reference opened in split view behaves like the article it came from
|
||||
* rather than like a cut-down copy of it.
|
||||
* The reading page hands the window to this component — the rail meets the
|
||||
* left edge and the prose takes everything to the right of it. Capping the
|
||||
* page at 1080px and centring it left a thin ribbon of text in the middle of a
|
||||
* wide screen with the rail floating somewhere in the margin, which is the
|
||||
* complaint this layout answers.
|
||||
*
|
||||
* `bare` is the second article read beside the first. That pane used to render
|
||||
* the whole of this component, rail and all, inside a column half the width of
|
||||
* the page: two contents lists and two scrollbars for one paragraph of prose,
|
||||
* each cross-reference pushing the text further right. A pane is a column to
|
||||
* read, not a page to navigate, so it gets the prose and nothing else.
|
||||
*
|
||||
* `activeSection` is the caller's, because the page keeps it in the URL for deep
|
||||
* links and the pane keeps it to itself; everything else — what is open, the
|
||||
|
|
@ -36,17 +44,77 @@ const VIEWS = [
|
|||
{ key: 'clinical', label: 'Clinical' },
|
||||
]
|
||||
|
||||
export default function ArticleReader({ article, activeSection = '', onOpenSection, idPrefix = '', landmark = true, children }) {
|
||||
// Remembered rather than reset on every article, the way the session player
|
||||
// remembers its own rail: hiding the contents is a statement about how you
|
||||
// read, not about the page you happened to be on when you said it.
|
||||
const RAIL_KEY = 'pedshub.articleRail'
|
||||
const SIZE_KEY = 'pedshub.articleText'
|
||||
const SIZES = ['s', 'm', 'l']
|
||||
|
||||
const stored = (key, fallback) => {
|
||||
try { return localStorage.getItem(key) ?? fallback } catch { return fallback }
|
||||
}
|
||||
const remember = (key, value) => {
|
||||
try { localStorage.setItem(key, value) } catch { /* private browsing: the choice lasts the visit */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far down the window the sticky furniture has to start.
|
||||
*
|
||||
* Measured off the header rather than written down as a number, because the
|
||||
* navbar's section strip collapses as you scroll and a hard-coded offset would
|
||||
* either leave a band of page showing above the rail or hide the first line of
|
||||
* it behind the bar. A second copy of the navbar's scroll logic would only be
|
||||
* a guess about somebody else's component; its height is the fact itself.
|
||||
*/
|
||||
function useHeaderOffset() {
|
||||
const [top, setTop] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined
|
||||
const bar = document.querySelector('.navbar')
|
||||
if (!bar) return undefined
|
||||
const measure = () => setTop(Math.round(bar.getBoundingClientRect().height))
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return undefined
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(bar)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return top
|
||||
}
|
||||
|
||||
export default function ArticleReader({
|
||||
article, activeSection = '', onOpenSection, idPrefix = '', landmark = true,
|
||||
// No rail, no bar, no chrome: the reader used as a second column.
|
||||
bare = false,
|
||||
// The trail back to the library, and the article's own icon toolbar. Owned
|
||||
// by the page because publishing and editing are the page's business, drawn
|
||||
// here because they belong above the title rather than above the layout.
|
||||
breadcrumbs = null, toolbar = null,
|
||||
// A second column to the right of the prose, inside the same grid — so the
|
||||
// rail stays flush left and the pane can never push the article sideways.
|
||||
aside = null,
|
||||
children,
|
||||
}) {
|
||||
// Which sections are open. Everything starts closed: an article is a reference
|
||||
// you consult, and a wall of prose hides the one heading you came for.
|
||||
const [openIds, setOpenIds] = useState({})
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
// Collapsing the contents rail hands its width to the prose.
|
||||
const [railOpen, setRailOpen] = useState(true)
|
||||
const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed')
|
||||
const [size, setSize] = useState(() => (SIZES.includes(stored(SIZE_KEY, 'm')) ? stored(SIZE_KEY, 'm') : 'm'))
|
||||
// The section under the reader's eye, which is not the same thing as the one
|
||||
// they last clicked: the bar names where you are, so it has to follow the
|
||||
// scroll rather than the last deep link.
|
||||
const [reading, setReading] = useState('')
|
||||
const root = useRef(null)
|
||||
const headerTop = useHeaderOffset()
|
||||
// A page has one main landmark, so the second reader on it is not one.
|
||||
const Content = landmark ? 'main' : 'div'
|
||||
|
||||
useEffect(() => { remember(RAIL_KEY, railOpen ? 'open' : 'closed') }, [railOpen])
|
||||
useEffect(() => { remember(SIZE_KEY, size) }, [size])
|
||||
|
||||
// Older articles have no variant on their sections; they are the full article.
|
||||
const everySection = (article.sections || []).map(
|
||||
sec => ({ ...sec, variant: sec.variant || 'long' }))
|
||||
|
|
@ -59,10 +127,12 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
|||
// space — which read as the view having no content at all.
|
||||
useEffect(() => {
|
||||
const tops = allSections.filter(sec => !sec.parent_id)
|
||||
if (tops.length === 1) {
|
||||
// A pane has no contents list to navigate by, so a wall of chevrons there
|
||||
// is a wall of dead ends: the cross-reference opens as prose.
|
||||
if (bare || tops.length === 1) {
|
||||
setOpenIds(Object.fromEntries(allSections.map(sec => [sec.id, true])))
|
||||
}
|
||||
}, [view, article])
|
||||
}, [view, article, bare])
|
||||
// Element ids are prefixed because two readers can share a page, and a
|
||||
// duplicate id would point the pane's contents at the article behind it.
|
||||
const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`)
|
||||
|
|
@ -108,12 +178,40 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
|||
// shown as prose rather than as a contents page of one entry.
|
||||
const soleSection = topSections.length === 1 && allSections.length === 1 ? topSections[0] : null
|
||||
|
||||
/**
|
||||
* Which heading the reader is under.
|
||||
*
|
||||
* The bar names it once the title has scrolled away, and the rail marks the
|
||||
* same one — a rail pointing at a heading you left three screens ago is
|
||||
* worse than a rail pointing at nothing.
|
||||
*/
|
||||
const openKey = Object.keys(openIds).filter(id => openIds[id]).sort().join(',')
|
||||
useEffect(() => {
|
||||
if (bare || typeof IntersectionObserver === 'undefined') return undefined
|
||||
const nodes = [...(root.current?.querySelectorAll('[data-section-id]') || [])]
|
||||
if (!nodes.length) return undefined
|
||||
const seen = new Map()
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => seen.set(entry.target, entry))
|
||||
const live = [...seen.values()].filter(entry => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
// Nothing in the band means the reader is above the first heading or
|
||||
// below the last; either way the previous answer is still the best one.
|
||||
if (live.length) setReading(live[0].target.dataset.sectionId)
|
||||
}, { rootMargin: `-${headerTop + 56}px 0px -62% 0px` })
|
||||
nodes.forEach(node => observer.observe(node))
|
||||
return () => observer.disconnect()
|
||||
}, [article, view, openKey, headerTop, bare])
|
||||
|
||||
const marked = reading || activeSection
|
||||
const readingTitle = allSections.find(sec => sec.id === reading)?.title || ''
|
||||
|
||||
const renderSection = (sec, depth) => {
|
||||
const isOpen = !!openIds[sec.id]
|
||||
const kids = kidsOf(sec.id)
|
||||
const Heading = depth === 0 ? 'h2' : 'h3'
|
||||
return (
|
||||
<section key={sec.id} id={`section-${idPrefix}${sec.id}`}
|
||||
<section key={sec.id} id={`section-${idPrefix}${sec.id}`} data-section-id={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}
|
||||
|
|
@ -132,115 +230,191 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
|||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}`} ref={root}>
|
||||
<button className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)}
|
||||
aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
|
||||
{drawerOpen ? '✕ Close sections' : '☰ Sections'}
|
||||
</button>
|
||||
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
|
||||
<button type="button" className="article-rail-toggle" aria-expanded={railOpen}
|
||||
aria-label={railOpen ? 'Collapse contents' : 'Show contents'}
|
||||
onClick={() => setRailOpen(v => !v)}>
|
||||
<span aria-hidden="true">{railOpen ? '‹' : '›'}</span>
|
||||
const prose = (
|
||||
<>
|
||||
{/* The summary is prose, so it is rendered as prose. Printed raw it put
|
||||
`[[288|eczema]]` in front of the reader on 58 articles — the syntax
|
||||
an educator writes a cross-reference in, shown to the person it was
|
||||
written for. */}
|
||||
{article.summary && <RichText className="article-summary" value={article.summary} linkArticles />}
|
||||
{article.content && <Markdown>{article.content}</Markdown>}
|
||||
{!allSections.length && (!article.summary && !article.content) && (
|
||||
<div className="empty-state">Content is being prepared by educators.</div>
|
||||
)}
|
||||
<div className="asec-list">
|
||||
{/* A view with one section does not need a heading over it, or a
|
||||
control to collapse the only thing there is. The tab already
|
||||
names it — "Short" then a heading reading "In short" says the
|
||||
same word twice and hides the content behind a chevron. */}
|
||||
{soleSection ? (
|
||||
<div className="asec-sole" id={`section-${idPrefix}${soleSection.id}`}>
|
||||
<Markdown>{soleSection.content}</Markdown>
|
||||
</div>
|
||||
) : topSections.map(sec => renderSection(sec, 0))}
|
||||
</div>
|
||||
|
||||
{(article.references || []).length > 0 && (
|
||||
<section className="article-references">
|
||||
<h2>References</h2>
|
||||
{/* Sources for the whole article, not markers in the prose: a
|
||||
learner checking a claim wants the book and the page, and a
|
||||
sentence peppered with superscripts is harder to read. */}
|
||||
<ol>
|
||||
{article.references.map((ref, index) => (
|
||||
<li key={`${ref.title}-${index}`}>
|
||||
<span className="article-ref-title">{ref.title}</span>
|
||||
{ref.author && <span className="article-ref-author"> — {ref.author}</span>}
|
||||
{(ref.pages || []).length > 0 && (
|
||||
<span className="article-ref-pages"> · p. {ref.pages.join(', ')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
// A pane is a column of reading. Everything the page around it already
|
||||
// provides — the rail, the controls, the title bar — would be a second copy
|
||||
// in half the width.
|
||||
if (bare) {
|
||||
return (
|
||||
<div className={`article-bare article-text-${size}`} ref={root}>{prose}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the topic to read, where AMBOSS puts the same choice: in the
|
||||
* row above the sections, at the right. It used to sit below the summary
|
||||
* with the expand-all button, which is inside the article rather than over
|
||||
* it — and the rail is filtered by the same choice, so a reader who cannot
|
||||
* see the switch cannot explain why half the contents just went away.
|
||||
*/
|
||||
const viewSwitch = present.length > 1 && (
|
||||
<div className="aview-switch" role="tablist" aria-label="How much of this topic to read">
|
||||
{present.map(option => (
|
||||
<button key={option.key} type="button" role="tab"
|
||||
aria-selected={view === option.key}
|
||||
className={`aview${view === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => { setView(option.key); setOpenIds({}); setReading('') }}>
|
||||
{option.label}
|
||||
</button>
|
||||
<h4>{article.title}</h4>
|
||||
{/* A contents list of one entry is not a contents list. */}
|
||||
{!soleSection && (
|
||||
<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>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}${aside ? ' has-aside' : ''}`}
|
||||
ref={root} style={{ '--article-top': `${headerTop}px` }}>
|
||||
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
|
||||
<div className="article-rail-scroll">
|
||||
<div className="article-rail-head">
|
||||
<h4>{article.title}</h4>
|
||||
{/* On the boundary between the rail and the prose, which is where
|
||||
the reader is looking when they decide they want the width. */}
|
||||
<button type="button" className="article-rail-hide" aria-expanded={railOpen}
|
||||
aria-label="Collapse contents" onClick={() => setRailOpen(false)}>
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* A contents list of one entry is not a contents list. */}
|
||||
{!soleSection && (
|
||||
<ul className="atoc">
|
||||
{topSections.map(sec => (
|
||||
<li key={sec.id}>
|
||||
<button className={marked === 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={marked === kid.id ? 'section-link active' : 'section-link'}
|
||||
onClick={() => openSection(kid.id)}>
|
||||
{kid.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<Content className="article-content">
|
||||
{/* Where the rail was, so putting it back is where you last saw it. */}
|
||||
{!railOpen && (
|
||||
<button type="button" className="article-rail-reopen" aria-expanded={railOpen}
|
||||
aria-label="Show contents" onClick={() => setRailOpen(true)}>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
)}
|
||||
<Content className={`article-content article-text-${size}`}>
|
||||
<div className="article-top">
|
||||
<div className="article-trail">{breadcrumbs}</div>
|
||||
<div className="article-tools">
|
||||
{toolbar}
|
||||
{/* Three steps, not a slider: a reader wants bigger or smaller,
|
||||
and a control with twelve answers to that is a control you
|
||||
have to think about mid-paragraph. */}
|
||||
<button type="button" className="article-tool"
|
||||
aria-label={`Text size: ${{ s: 'small', m: 'medium', l: 'large' }[size]}`}
|
||||
onClick={() => setSize(SIZES[(SIZES.indexOf(size) + 1) % SIZES.length])}>
|
||||
<span aria-hidden="true">A</span><span aria-hidden="true" className="article-tool-a">a</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{article.updated_at && (
|
||||
<p className="article-updated">Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}</p>
|
||||
)}
|
||||
{article.summary && <p className="article-summary">{article.summary}</p>}
|
||||
{article.content && <Markdown>{article.content}</Markdown>}
|
||||
{!allSections.length && (!article.summary && !article.content) && (
|
||||
<div className="empty-state">Content is being prepared by educators.</div>
|
||||
)}
|
||||
<h1 className="article-title">
|
||||
{article.title}
|
||||
{/* Drawn here rather than passed in, because a draft badge belongs
|
||||
to the title and the title now lives with the reader. */}
|
||||
{article.status && article.status !== 'published' && (
|
||||
<span className="article-status-draft">Draft</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{/* Headings first, prose on request: the article opens as a contents
|
||||
page you can scan, and each section expands where it sits rather
|
||||
than in a modal that loses the thread. */}
|
||||
than in a modal that loses the thread. The row sticks under the
|
||||
header, so the controls stay put and the trail can say where in the
|
||||
article you have got to once the title has gone. */}
|
||||
<div className="asec-controls">
|
||||
{/* Only the views this article actually has: an empty tab is a
|
||||
promise the article cannot keep. */}
|
||||
{present.length > 1 && (
|
||||
<div className="aview-switch" role="tablist" aria-label="How to read this article">
|
||||
{present.map(option => (
|
||||
<button key={option.key} type="button" role="tab"
|
||||
aria-selected={view === option.key}
|
||||
className={`aview${view === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => { setView(option.key); setOpenIds({}) }}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allSections.length > 0 && !soleSection && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
|
||||
{allOpen ? 'Collapse all' : 'Expand all'}
|
||||
<div className="asec-controls-left">
|
||||
<button type="button" className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)}
|
||||
aria-expanded={drawerOpen} aria-controls={`${idPrefix}article-sections`}>
|
||||
{drawerOpen ? '✕ Close contents' : '☰ Contents'}
|
||||
</button>
|
||||
)}
|
||||
{readingTitle && (
|
||||
<span className="asec-where">
|
||||
<span className="asec-where-article">{article.title}</span>
|
||||
<span aria-hidden="true"> › </span>
|
||||
<span className="asec-where-section">{readingTitle}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="asec-controls-right">
|
||||
{allSections.length > 0 && !soleSection && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
|
||||
{allOpen ? 'Collapse all' : 'Expand all'}
|
||||
</button>
|
||||
)}
|
||||
{/* Only the views this article actually has: an empty tab is a
|
||||
promise the article cannot keep. */}
|
||||
{viewSwitch}
|
||||
</div>
|
||||
</div>
|
||||
<div className="asec-list">
|
||||
{/* A view with one section does not need a heading over it, or a
|
||||
control to collapse the only thing there is. The tab already
|
||||
names it — "Short" then a heading reading "In short" says the
|
||||
same word twice and hides the content behind a chevron. */}
|
||||
{soleSection ? (
|
||||
<div className="asec-sole" id={`section-${idPrefix}${soleSection.id}`}>
|
||||
<Markdown>{soleSection.content}</Markdown>
|
||||
</div>
|
||||
) : topSections.map(sec => renderSection(sec, 0))}
|
||||
</div>
|
||||
|
||||
{(article.references || []).length > 0 && (
|
||||
<section className="article-references">
|
||||
<h2>References</h2>
|
||||
{/* Sources for the whole article, not markers in the prose: a
|
||||
learner checking a claim wants the book and the page, and a
|
||||
sentence peppered with superscripts is harder to read. */}
|
||||
<ol>
|
||||
{article.references.map((ref, index) => (
|
||||
<li key={`${ref.title}-${index}`}>
|
||||
<span className="article-ref-title">{ref.title}</span>
|
||||
{ref.author && <span className="article-ref-author"> — {ref.author}</span>}
|
||||
{(ref.pages || []).length > 0 && (
|
||||
<span className="article-ref-pages"> · p. {ref.pages.join(', ')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{prose}
|
||||
</Content>
|
||||
{aside}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
45
frontend/src/components/ArticleSaveButton.css
Normal file
45
frontend/src/components/ArticleSaveButton.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* Keeping a topic: the star in the article's toolbar, and the panel behind it. */
|
||||
|
||||
.asave { position: relative; display: inline-flex; }
|
||||
|
||||
.asave-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
width: min(280px, calc(100vw - 32px));
|
||||
padding: 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, .16);
|
||||
text-align: left;
|
||||
}
|
||||
.asave-title { margin: 0 0 2px; font-size: .84rem; font-weight: 700; }
|
||||
.asave-note, .asave-empty { margin: 0 0 8px; font-size: .76rem; color: var(--text-muted); line-height: 1.45; }
|
||||
.asave-empty { margin-bottom: 0; }
|
||||
|
||||
.asave-find {
|
||||
width: 100%; padding: 7px 9px; margin-bottom: 8px;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font: inherit; font-size: .84rem;
|
||||
}
|
||||
.asave-find:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
|
||||
|
||||
.asave-new {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
width: 100%; margin-bottom: 8px; padding: 8px 10px;
|
||||
border: 1px dashed var(--border); border-radius: 8px; background: none;
|
||||
font: inherit; font-size: .84rem; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.asave-new:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.asave-new span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.asave-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 210px; overflow-y: auto; }
|
||||
.asave-list button {
|
||||
width: 100%; text-align: left; padding: 7px 9px;
|
||||
border: 0; border-radius: 7px; background: none;
|
||||
font: inherit; font-size: .84rem; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.asave-list button:hover { background: var(--option-hover, #eef4fb); }
|
||||
.asave-list button.is-in { color: var(--text-muted); cursor: default; }
|
||||
155
frontend/src/components/ArticleSaveButton.jsx
Normal file
155
frontend/src/components/ArticleSaveButton.jsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import './ArticleSaveButton.css'
|
||||
|
||||
/**
|
||||
* Keep this topic.
|
||||
*
|
||||
* Libraries hold questions — `user_collection_questions` has a question_id and
|
||||
* nothing else — so saving a topic saves the questions written against it,
|
||||
* which is what you would want it for: the reading and the practice on one
|
||||
* subject, filed together, ready to sit later. Nothing here invents a second
|
||||
* place to save things; it is the same endpoint the session player writes to
|
||||
* when you file a question mid-attempt.
|
||||
*
|
||||
* The consequence is that the server cannot be asked "is this article saved?",
|
||||
* only "is this question in that library?", and answering it properly would
|
||||
* mean fetching every library's contents on every article — which also stamps
|
||||
* each one as used and reorders the reader's own list. So the answer is kept
|
||||
* on the device that gave it. It is honest about a save that happened; it will
|
||||
* not know about one made on another machine. An `article_id` on the join
|
||||
* table would settle it, and is the right fix when the API can change.
|
||||
*/
|
||||
const SAVED_KEY = 'pedshub.articleLibraries'
|
||||
|
||||
const readSaved = () => {
|
||||
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '{}') } catch { return {} }
|
||||
}
|
||||
const writeSaved = (next) => {
|
||||
try { localStorage.setItem(SAVED_KEY, JSON.stringify(next)) } catch { /* the visit still knows */ }
|
||||
}
|
||||
|
||||
export default function ArticleSaveButton({ articleId, questionIds = [] }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [libraries, setLibraries] = useState([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState(() => readSaved()[String(articleId)] || [])
|
||||
const wrap = useRef(null)
|
||||
|
||||
useEffect(() => { setSaved(readSaved()[String(articleId)] || []) }, [articleId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
api.get('/collections/').then(res => setLibraries(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setLibraries([]))
|
||||
}, [open])
|
||||
|
||||
// A popover that outlives the click that dismissed it is a popover you have
|
||||
// to close twice.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const away = (event) => { if (!wrap.current?.contains(event.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => document.removeEventListener('mousedown', away)
|
||||
}, [open])
|
||||
|
||||
const record = useCallback((collectionId) => {
|
||||
setSaved(prev => {
|
||||
if (prev.includes(collectionId)) return prev
|
||||
const next = [...prev, collectionId]
|
||||
writeSaved({ ...readSaved(), [String(articleId)]: next })
|
||||
return next
|
||||
})
|
||||
}, [articleId])
|
||||
|
||||
const saveInto = async (collectionId) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
// Sequential rather than parallel: a topic with forty questions firing
|
||||
// forty writes at once is a burst the rate limiter reads as abuse.
|
||||
for (const questionId of questionIds) {
|
||||
await api.put(`/collections/${collectionId}/questions/${questionId}`)
|
||||
}
|
||||
record(collectionId)
|
||||
setQuery('')
|
||||
} catch { /* nothing is recorded, which is the honest signal */ }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const createAndSave = async (title) => {
|
||||
const name = title.trim()
|
||||
if (!name) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const made = await api.post('/collections/', { title: name })
|
||||
setLibraries(list => [...list, made.data])
|
||||
setBusy(false)
|
||||
await saveInto(made.data.id)
|
||||
} catch { setBusy(false) }
|
||||
}
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
const found = libraries.filter(row => row.title.toLowerCase().includes(needle))
|
||||
// Offered only when nothing you already have is called this — two libraries
|
||||
// of the same name is a filing system nobody can use.
|
||||
const canCreate = !!needle && !libraries.some(row => row.title.trim().toLowerCase() === needle)
|
||||
const isSaved = saved.length > 0
|
||||
|
||||
return (
|
||||
<span className="asave" ref={wrap}>
|
||||
<button type="button" className={`article-tool${isSaved ? ' is-on' : ''}`}
|
||||
aria-expanded={open} aria-haspopup="dialog"
|
||||
aria-label={isSaved ? `Saved to ${saved.length} librar${saved.length === 1 ? 'y' : 'ies'}` : 'Save this topic to a library'}
|
||||
onClick={() => setOpen(v => !v)}>
|
||||
<span aria-hidden="true">{isSaved ? '★' : '☆'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="asave-pop" role="dialog" aria-label="Save this topic to a library">
|
||||
<p className="asave-title">Save this topic</p>
|
||||
{questionIds.length === 0 ? (
|
||||
/* Said plainly rather than offering a control that would file
|
||||
nothing: a library holds questions, and this topic has none yet. */
|
||||
<p className="asave-empty">No questions are linked to this topic yet, so there is nothing to file. Link one and it can be saved with the reading.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="asave-note">Files its {questionIds.length} linked question{questionIds.length === 1 ? '' : 's'} so you can sit them later.</p>
|
||||
{/* One box for both. Searching what you have and naming what you
|
||||
do not are the same act — you type the name of the library
|
||||
you want and it either exists or it doesn't. */}
|
||||
<input className="asave-find" value={query} disabled={busy}
|
||||
placeholder="Create or find a library" aria-label="Create or find a library"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && canCreate) { event.preventDefault(); createAndSave(query) }
|
||||
}} />
|
||||
{canCreate && (
|
||||
<button type="button" className="asave-new" disabled={busy} onClick={() => createAndSave(query)}>
|
||||
<span>{query.trim()}</span><span aria-hidden="true">+</span>
|
||||
</button>
|
||||
)}
|
||||
{found.length > 0 && (
|
||||
<ul className="asave-list">
|
||||
{found.map(row => {
|
||||
const inIt = saved.includes(row.id)
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt || busy}
|
||||
onClick={() => saveInto(row.id)}>
|
||||
{inIt ? '✓ ' : '+ '}{row.title}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!found.length && !canCreate && (
|
||||
<p className="asave-empty">{libraries.length ? 'No library by that name.' : 'Type a name to make your first library.'}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,18 +11,28 @@ import './ArticleSplitPane.css'
|
|||
* The point of split view is that the article you came from stays put, so this
|
||||
* pane carries only the reading — no comments, no practice, no editing — and
|
||||
* the full article is one click away in the heading for anything more.
|
||||
*
|
||||
* It is one column of a grid the reading page already owns, not a second page
|
||||
* nested inside the first. It used to render the whole reader, contents rail
|
||||
* included, into a box half the width of the screen: two rails, two
|
||||
* scrollbars, and prose that started further right with every cross-reference
|
||||
* followed. A fixed column and a `bare` reader mean the article on the left
|
||||
* reflows once, when the pane opens, and never again however deep you go.
|
||||
*
|
||||
* Depth is a trail rather than a stack of panes. Following a reference from
|
||||
* inside the pane replaces what is in it and offers the way back, which is the
|
||||
* thing that was actually wanted: read the aside, then return to the sentence
|
||||
* that sent you.
|
||||
*/
|
||||
export default function ArticleSplitPane({ slug, onClose }) {
|
||||
export default function ArticleSplitPane({ slug, onClose, onBack, depth = 0 }) {
|
||||
const [article, setArticle] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [activeSection, setActiveSection] = useState('')
|
||||
const pane = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setArticle(null)
|
||||
setError('')
|
||||
setActiveSection('')
|
||||
// The slug's id has already been fetched by the preview card that offered
|
||||
// the split, so opening the pane costs one request, not two.
|
||||
resolveArticleId(slug)
|
||||
|
|
@ -42,6 +52,12 @@ export default function ArticleSplitPane({ slug, onClose }) {
|
|||
aria-label={article ? `Split view: ${article.title}` : 'Split view'}
|
||||
onKeyDown={event => { if (event.key === 'Escape') onClose() }}>
|
||||
<header className="asplit-head">
|
||||
{depth > 0 && (
|
||||
<button type="button" className="asplit-back" onClick={onBack}
|
||||
aria-label="Back to the previous cross-reference">
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
)}
|
||||
<Link className="asplit-title" to={`/articles/s/${slug}`}>{article ? article.title : 'Opening…'}</Link>
|
||||
<button type="button" className="asplit-close" onClick={onClose} aria-label="Close split view">
|
||||
<span aria-hidden="true">✕</span>
|
||||
|
|
@ -50,8 +66,7 @@ export default function ArticleSplitPane({ slug, onClose }) {
|
|||
<div className="asplit-body">
|
||||
{error ? <div className="empty-state">{error}</div>
|
||||
: !article ? <div className="loading"><div className="spinner" /></div>
|
||||
: <ArticleReader article={article} idPrefix="pane-" landmark={false}
|
||||
activeSection={activeSection} onOpenSection={setActiveSection} />}
|
||||
: <ArticleReader article={article} idPrefix="pane-" landmark={false} bare />}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
/* Question authoring modals shared by the question bank and the question manager. */
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import lazyPage from '../utils/lazyPage'
|
||||
import api from '../api/client'
|
||||
import CategoryTree from './CategoryTree'
|
||||
|
||||
const RichEditor = lazyPage(() => import('../components/RichEditor'))
|
||||
|
||||
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
||||
|
||||
function apiError(err, fallback) {
|
||||
const detail = err?.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map(item => typeof item?.msg === 'string' ? item.msg : '').filter(Boolean).join('; ') || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
||||
const [form, setForm] = useState({
|
||||
question_text: question.question_text,
|
||||
question_type: question.question_type,
|
||||
options: question.options ? [...question.options] : [],
|
||||
correct_answer: question.correct_answer,
|
||||
explanation: question.explanation || '',
|
||||
question_category_id: question.question_category_id || '',
|
||||
extraCategoryIds: (question.category_ids || []).filter(id => id !== (question.question_category_id || null)),
|
||||
option_explanations: { ...(question.option_explanations || {}) },
|
||||
key_points: (question.key_points || []).map(p => ({ text: p.text, article_id: p.article_id || '', article_section_id: p.article_section_id || '' })),
|
||||
difficulty: question.difficulty || '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const setOption = (i, val) => {
|
||||
const updated = [...form.options]
|
||||
const wasCorrect = form.options[i] === form.correct_answer
|
||||
const oldValue = updated[i]
|
||||
updated[i] = val
|
||||
setForm(f => {
|
||||
const explanations = { ...f.option_explanations }
|
||||
if (oldValue in explanations) {
|
||||
explanations[val] = explanations[oldValue]
|
||||
delete explanations[oldValue]
|
||||
}
|
||||
return { ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer, option_explanations: explanations }
|
||||
})
|
||||
}
|
||||
|
||||
const setKeyPoint = (index, field, value) => {
|
||||
setForm(f => ({ ...f, key_points: f.key_points.map((p, i) => i === index ? { ...p, [field]: value } : p) }))
|
||||
}
|
||||
|
||||
const setOptionExplanation = (option, value) => {
|
||||
setForm(f => {
|
||||
const explanations = { ...f.option_explanations }
|
||||
if (value.trim()) explanations[option] = value
|
||||
else delete explanations[option]
|
||||
return { ...f, option_explanations: explanations }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleExtra = (categoryId) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
extraCategoryIds: f.extraCategoryIds.includes(categoryId)
|
||||
? f.extraCategoryIds.filter(id => id !== categoryId)
|
||||
: [...f.extraCategoryIds, categoryId],
|
||||
}))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form.question_text.trim()) return setError('Question text is required')
|
||||
if (form.options.length > 0 && !form.options.includes(form.correct_answer))
|
||||
return setError('Correct answer must match one of the options')
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const primary = form.question_category_id ? parseInt(form.question_category_id) : null
|
||||
const payload = {
|
||||
...form,
|
||||
question_category_id: primary,
|
||||
additional_category_ids: form.extraCategoryIds.filter(id => id !== primary),
|
||||
option_explanations: Object.keys(form.option_explanations).length ? form.option_explanations : null,
|
||||
key_points: form.key_points.filter(p => p.text.trim()).map(p => ({
|
||||
text: p.text.trim(),
|
||||
article_id: p.article_id ? parseInt(p.article_id, 10) : null,
|
||||
article_section_id: p.article_section_id || null,
|
||||
})),
|
||||
difficulty: form.difficulty || null,
|
||||
}
|
||||
const res = await api.patch(`/questions/${question.id}`, payload)
|
||||
onSaved({ ...question, ...res.data,
|
||||
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
|
||||
onClose()
|
||||
} catch (err) { setError(apiError(err, 'Save failed')) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div role="dialog" aria-label="Edit Question" style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginBottom: 16 }}>
|
||||
This question is shared — changes apply to all quizzes using it.
|
||||
</p>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label>Question Text</label>
|
||||
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
|
||||
style={{ fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
{form.options.length > 0 && (
|
||||
<div className="form-group">
|
||||
<label>Options — select the correct one</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{form.options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="radio" name="correct" checked={form.correct_answer === opt}
|
||||
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
|
||||
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
|
||||
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
|
||||
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
|
||||
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
|
||||
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<details open={!!form.option_explanations[opt]} style={{ marginLeft: 32 }}>
|
||||
<summary style={{ fontSize: '0.74rem', color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||
Explain this option{form.option_explanations[opt] ? ' ✓' : ''}
|
||||
</summary>
|
||||
<textarea rows={2} placeholder="Why this option is wrong or right — shown in study mode"
|
||||
value={form.option_explanations[opt] || ''} aria-label={`Explanation for option ${opt}`}
|
||||
onChange={e => setOptionExplanation(opt, e.target.value)}
|
||||
style={{ width: '100%', marginTop: 4, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
|
||||
</details>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Categories</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
|
||||
Primary category drives the main listing; additional categories place the question in every selected branch.
|
||||
</p>
|
||||
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
|
||||
<option value="">— Uncategorized —</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name}</option>)}
|
||||
</select>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
selectedIds={form.extraCategoryIds}
|
||||
excludedId={form.question_category_id ? parseInt(form.question_category_id) : null}
|
||||
onToggle={toggleExtra}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Key points (smart links to reading)</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
|
||||
Short take-aways shown in study feedback; link a point to an article or a section to open it directly.
|
||||
</p>
|
||||
{form.key_points.map((point, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<input type="text" value={point.text} placeholder="Key point" aria-label={`Key point ${i + 1}`}
|
||||
onChange={e => setKeyPoint(i, 'text', e.target.value)}
|
||||
style={{ flex: 2, minWidth: 160, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
<input type="text" value={point.article_id} placeholder="Article ID" aria-label={`Key point ${i + 1} article`}
|
||||
onChange={e => setKeyPoint(i, 'article_id', e.target.value)}
|
||||
style={{ width: 90, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
<input type="text" value={point.article_section_id} placeholder="Section ID" aria-label={`Key point ${i + 1} section`}
|
||||
onChange={e => setKeyPoint(i, 'article_section_id', e.target.value)}
|
||||
style={{ width: 110, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({ ...f, key_points: f.key_points.filter((_, j) => j !== i) }))}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({ ...f, key_points: [...f.key_points, { text: '', article_id: '', article_section_id: '' }] }))}>+ Add key point</button>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Difficulty</label>
|
||||
<select value={form.difficulty || ''} onChange={e => setForm(f => ({ ...f, difficulty: e.target.value || null }))}>
|
||||
<option value="">— Not set —</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Explanation</label>
|
||||
<textarea rows={6} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
|
||||
style={{ fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QuestionEditModal } from './QuestionEditors'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
const CATS = [
|
||||
{ id: 1, name: 'Neonatology', parent_id: null, breadcrumbs: [{ id: 1, name: 'Neonatology' }] },
|
||||
{ id: 2, name: 'Cardiology', parent_id: null, breadcrumbs: [{ id: 2, name: 'Cardiology' }] },
|
||||
{ id: 3, name: 'Renal', parent_id: null, breadcrumbs: [{ id: 3, name: 'Renal' }] },
|
||||
]
|
||||
|
||||
const question = (overrides = {}) => ({
|
||||
id: 7, question_text: 'Edit me', question_type: 'mcq', options: ['A', 'B'],
|
||||
correct_answer: 'A', explanation: '', question_category_id: 1, category_ids: [1], ...overrides,
|
||||
})
|
||||
|
||||
// The quick modal the question manager uses for a small correction; the full
|
||||
// page at /questions/:id is where a question is edited properly.
|
||||
const mount = (q = question()) => render(
|
||||
<MemoryRouter>
|
||||
<QuestionEditModal question={q} categories={CATS} onSaved={() => {}} onClose={() => {}} />
|
||||
</MemoryRouter>)
|
||||
|
||||
describe('quick question edit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: [] })
|
||||
api.patch.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('saves additional subcategories and excludes the primary from extras', async () => {
|
||||
mount(question({ category_ids: [1, 2] }))
|
||||
const modal = await screen.findByRole('dialog', { name: 'Edit Question' })
|
||||
expect(within(modal).getByLabelText(/Cardiology/)).toBeChecked()
|
||||
expect(within(modal).getByLabelText(/Renal/)).not.toBeChecked()
|
||||
// The primary category cannot also be an extra, so it is not offered as one.
|
||||
expect(within(modal).getByLabelText(/Neonatology/)).toBeDisabled()
|
||||
|
||||
await userEvent.click(within(modal).getByLabelText(/Renal/))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
|
||||
additional_category_ids: [2, 3],
|
||||
question_category_id: 1,
|
||||
})))
|
||||
})
|
||||
|
||||
it('saves per-option explanations keyed by option text', async () => {
|
||||
mount()
|
||||
await screen.findByRole('dialog', { name: 'Edit Question' })
|
||||
await userEvent.click(screen.getAllByText('Explain this option')[0])
|
||||
await userEvent.type(screen.getByLabelText('Explanation for option A'), 'Right because of this')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
|
||||
option_explanations: { A: 'Right because of this' },
|
||||
})))
|
||||
})
|
||||
})
|
||||
|
|
@ -36,6 +36,23 @@ const expandWikiLinks = (text) => (text || '').replace(
|
|||
: `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`),
|
||||
)
|
||||
|
||||
/**
|
||||
* The same cross-references, reduced to the words an educator wrote.
|
||||
*
|
||||
* A summary is shown in two places that want different things from it. At the
|
||||
* head of an article it is prose, so it goes through the renderer and its
|
||||
* references are live. In a card or a list row it is a one-line description
|
||||
* inside something that is already a link, and an `ArticleLink` nested there
|
||||
* would swallow the click that was meant to open the card — so there the
|
||||
* markup is flattened to its labels rather than rendered at all.
|
||||
*
|
||||
* Either way `[[288|eczema]]` never reaches a reader as brackets and a number.
|
||||
*/
|
||||
export const plainWikiText = (text) => (text || '').replace(
|
||||
WIKI_LINK,
|
||||
(_m, _id, idLabel, label) => (idLabel || label || '').trim(),
|
||||
)
|
||||
|
||||
const internalArticle = (href) => {
|
||||
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '')
|
||||
return match ? match[1] : null
|
||||
|
|
|
|||
|
|
@ -13,32 +13,53 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
|||
* Counted rather than flagged, so a player unmounting as another mounts — a
|
||||
* route change from one attempt straight into another — cannot leave the shell
|
||||
* stuck in the wrong state.
|
||||
*
|
||||
* Two claims, not one. Being in a session did two things at once: it gave the
|
||||
* page the whole window, and it took the navbar's section strip away. A
|
||||
* reading page wants the first and not the second — every link on that strip
|
||||
* leaves a session you are sitting, which is the argument for hiding it, and
|
||||
* none of that is true of an article you can wander away from at any time. So
|
||||
* width and chrome are claimed separately, and a player claims both.
|
||||
*/
|
||||
const Context = createContext({ inSession: false, enter: () => () => {} })
|
||||
const Context = createContext({ inSession: false, fullBleed: false, enter: () => () => {} })
|
||||
|
||||
export function SessionChromeProvider({ children }) {
|
||||
const [depth, setDepth] = useState(0)
|
||||
const [depth, setDepth] = useState({ session: 0, bleed: 0 })
|
||||
const value = useMemo(() => ({
|
||||
inSession: depth > 0,
|
||||
enter: () => {
|
||||
setDepth(n => n + 1)
|
||||
return () => setDepth(n => Math.max(0, n - 1))
|
||||
inSession: depth.session > 0,
|
||||
// A session takes the window too, so it need not ask for it twice.
|
||||
fullBleed: depth.session > 0 || depth.bleed > 0,
|
||||
enter: (kind) => {
|
||||
setDepth(d => ({ ...d, [kind]: d[kind] + 1 }))
|
||||
return () => setDepth(d => ({ ...d, [kind]: Math.max(0, d[kind] - 1) }))
|
||||
},
|
||||
}), [depth])
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
/** Read by the shell and the navbar. */
|
||||
/** Read by the navbar: is a session open that its section strip would break? */
|
||||
export const useInSession = () => useContext(Context).inSession
|
||||
|
||||
/** Called by a player while it is mounted. */
|
||||
export function useClaimSessionChrome(active = true) {
|
||||
/** Read by the shell: does anything on screen want the window without gutters? */
|
||||
export const useFullBleed = () => useContext(Context).fullBleed
|
||||
|
||||
function useClaim(kind, active) {
|
||||
const { enter } = useContext(Context)
|
||||
useEffect(() => {
|
||||
if (!active) return undefined
|
||||
return enter()
|
||||
return enter(kind)
|
||||
// `enter` is stable in intent but changes identity with the depth it sets;
|
||||
// depending on it here would release and re-take on every change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active])
|
||||
}, [kind, active])
|
||||
}
|
||||
|
||||
/** Called by a player while it is mounted: the window, and no section strip. */
|
||||
export function useClaimSessionChrome(active = true) {
|
||||
useClaim('session', active)
|
||||
}
|
||||
|
||||
/** The window without the gutters, with the site's navigation left alone. */
|
||||
export function useClaimFullBleed(active = true) {
|
||||
useClaim('bleed', active)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ import ArticleRevisions from '../components/ArticleRevisions'
|
|||
import CategoryColumns from '../components/CategoryColumns'
|
||||
import { resolveArticleId } from '../components/ArticleLink'
|
||||
import ArticleReader from '../components/ArticleReader'
|
||||
import ArticleSaveButton from '../components/ArticleSaveButton'
|
||||
import ArticleSplitPane from '../components/ArticleSplitPane'
|
||||
import PractiseTopic from '../components/PractiseTopic'
|
||||
import { plainWikiText } from '../components/RichText'
|
||||
import { useClaimFullBleed } from '../context/SessionChrome'
|
||||
import './ArticlesPage.css'
|
||||
|
||||
const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
|
@ -140,7 +143,11 @@ export default function ArticlesPage() {
|
|||
{articles.map(article => (
|
||||
<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>}
|
||||
{/* Flattened, not rendered: the card is itself a link, and a
|
||||
live cross-reference inside one would eat the click that
|
||||
was meant to open the article. Printed raw it showed the
|
||||
reader `[[288|eczema]]`. */}
|
||||
{article.summary && <p>{plainWikiText(article.summary)}</p>}
|
||||
<span className="article-card-meta">{article.section_count ?? 0} sections</span>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -175,8 +182,11 @@ export function ArticlePage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [activeSection, setActiveSection] = useState('')
|
||||
// The cross-reference being read beside this article, by slug.
|
||||
const [splitSlug, setSplitSlug] = useState(null)
|
||||
// The cross-references being read beside this article, oldest first. A trail
|
||||
// rather than a single slug: following a reference from inside the pane used
|
||||
// to overwrite the one that sent you there, so the way back was the browser
|
||||
// and the browser leaves the article behind.
|
||||
const [splitTrail, setSplitTrail] = useState([])
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [cards, setCards] = useState([])
|
||||
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
|
||||
|
|
@ -228,9 +238,13 @@ export function ArticlePage() {
|
|||
}
|
||||
|
||||
// Both readers open a cross-reference into the same slot, so one followed
|
||||
// from inside the pane replaces what is there instead of splitting again.
|
||||
const splitView = useMemo(() => ({ open: setSplitSlug, inPane: false }), [])
|
||||
const paneView = useMemo(() => ({ open: setSplitSlug, inPane: true }), [])
|
||||
// from inside the pane replaces what is there instead of splitting again —
|
||||
// there is never more than one pane, whatever the depth of the trail.
|
||||
const pushSplit = useCallback((slug) => setSplitTrail(
|
||||
trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
|
||||
const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit])
|
||||
const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit])
|
||||
const splitSlug = splitTrail.at(-1) || null
|
||||
|
||||
const save = async (publish = null) => {
|
||||
setSaving(true)
|
||||
|
|
@ -291,36 +305,35 @@ export function ArticlePage() {
|
|||
|
||||
const canEdit = user?.is_moderator || article.user_id === user?.id
|
||||
|
||||
return (
|
||||
<div className={`article-page${splitSlug ? ' is-split' : ''}`}>
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<Link to="/articles">Reading</Link>
|
||||
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
|
||||
<span> / {article.title}</span>
|
||||
</nav>
|
||||
<div className="article-header">
|
||||
<div>
|
||||
<h1>{article.title} <DraftBadge status={article.status} /></h1>
|
||||
<p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p>
|
||||
</div>
|
||||
<div className="article-header-actions">
|
||||
{user?.is_moderator && !editing && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => runAi('cards')} disabled={!!aiJob}>Generate cards</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(v => !v)} disabled={!!aiJob}>AI refine</button>
|
||||
</>
|
||||
)}
|
||||
{canEdit && !editing && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => save(article.status === 'published' ? false : true)}>
|
||||
{article.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editing && <button className="btn btn-primary btn-sm" onClick={() => save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
const breadcrumbs = (
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<Link to="/articles">Reading</Link>
|
||||
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
|
||||
<span> / {article.title}</span>
|
||||
</nav>
|
||||
)
|
||||
|
||||
const editorActions = (
|
||||
<>
|
||||
{user?.is_moderator && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => runAi('cards')} disabled={!!aiJob}>Generate cards</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(v => !v)} disabled={!!aiJob}>AI refine</button>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => save(article.status === 'published' ? false : true)}>
|
||||
{article.status === 'published' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const notices = (
|
||||
<>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
{showRefine && user?.is_moderator && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -334,55 +347,81 @@ export function ArticlePage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
{editing && form ? (
|
||||
<div>
|
||||
<ArticleEditor form={form} setForm={setForm} />
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h4>Link a question</h4>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Enter a bank question ID, optionally scoped to one section.</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input className="input" style={{ maxWidth: 180 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
|
||||
<select className="input" value={linkSection} onChange={e => setLinkSection(e.target.value)} aria-label="Section scope">
|
||||
<option value="">Whole article</option>
|
||||
{form.sections.map(s => <option key={s.id} value={s.id}>{s.title || `Section ${s.slug}`}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-secondary btn-sm" onClick={linkQuestion}>Link</button>
|
||||
</div>
|
||||
{linkError && <div className="form-error" role="alert">{linkError}</div>}
|
||||
if (editing && form) {
|
||||
return (
|
||||
<div className="article-page">
|
||||
{breadcrumbs}
|
||||
<div className="article-header">
|
||||
<div><h1>{article.title} <DraftBadge status={article.status} /></h1></div>
|
||||
<div className="article-header-actions">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
|
||||
<ArticleRevisions articleId={article.id} canRestore={!!user?.is_moderator}
|
||||
onRestored={() => { setEditing(false); load() }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`article-split${splitSlug ? ' is-open' : ''}`}>
|
||||
<div className="article-split-main">
|
||||
<SplitViewProvider value={splitView}>
|
||||
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}>
|
||||
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
|
||||
{cards.length > 0 && (
|
||||
<div className="article-linked">
|
||||
<h3>Related cards</h3>
|
||||
{cards.map(card => (
|
||||
<div key={card.card_id} className="linked-card">
|
||||
<div><strong>{card.front}</strong> <span className="article-card-meta">→ {card.back}</span></div>
|
||||
<Link className="btn btn-sm btn-secondary" to={`/flashcards/${card.deck_id}/study`}>Study deck</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ArticleReader>
|
||||
</SplitViewProvider>
|
||||
{notices}
|
||||
<ArticleEditor form={form} setForm={setForm} />
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h4>Link a question</h4>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Enter a bank question ID, optionally scoped to one section.</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input className="input" style={{ maxWidth: 180 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
|
||||
<select className="input" value={linkSection} onChange={e => setLinkSection(e.target.value)} aria-label="Section scope">
|
||||
<option value="">Whole article</option>
|
||||
{form.sections.map(s => <option key={s.id} value={s.id}>{s.title || `Section ${s.slug}`}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-secondary btn-sm" onClick={linkQuestion}>Link</button>
|
||||
</div>
|
||||
{splitSlug && (
|
||||
{linkError && <div className="form-error" role="alert">{linkError}</div>}
|
||||
</div>
|
||||
|
||||
<ArticleRevisions articleId={article.id} canRestore={!!user?.is_moderator}
|
||||
onRestored={() => { setEditing(false); load() }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
/* Reading takes the window. The rail meets the left edge of the screen and
|
||||
the prose takes the rest, which is what the 1080px cap and the auto
|
||||
margins were preventing — on a wide monitor the article was a ribbon
|
||||
down the middle with empty page either side. The editor keeps the
|
||||
gutters: a form field two thousand pixels wide is nobody's idea of an
|
||||
improvement. */
|
||||
<div className="article-page is-reading">
|
||||
{notices && <div className="article-notices">{notices}</div>}
|
||||
<SplitViewProvider value={splitView}>
|
||||
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}
|
||||
breadcrumbs={breadcrumbs}
|
||||
toolbar={
|
||||
<>
|
||||
<ArticleSaveButton articleId={article.id} questionIds={questions.map(q => q.question_id)} />
|
||||
{editorActions}
|
||||
</>
|
||||
}
|
||||
aside={splitSlug ? (
|
||||
<SplitViewProvider value={paneView}>
|
||||
<ArticleSplitPane slug={splitSlug} onClose={() => setSplitSlug(null)} />
|
||||
<ArticleSplitPane slug={splitSlug} depth={splitTrail.length - 1}
|
||||
onBack={() => setSplitTrail(trail => trail.slice(0, -1))}
|
||||
onClose={() => setSplitTrail([])} />
|
||||
</SplitViewProvider>
|
||||
) : null}>
|
||||
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
|
||||
{cards.length > 0 && (
|
||||
<div className="article-linked">
|
||||
<h3>Related cards</h3>
|
||||
{cards.map(card => (
|
||||
<div key={card.card_id} className="linked-card">
|
||||
<div><strong>{card.front}</strong> <span className="article-card-meta">→ {card.back}</span></div>
|
||||
<Link className="btn btn-sm btn-secondary" to={`/flashcards/${card.deck_id}/study`}>Study deck</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ArticleReader>
|
||||
</SplitViewProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue