From 158930d532552d45dd0f917d5e4949b740c8408d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 02:05:02 +0200 Subject: [PATCH] feat: section links in prose, a picker that writes them, and cards that render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Cross-references can name a section.** `[[264#workup|the workup]]` opens the reader at that heading, which is what a sentence about one part of a long article actually means. Whole-article `[[264|label]]` is unchanged, and a section renamed since is not a broken link β€” it lands at the top of the right article, which is a mild disappointment rather than a dead end. **A picker that writes the marker for you.** πŸ”— Link an article, in the editor: type a few words, click the article β€” or one of its sections β€” and the marker is on the clipboard with the right title as its label. Getting an id used to mean opening the library in another tab, finding the article and reading the number out of the address bar, which is four steps and a chance to mistype, every time. Its own small endpoint, because the listing deliberately does not carry sections and this needs nothing else. **Three things about cards that were built but never drawn:** - A card can carry an image. The column is there, the API returns it, the editor accepts one β€” and no view in the app rendered it, so every picture anybody attached to a card was stored and never seen. Both card views show it now, small until clicked like every other figure. - The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The study view has rendered them as links for a while; now both do. - There was no way to make a deck by hand. Every deck came out of a model β€” generated from a document section or an article β€” so an educator who wanted to write six cards had nowhere to put them, and the add-a-card route could only add to a deck that did not exist yet. `+ New deck` on the cards page. **Generate cards ran in silence.** It starts a real job, and the only place its progress was drawn was inside the refine panel β€” which lives in the editor and is shut. Pressing it on the reading page did nothing visible for ninety seconds. It now says what it is doing where it was pressed. **Overlays were invisible to learners.** A stored width is a fraction of the image, and the stroke is drawn with `non-scaling-stroke`, which makes `stroke-width` a count of screen pixels β€” so 0.006 meant six thousandths of a pixel. The editor has always multiplied by its rendered width; the viewer now does the same sum. Every region an educator has ever marked was invisible to everyone who was not editing it. Also: the figure viewer no longer scrolls, at any width, and the page behind it is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a figure opened half-way down an article drifted while it was read). Options are full width on a phone. The question toolbar's seven glyphs are four, with the rest folded into the β‹― that was already there, spelled out in words. The jobs popover closes on a click anywhere outside it. And the editor has a way back to Editorial β€” "back to the article", from an article you opened to edit, is a loop. The contract snapshot caught both new routes on the way through, which is what it is for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/articles.py | 40 ++++++ backend/app/routers/flashcards.py | 31 +++++ backend/app/services/article_service.py | 21 +++- backend/tests/api-contract.json | 19 +++ docs/writing-articles.md | 28 +++-- frontend/src/components/ImageFigure.css | 35 ++++-- frontend/src/components/ImageFigure.jsx | 25 +++- frontend/src/components/ImageOverlay.jsx | 38 +++++- frontend/src/components/LinkPicker.css | 65 ++++++++++ frontend/src/components/LinkPicker.jsx | 130 ++++++++++++++++++++ frontend/src/components/LinkPicker.test.jsx | 83 +++++++++++++ frontend/src/components/Navbar.jsx | 20 ++- frontend/src/components/RichText.jsx | 21 ++-- frontend/src/components/RichText.test.jsx | 13 ++ frontend/src/pages/ArticlesPage.css | 10 ++ frontend/src/pages/ArticlesPage.jsx | 34 ++++- frontend/src/pages/FlashcardStudyPage.jsx | 11 ++ frontend/src/pages/FlashcardsPage.jsx | 55 ++++++++- frontend/src/pages/QuizPage.jsx | 32 ++++- frontend/src/pages/QuizPlayer.css | 9 ++ 20 files changed, 675 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/LinkPicker.css create mode 100644 frontend/src/components/LinkPicker.jsx create mode 100644 frontend/src/components/LinkPicker.test.jsx diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index ac776d2..21e4966 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -292,6 +292,46 @@ def list_articles( return [_article_card_json(a) for a in articles] +@router.get("/link-targets") +def link_targets( + q: str | None = Query(None, description="What the writer typed"), + limit: int = Query(8, le=25), + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Articles and their sections, for the picker that writes a cross-reference. + + Its own endpoint rather than the listing, because the two want opposite + things: the listing is a page of cards and deliberately does not carry + sections, and this needs the sections and almost nothing else. Eight + results, because a picker is for choosing rather than for browsing. + """ + query = db.query(Article).filter(Article.deleted_at.is_(None)) + ranked: list[int] = [] + if q and q.strip(): + ranked, _ = article_ids_with_sections(db, q.strip(), limit=60) + if not ranked: + return [] + query = query.filter(Article.id.in_(ranked)) + articles = query.order_by(Article.updated_at.desc()).all() + if ranked: + rank_of = {article_id: position for position, article_id in enumerate(ranked)} + articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of))) + return [{ + "id": article.id, + "title": article.title, + "slug": article.slug, + "status": article.status, + # Only the sections that have somewhere to land: a section with no id + # cannot be addressed, and offering it would produce a dead marker. + "sections": [ + {"id": section.get("id"), "title": section.get("title") or section.get("slug") or "Untitled", + "variant": section.get("variant")} + for section in (article.sections or []) if section.get("id") + ], + } for article in articles[:limit]] + + @router.post("/") def create_article( data: ArticleWrite, diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 9dbbd0d..210735d 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -140,6 +140,37 @@ def create_flashcard_deck( return {"job_id": job_id, "status": "pending"} +class ManualDeckCreate(BaseModel): + title: str + category_id: int | None = None + + +@router.post("/manual") +def create_deck_manually( + data: ManualDeckCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """An empty deck, to write cards into by hand. + + Every deck until now came out of a model β€” generated from a document + section or from an article β€” so an educator who simply wanted to write six + cards had nowhere to put them. The single-card route already existed and + could only add to a deck that did not exist yet. + + Unshared, like every other new deck: sharing is a second, deliberate act. + """ + title = (data.title or "").strip() + if not title: + raise HTTPException(status_code=400, detail="A deck needs a title") + deck = FlashcardDeck(title=title[:200], user_id=current_user.id, + category_id=data.category_id, card_count=0, is_shared=0) + db.add(deck) + db.commit() + db.refresh(deck) + return {"id": deck.id, "title": deck.title, "card_count": 0, "is_shared": 0} + + @router.get("/", response_model=list[FlashcardDeckResponse]) def list_flashcard_decks( include_deleted: bool = Query(False), diff --git a/backend/app/services/article_service.py b/backend/app/services/article_service.py index 5d0b809..e0f7953 100644 --- a/backend/app/services/article_service.py +++ b/backend/app/services/article_service.py @@ -38,8 +38,11 @@ VARIANTS = ("short", "long", "clinical") DEFAULT_VARIANT = "long" # [[7|Febrile seizures]] β€” id first, because the id is the part that must not -# change. [[febrile-seizures]] is the older slug form and still resolves. -MARKER_RE = re.compile(r"\[\[(?:(\d+)\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]") +# change. [[7#workup|the workup]] lands on one section of it, so a sentence can +# point at the paragraph it is actually about rather than at the top of a long +# article. [[febrile-seizures]] is the older slug form and still resolves. +MARKER_RE = re.compile( + r"\[\[(?:(\d+)(?:#([A-Za-z0-9_-]+))?\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]") STATUSES = ("draft", "in_review", "published") @@ -133,13 +136,19 @@ def snapshot(db: Session, article: Article, user_id: int | None, note: str | Non def marker_targets(text: str) -> tuple[set[int], set[str]]: - """The article ids and legacy slugs a piece of prose points at.""" + """The article ids and legacy slugs a piece of prose points at. + + The section part of `[[7#workup|…]]` is not returned: what makes a marker + broken is the article being gone. A section that has been renamed since + leaves the reader at the top of the right article, which is a mild + disappointment rather than a dead link. + """ ids, slugs = set(), set() for match in MARKER_RE.finditer(text or ""): if match.group(1): ids.add(int(match.group(1))) - elif match.group(3): - slugs.add(match.group(3)) + elif match.group(4): + slugs.add(match.group(4)) return ids, slugs @@ -175,7 +184,7 @@ def upgrade_markers(db: Session, text: str) -> str: def replace(match: re.Match) -> str: if match.group(1): return match.group(0) - slug = match.group(3) + slug = match.group(4) article = resolve_slug(db, slug) return f"[[{article.id}|{article.title}]]" if article else match.group(0) diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json index 7c4b663..4212db2 100644 --- a/backend/tests/api-contract.json +++ b/backend/tests/api-contract.json @@ -633,6 +633,17 @@ "422" ] }, + "GET /api/v1/articles/link-targets": { + "body": false, + "params": [ + "query:limit?", + "query:q?" + ], + "responses": [ + "200", + "422" + ] + }, "GET /api/v1/articles/preview/{slug}": { "body": false, "params": [ @@ -2399,6 +2410,14 @@ "422" ] }, + "POST /api/v1/flashcards/manual": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, "POST /api/v1/flashcards/{deck_id}/rate": { "body": true, "params": [ diff --git a/docs/writing-articles.md b/docs/writing-articles.md index 597632f..f0207f0 100644 --- a/docs/writing-articles.md +++ b/docs/writing-articles.md @@ -6,11 +6,12 @@ an AI draft, and a database import equally well. ## Cross-references β€” `[[…]]` -A link from one article to another. Two forms: +A link from one article to another. Three forms: | You write | It renders as | Use when | |---|---|---| | `[[264\|respiratory failure]]` | a link labelled *respiratory failure* | **always, by preference** | +| `[[264#workup\|the workup]]` | the same, landing on that section | the sentence is about one part of a long article | | `[[respiratory-failure]]` | a link labelled *respiratory-failure* | quick drafting | The first number is the article's **id**, and the id is the part that cannot @@ -20,8 +21,19 @@ is the one that cannot rot. The label after the bar is whatever reads naturally in the sentence: *"…severe viral `[[3|bronchiolitis]]`…"* reads as "…severe viral bronchiolitis…", with the two words linked. -Where the id comes from: the article's URL. `/articles/264` is id 264. The -editor's Linked-questions panel and the library listing both show it too. +Where the id comes from: **πŸ”— Link an article**, in the editor's toolbar. Type +a few words, click the article, and the marker is on your clipboard with the +article's own title as the label β€” click a *section* instead and you get +`[[264#workup|Workup]]`, which opens the reader at that heading. Drafts are +marked as such in the list, because a link to one is a link to nothing yet. + +The id is also in the article's URL β€” `/articles/264` is id 264 β€” and on the +editor's ID/Slug/marker buttons, which copy the whole thing ready to paste. + +The section part is the section's own id, and it is optional in both +directions: a section renamed after the link was written leaves the reader at +the top of the right article rather than nowhere, which is why a missing +section is not reported as a broken link. What happens at the far end: the link opens the article **in a side pane** next to what you are reading, rather than navigating away β€” following a reference @@ -128,17 +140,17 @@ the model wrote. | From | To | Where you do it | What the reader gets | |---|---|---|---| -| Article prose | Article | `[[264\|label]]` in the text | A link with a hover card: excerpt, new tab, or a pane beside what they are reading | +| Article prose | Article | `[[264\|label]]`, or πŸ”— Link an article | A link with a hover card: excerpt, new tab, or a pane beside what they are reading | +| Article prose | One section | `[[264#workup\|label]]`, or πŸ”— β†’ the section | The same card, opening the reader at that heading | | Article | Question | Editor β†’ Linked questions | Practice under the article, and the article under the question's answer | | Question | Article | Question editor β†’ the same list | β–€ chips under the correct answer, landing on the article or one of its sections | | Card | Question | Cards β†’ deck β†’ β›“ | β–¦ chip under the correct answer, opening the deck to study | | Card | Article | Cards β†’ deck β†’ β›“ | The card listed under *Related cards* on the article | | Explanation prose | Article | `[[264\|label]]`, same as anywhere | The same hover card | -A questionβ†’article link can land on **one section** rather than the whole -article β€” choose the section when you make the link, and the reader opens at -that heading. A cross-reference written in prose cannot: `[[264|label]]` always -means the whole article. +Both directions can land on **one section** rather than the whole article: a +questionβ†’article link has a section picker beside it, and prose says it with +`#` β€” `[[264#workup|the workup]]`. ## Deleting diff --git a/frontend/src/components/ImageFigure.css b/frontend/src/components/ImageFigure.css index a13c09f..4ffd238 100644 --- a/frontend/src/components/ImageFigure.css +++ b/frontend/src/components/ImageFigure.css @@ -20,6 +20,10 @@ position: fixed; inset: 0; z-index: 1200; display: flex; flex-direction: column; background: #0b1220; color: #e2e8f0; + /* The viewer never scrolls as a whole, at any width. It is one screen: look + at the picture, close it, carry on from where you were. A scrollbar down + the side of it invites a scroll that moves nothing anybody wanted moved. */ + overflow: hidden; } .imgfig-bar { display: flex; align-items: center; gap: 10px; @@ -39,7 +43,10 @@ } .imgfig-close:hover { background: rgba(255, 255, 255, .12); } -.imgfig-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px minmax(0, 1fr); } +.imgfig-body { + flex: 1; min-height: 0; overflow: hidden; + display: grid; grid-template-columns: 320px minmax(0, 1fr); +} .imgfig-desc { display: flex; flex-direction: column; gap: 14px; padding: 26px 24px; overflow-y: auto; @@ -52,32 +59,44 @@ .imgfig-stage { display: flex; align-items: center; justify-content: center; - padding: 20px; overflow: auto; cursor: zoom-out; + /* `hidden`, not `auto`: the picture is capped to the space it has, so there + is nothing to scroll to. */ + padding: 20px; overflow: hidden; cursor: zoom-out; + min-height: 0; } /* The frame is what the overlay is positioned against, so it must be exactly the size of the drawn image and no larger β€” hence a block that shrinks to its content rather than a flex child that stretches. */ .imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; } -.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; } +.imgfig-frame { max-width: 100%; max-height: 100%; } +.imgfig-frame img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; } /* A drawing has no size of its own, only a shape, so it is given the width it is allowed and takes its height from that. Line art scales; a photograph would be blown up by the same rule, which is why this is not the default. */ .imgfig-frame.is-vector { width: min(100%, 900px); } -.imgfig-frame.is-vector img { width: 100%; height: auto; max-height: calc(100dvh - 160px); object-fit: contain; } +.imgfig-frame.is-vector img { width: 100%; height: auto; max-height: 100%; object-fit: contain; } .imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } @media (max-width: 820px) { /* Stacked, description first: it can be read before scrolling to the picture rather than after it, and on a phone the picture wants the width more than the text does. */ - .imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow-y: auto; } - .imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; } + /* The viewer fills the screen and does not scroll as a whole. What the + picture is stays at the top and scrolls inside its own box if it is long; + the picture keeps everything under it. A viewer you have to scroll to see + the picture in is a viewer that looks broken. */ + .imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow: hidden; } + .imgfig-desc { + border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); + padding: 16px 16px 14px; gap: 10px; + max-height: 38dvh; overflow-y: auto; + } .imgfig-stage { padding: 12px; } - .imgfig-frame img { max-height: none; } + .imgfig-frame img { max-height: 100%; } /* Stacked, so the picture has the width of the screen and whatever height its shape asks for β€” the row it sits in scrolls with the description. */ .imgfig-frame.is-vector { width: 100%; } - .imgfig-frame.is-vector img { max-height: none; } + .imgfig-frame.is-vector img { max-height: 100%; } } @media (max-width: 560px) { diff --git a/frontend/src/components/ImageFigure.jsx b/frontend/src/components/ImageFigure.jsx index a8057ce..5cef765 100644 --- a/frontend/src/components/ImageFigure.jsx +++ b/frontend/src/components/ImageFigure.jsx @@ -37,12 +37,29 @@ export function ImageViewer({ src, alt = '', attemptId, onClose }) { useEffect(() => { const onKey = event => { if (event.key === 'Escape') onClose?.() } document.addEventListener('keydown', onKey) - // The page behind must not scroll while the viewer has the screen. - const previous = document.body.style.overflow - document.body.style.overflow = 'hidden' + + // The page behind must not move while the viewer has the screen. + // + // `overflow: hidden` on the body is enough for a desktop browser and does + // nothing on iOS, where the document keeps scrolling under a fixed + // overlay β€” so a figure opened half-way down an article drifted while it + // was being read, and closing it landed somewhere else. Pinning the body + // and putting the scroll position back on the way out is the only thing + // that holds on both. + const y = window.scrollY + const body = document.body + const previous = { + overflow: body.style.overflow, position: body.style.position, + top: body.style.top, width: body.style.width, + } + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${y}px` + body.style.width = '100%' return () => { document.removeEventListener('keydown', onKey) - document.body.style.overflow = previous + Object.assign(body.style, previous) + window.scrollTo(0, y) } }, [onClose]) diff --git a/frontend/src/components/ImageOverlay.jsx b/frontend/src/components/ImageOverlay.jsx index 4498401..8b0a89f 100644 --- a/frontend/src/components/ImageOverlay.jsx +++ b/frontend/src/components/ImageOverlay.jsx @@ -1,3 +1,5 @@ +import { useEffect, useRef, useState } from 'react' + import smoothPath from '../utils/smoothPath' /** @@ -16,12 +18,41 @@ const DEFAULT_WIDTH = 0.006 const points = (shape) => (Array.isArray(shape.points) ? shape.points : []) .filter(point => Array.isArray(point) && point.length === 2) +/** + * How wide the overlay is drawn, in real pixels. + * + * A stored width is a fraction of the image, so that a "medium" line carries + * the same weight in a thumbnail and on a projector. But the stroke is drawn + * with `non-scaling-stroke` β€” otherwise a unit square stretched over a 3:2 + * picture would make horizontal lines half again as heavy as vertical ones β€” + * and that makes `stroke-width` a count of *screen* pixels. So 0.006 was + * six thousandths of a pixel, and every overlay an educator drew was invisible + * to every learner. The editor has always multiplied by its rendered width; + * this is the same sum, on the other side of the app. + */ +function useRenderedWidth() { + const ref = useRef(null) + const [width, setWidth] = useState(0) + useEffect(() => { + const node = ref.current + if (!node) return undefined + const measure = () => setWidth(node.getBoundingClientRect().width) + measure() + if (typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] +} + export default function ImageOverlay({ overlay }) { + const [ref, box] = useRenderedWidth() const shapes = (overlay && Array.isArray(overlay.shapes)) ? overlay.shapes : [] if (!shapes.length) return null return ( -
+
+ setQuery(event.target.value)} /> + +
+ + {copied &&

Copied {copied}

} + + {busy && rows.length === 0 &&

Searching…

} + {!busy && rows.length === 0 && ( +

{query.trim() ? 'Nothing matches that.' : 'No articles yet.'}

+ )} + +
    + {rows.map(article => ( +
  • +
    + + {article.sections.length > 0 && ( + + )} +
    + {open === article.id && ( +
      + {article.sections.map(section => ( +
    • + +
    • + ))} +
    + )} +
  • + ))} +
