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

+ )} + + + +

+ 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%; } +}