diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index c6b0107..0131a1d 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -27,6 +27,10 @@ .article-content img { max-width: 100%; border-radius: 8px; } .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; } @@ -61,6 +65,26 @@ .comment-content p { margin: 0 0 4px; } .comment-actions { display: flex; gap: 6px; margin-top: 8px; } .comment-load-more { margin-top: 10px; } +.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; } +.comment-heading { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } +.comment-heading h3 { margin: 0; font-size: 1rem; } +.comment-subtitle { font-size: .74rem; color: var(--text-muted); } +.comment-compose { background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px; margin-bottom: 14px; } +.comment-input { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: .88rem; resize: vertical; } +.comment-compose-footer { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.comment-count { margin-left: auto; font-size: .72rem; color: var(--text-muted); } +.comment-empty { color: var(--text-muted); font-size: .84rem; margin: 6px 0 0; } +.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.comment { display: flex; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); } +.comment-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 700; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; } +.comment-body { flex: 1; min-width: 0; } +.comment-meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; font-size: .84rem; } +.comment-meta span { color: var(--text-muted); font-size: .74rem; } +.comment-badge { font-style: normal; font-size: .66rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 1px 8px; border-radius: 10px; } +.comment-content { font-size: .88rem; } +.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; } } @@ -108,3 +132,8 @@ .article-practise-controls label { flex: 1; } .article-practise-controls .btn { width: 100%; } } + +@media (max-width: 640px) { + .article-section { margin-top: 20px; } + .article-section > h2 { font-size: 1.06rem; } +} diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index dc8d119..711dee9 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -193,11 +193,22 @@ export function ArticlePage() { useEffect(() => { load() }, [load]) - const section = article?.sections?.find(s => s.id === activeSection) + // Deep link: scroll to the section once it has rendered. + useEffect(() => { + if (!activeSection || !article) return + const target = document.getElementById(`section-${activeSection}`) + target?.scrollIntoView?.({ block: 'start' }) + }, [activeSection, article]) + /** Jump to a section in the page and record it in the URL for deep links. */ const openSection = (secId) => { setActiveSection(secId) setSearchParams(secId ? { section: secId } : {}) + setDrawerOpen(false) + if (secId) { + const target = document.getElementById(`section-${secId}`) + target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }) + } } const save = async (publish = null) => { @@ -363,11 +374,26 @@ export function ArticlePage() {
+ {article.updated_at && ( +

Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}

+ )} {article.summary &&

{article.summary}

} {article.content && {article.content}} {!article.sections?.length && (!article.summary && !article.content) && (
Content is being prepared by educators.
)} + + {/* 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 => ( +
+

{sec.title}

+ {sec.content} +
+ ))} + {cards.length > 0 && (
@@ -382,20 +408,6 @@ export function ArticlePage() { )}
- {section && ( -
-
-
-

{section.title}

- -
-
{section.content}
- -
-
- )} )} diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index f88e114..de07314 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -44,13 +44,15 @@ describe('topic reading', () => { expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology') expect(screen.getByText('Introduction markdown')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Initial workup' })) - // The section opens in a closable overlay while the main article stays visible. - const overlay = await screen.findByRole('dialog', { name: 'Section: Initial workup' }) - expect(within(overlay).getByText('Section 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() - await userEvent.click(within(overlay).getByRole('button', { name: 'Close section' })) 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') // 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() @@ -90,9 +92,11 @@ describe('topic reading', () => { return Promise.resolve({ data: [] }) }) render(} />) - const overlay = await screen.findByRole('dialog', { name: 'Section: Second section' }) - expect(within(overlay).getByText('Second body')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Second section' })).toHaveClass('active') + // A deep link marks the section without hiding the rest of the article. + expect(await screen.findByRole('button', { name: 'Second section' })).toHaveClass('active') + expect(screen.getByText('Second body')).toBeInTheDocument() + expect(screen.getByText('First body')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument() + expect(document.querySelector(`#section-${'b'.repeat(32)}`)).toHaveClass('is-target') }) }) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 106ef61..e32011c 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -939,6 +939,29 @@ const timerStarted = timeLeft !== null } } + const QuestionRailItem = ({ q, i }) => { + const isActive = i === currentIdx + const isDone = !!answers[q.id] + const marked = favorites.includes(q.id) + const excerpt = questionStem(q).replace(/\s+/g, ' ').trim() + return ( + + ) + } + const QuestionDot = ({ q, i }) => { const isActive = i === currentIdx const isDone = !!answers[q.id] @@ -1326,13 +1349,11 @@ const timerStarted = timeLeft !== null )} - {/* Desktop sidebar */} -
-
- Questions -
-
- {questions.map((q, i) => )} + {/* Desktop rail — numbers with an excerpt, as in a Qbank session */} +
+
Session questions
+
+ {questions.map((q, i) => )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index ef780bc..9948255 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -50,13 +50,51 @@ beforeEach(() => { function mount(entry = '/quizzes/10') { render(} />Submitted results
} />) } +/** The session rail repeats each stem as an excerpt, so stem lookups are + * scoped to the question card to stay unambiguous. */ +const inCard = () => within(document.querySelector('.question-card')) +const findStem = async (text) => { + await waitFor(() => expect(document.querySelector('.question-card')).toBeInTheDocument()) + return waitFor(() => inCard().getByText(text)) +} + async function begin(study = true) { quizModeVar = study ? 'learning' : 'timed' mount() - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') } describe('quiz player', () => { + it('lists every question in the rail with a number, excerpt and difficulty', async () => { + await begin() + const rail = document.querySelector('.quiz-rail-list') + expect(rail).toBeInTheDocument() + + const items = rail.querySelectorAll('.quiz-rail-item') + expect(items).toHaveLength(2) + expect(within(items[0]).getByText('Question 1')).toBeInTheDocument() + expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument() + expect(within(items[0]).getByText('hard')).toBeInTheDocument() + expect(items[0]).toHaveAttribute('aria-current', 'true') + expect(items[1]).not.toHaveAttribute('aria-current') + + // The rail navigates, and the active row follows. + await userEvent.click(items[1]) + await findStem('Full second clinical question.') + const after = document.querySelectorAll('.quiz-rail-item') + expect(after[1]).toHaveAttribute('aria-current', 'true') + }) + + it('marks answered questions in the rail', async () => { + await begin() + const before = document.querySelectorAll('.quiz-rail-item') + expect(before[0].className).not.toMatch(/is-done/) + await userEvent.click(inCard().getByText('First answer').closest('.option')) + // Study mode holds a draft until it is submitted, so the rail marks it then. + await userEvent.click(screen.getByRole('button', { name: 'Submit response' })) + await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/)) + }) + it('keeps question metadata on one compact strip with a single action bar', async () => { await begin() // The category trail used to be a tall wrapping block above the stem. @@ -106,7 +144,7 @@ describe('quiz player', () => { expect(api.post).not.toHaveBeenCalled() await userEvent.click(screen.getByRole('button', { name: 'Retry resume' })) // Retry loads the quiz and starts it straight away in its own mode — no re-ask. - expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument() + expect(await findStem('Full first clinical question.')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') }) @@ -132,7 +170,7 @@ describe('quiz player', () => { }) mount('/quizzes/10?return_to=%2Fcourses%2F1') await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' })) - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() }) @@ -147,7 +185,7 @@ describe('quiz player', () => { await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' })) expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open') - expect(screen.getByText('Full first clinical question.')).toBeInTheDocument() + expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() failSaving = false await userEvent.click(screen.getByRole('button', { name: 'Retry saving' })) await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) @@ -181,7 +219,7 @@ describe('quiz player', () => { mount() expect(await screen.findByText(/Time expired while you were away/)).toBeInTheDocument() expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) - expect(screen.getByText('Full first clinical question.')).toBeInTheDocument() + expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() }) it('shows per-option explanations in study feedback behind a toggle', async () => { @@ -281,7 +319,7 @@ describe('quiz player', () => { const review = screen.getByRole('dialog', { name: 'Review & Complete' }) expect(within(review).getByText(/1 of 2 questions answered/)).toBeInTheDocument() await userEvent.click(within(review).getByRole('button', { name: '2 · Unanswered' })) - await screen.findByText('Full second clinical question.') + await findStem('Full second clinical question.') expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' })) @@ -291,7 +329,7 @@ describe('quiz player', () => { it('starts timed quizzes in exam mode without a mode prompt', async () => { mount() - expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument() + expect(await findStem('Full first clinical question.')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') expect(screen.queryByRole('button', { name: /Study Mode|Exam Mode/ })).not.toBeInTheDocument() }) @@ -323,7 +361,7 @@ describe('quiz player', () => { return originalPost(url, ...args) }) mount() - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') await screen.findByRole('button', { name: 'Make shareable' }) await userEvent.click(screen.getByRole('button', { name: 'Make shareable' })) await screen.findByRole('button', { name: 'Copy share link' }) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 008431e..3f6f7a4 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -4,8 +4,38 @@ .quiz-player .quiz-header-title { font-size: .9rem; font-weight: 500; color: #737982; } .quiz-player .quiz-code-badge { font-size: .75rem; } .quiz-player .quiz-code-badge code { background: transparent; border: 0; } -.quiz-player .quiz-layout { display: block; } -.quiz-player .quiz-sidebar { display: none; } +/* Session rail beside the question, like a Qbank session's left column. + Below 1150px there is not room for both, so the topbar dropdown takes over. */ +.quiz-player .quiz-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 28px; align-items: start; } +.quiz-player .quiz-sidebar { display: block; order: -1; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow-y: auto; } +.quiz-rail-head { font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: #8b929c; padding: 0 0 8px; } +.quiz-rail-list { display: flex; flex-direction: column; } +.quiz-rail-item { + display: flex; gap: 10px; align-items: flex-start; width: 100%; + padding: 9px 8px; background: none; border: 0; border-left: 3px solid transparent; + font: inherit; text-align: left; cursor: pointer; color: #4a5058; +} +.quiz-rail-item:hover { background: #f4f6fb; } +.quiz-rail-item.is-active { background: #eaf0fa; border-left-color: #496fa5; color: #253038; } +.quiz-rail-num { + flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; position: relative; + font-size: .76rem; font-weight: 650; background: #edeef2; color: #6f767f; +} +.quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; } +.quiz-rail-item.is-active .quiz-rail-num { background: #496fa5; color: #fff; } +.quiz-rail-mark { position: absolute; top: -4px; right: -4px; font-size: .6rem; color: #d99a2b; } +.quiz-rail-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.quiz-rail-label { font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #98a0aa; } +.quiz-rail-text { font-size: .78rem; line-height: 1.4; overflow-wrap: anywhere; } +.quiz-rail-diff { align-self: flex-start; font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; border-radius: 10px; padding: 1px 7px; margin-top: 2px; } +.quiz-rail-diff.is-easy { background: #eef7f3; color: #327b64; } +.quiz-rail-diff.is-medium { background: #fdf6e8; color: #8a6417; } +.quiz-rail-diff.is-hard { background: #fbecf0; color: #a13c51; } +@media (max-width: 1150px) { + .quiz-player .quiz-layout { display: block; } + .quiz-player .quiz-sidebar { display: none; } +} .quiz-player .quiz-nav-toggle { display: inline-flex; } .quiz-player .progress-bar { height: 3px; } .quiz-topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 10px 0 20px; margin-bottom: 0; border-bottom: 1px solid var(--border); }