+ +

+ Click an article for [[id|title]], or a section to land the + reader on that heading. It goes to the clipboard, ready to paste. +

+
+ ) +} diff --git a/frontend/src/components/LinkPicker.test.jsx b/frontend/src/components/LinkPicker.test.jsx new file mode 100644 index 0000000..375f9c2 --- /dev/null +++ b/frontend/src/components/LinkPicker.test.jsx @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LinkPicker from './LinkPicker' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) + +const rows = [ + { + id: 264, title: 'Pediatric Respiratory Failure', slug: 'respiratory-failure', status: 'published', + sections: [ + { id: 'workup', title: 'Workup', variant: 'long' }, + { id: 'ladder', title: 'At the bedside: the escalation ladder', variant: 'clinical' }, + ], + }, + { id: 3, title: 'Bronchiolitis', slug: 'bronchiolitis', status: 'draft', sections: [] }, +] + +describe('finding an article to link to', () => { + let clipboard + beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ data: rows }) + clipboard = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboard }, configurable: true, + }) + }) + + it('copies the marker for an article, spelled correctly', async () => { + render( {}} />) + await screen.findByText('Pediatric Respiratory Failure') + + await userEvent.click(screen.getByText('Pediatric Respiratory Failure')) + // The id, not the slug: a slug can be renamed and an id cannot. + expect(clipboard).toHaveBeenCalledWith('[[264|Pediatric Respiratory Failure]]') + expect(await screen.findByRole('status')).toHaveTextContent('[[264|Pediatric Respiratory Failure]]') + }) + + it('offers the sections, and lands the reader on the one chosen', async () => { + render( {}} />) + await screen.findByText('Pediatric Respiratory Failure') + + await userEvent.click(screen.getByRole('button', { name: /Sections of Pediatric Respiratory Failure/ })) + await userEvent.click(screen.getByText('Workup')) + expect(clipboard).toHaveBeenCalledWith('[[264#workup|Workup]]') + }) + + it('hands the marker to the editor when there is one to hand it to', async () => { + const insert = vi.fn() + render( {}} onInsert={insert} />) + await screen.findByText('Bronchiolitis') + await userEvent.click(screen.getByText('Bronchiolitis')) + expect(insert).toHaveBeenCalledWith('[[3|Bronchiolitis]]') + }) + + it('says which are still drafts, because a link to one is a link to nothing yet', async () => { + render( {}} />) + await screen.findByText('Bronchiolitis') + expect(screen.getByText('draft')).toBeInTheDocument() + }) + + it('searches what was typed, once, rather than once per letter', async () => { + render( {}} />) + await waitFor(() => expect(api.get).toHaveBeenCalledTimes(1)) + + await userEvent.type(screen.getByRole('textbox'), 'croup') + await waitFor(() => expect(api.get).toHaveBeenLastCalledWith( + '/articles/link-targets', { params: { q: 'croup' } })) + // Five letters, and not five searches: each one goes through hybrid + // retrieval and they would queue behind each other. + expect(api.get.mock.calls.length).toBeLessThan(4) + }) + + it('closes on Escape, because it floats over what you were writing', async () => { + const onClose = vi.fn() + render() + await screen.findByText('Bronchiolitis') + await userEvent.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 3e963ee..5a6a36e 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -95,15 +95,31 @@ function FeedbackBadge() { function JobsBadge({ jobs }) { const [open, setOpen] = useState(false) + const wrap = useRef(null) const allJobs = jobs const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending') + // Anywhere outside closes it, and so does Escape. It only closed when a job + // in it was clicked, so a panel opened to check on something then sat over + // the page until you found the badge again. + useEffect(() => { + if (!open) return undefined + const away = event => { if (!wrap.current?.contains(event.target)) setOpen(false) } + const onKey = event => { if (event.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [open]) + if (allJobs.length === 0) return null const running = activeJobs.length return ( -
- @@ -512,6 +537,10 @@ export function ArticlePage() { {dirty ? 'Discard' : 'Close'} )} + {/* Writing a cross-reference means knowing an article's id. This + finds it: search, click, paste. */} + {user?.is_moderator && ( /* Refining is drafting, so it is here rather than on the page a learner reads: you are already editing, and the result is @@ -524,6 +553,7 @@ export function ArticlePage() {
{notices} + {showLinks && setShowLinks(false)} />}

Linked questions

diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx index f4ba109..b25290d 100644 --- a/frontend/src/pages/FlashcardStudyPage.jsx +++ b/frontend/src/pages/FlashcardStudyPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import api from '../api/client' import RichText from '../components/RichText' +import ImageFigure from '../components/ImageFigure' export default function FlashcardStudyPage() { const { deckId } = useParams() @@ -182,6 +183,16 @@ export default function FlashcardStudyPage() { most of what "link cards to things" turns out to mean. */}

+ {/* The picture on the card. A card could carry one β€” the column is + there, the editor accepts one, the API returns it β€” and no view + in the app drew it, so every image anybody attached to a card + was stored and never seen. Small until clicked, like every + other figure. */} + {currentCard.image_path && ( +
+ +
+ )} {!flipped && (
Tap to reveal answer
)} diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index 60cf64d..7ea739b 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import RichText from '../components/RichText' +import ImageFigure from '../components/ImageFigure' import api from '../api/client' function StarRating({ rating, onRate, readonly = false }) { @@ -26,6 +28,8 @@ function StarRating({ rating, onRate, readonly = false }) { export default function FlashcardsPage() { const [tab, setTab] = useState('decks') + const [making, setMaking] = useState(false) + const [newDeckTitle, setNewDeckTitle] = useState('') const [decks, setDecks] = useState([]) const [categories, setCategories] = useState([]) const [trashedDecks, setTrashedDecks] = useState([]) @@ -77,6 +81,17 @@ export default function FlashcardsPage() { }).catch(() => {}) } + const makeDeck = async () => { + const title = newDeckTitle.trim() + if (!title) return + try { + await api.post('/flashcards/manual', { title }) + setNewDeckTitle('') + setMaking(false) + loadDecks() + } catch { /* the list is unchanged, which is the honest signal */ } + } + useEffect(() => { loadDecks() }, []) const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => { @@ -225,7 +240,13 @@ export default function FlashcardsPage() {

Cards

-
+
+ {/* Every deck used to come out of a model β€” generated from a + document or an article β€” so an educator who wanted to write six + cards by hand had nowhere to put them. */} + {user?.is_moderator && ( + + )} @@ -234,6 +255,24 @@ export default function FlashcardsPage() {
+ {making && ( +
+

A new deck

+

+ Empty, and yours until you share it. Write the cards into it from the + deck itself. +

+
+ setNewDeckTitle(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') makeDeck() }} /> + + +
+
+ )} + {/* Study card modal */} {studyCard && (
{flipped ? 'Answer' : 'Question'} β€” tap to flip
-

- {flipped ? studyCard.back : studyCard.front} -

+
+ {/* Rendered, not printed: a card's faces are prose, so a + cross-reference on one is a link here too rather than a pair + of brackets and a number. */} + + {studyCard.image_path && ( +
+ +
+ )} +
e.stopPropagation()}>
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index ca634a5..95fdbae 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -337,6 +337,11 @@ export default function QuizPage() { // 1150px breakpoint in QuizPlayer.css that hides the rail. const sessionDrawer = useSessionDrawer() const hasRail = useMediaQuery('(min-width: 1151px)') + // On a phone the words under these controls are hidden and only the glyph is + // left, so seven of them in a row is seven puzzles. The three that are + // pressed on a question stay out; the rest go behind the β‹― that is already + // there for the occasional ones. + const phone = useMediaQuery('(max-width: 640px)') // Only once a session is actually being sat. The screen that asks // whether to start one is an ordinary card and wants the ordinary page. useClaimSessionChrome(!!quizMode) @@ -1767,7 +1772,7 @@ const timerStarted = timeLeft !== null are hidden and only the glyph is left, so a button whose name lived in that hidden span had no name at all to a screen reader β€” and no tooltip to a long press. */} - {!examChrome && ( + {!examChrome && !phone && ( + )} + {phone && ( + + )} + {phone && !examChrome && ( + + )} )} + {!phone && (
{/* Not a pencil. The pencil beside it opens the note, and two identical glyphs an inch apart doing different things @@ -1855,6 +1882,7 @@ const timerStarted = timeLeft !== null βœ• Clear
+ )}
{/* The panels sit under the toolbar, where the eye already is, and diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 978c54c..358bfd8 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -1004,3 +1004,12 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; } player, with nothing between the two. A scroller that ends flush against a sticky bar reads as content cut off rather than content finished. */ .quiz-layout { padding-bottom: 14px; } + +/* ── The options, on a phone ────────────────────────────────────────── + Full width, all of them. Shrink-to-fit is right on a wide screen, where the + eliminate control needs to sit against the words it belongs to β€” but at + 390px it made the one answered option a short box in a column of long ones, + which reads as a rendering fault rather than as a design. */ +@media (max-width: 640px) { + .option-row > .option { flex: 1 1 auto; width: 100%; } +}