feat: a deck is studied in the window, the way a session is sat
Boxed like the player, and for the same reason: this is one thing at a time, and a card floating in the middle of an empty page reads as an aside rather than as work. The deck takes the window, the bar saying what you are studying and how far in is fixed at the top, the card fills what is left and scrolls inside its own frame, and the controls are fixed at the foot. The foot is the player's three-part bar, adapted to what a deck actually has: the way back to the decks at the left, Prev / Show answer or the verdict / Next in the middle, the keyboard shortcuts at the right. No Exit session — a deck has no block to hand in — and no menu, because the controls that change the run (All, Review, Shuffle, Reset) are at the top where they belong. The navbar's 32px bottom margin is zeroed while a deck is open, as it is for the player: the shell is measured against the window minus the header, and that margin was 32px the arithmetic did not know about — exactly how far off the bottom of the screen the verdict bar had ended up. Separately: no "AI draft" and no "New article" on the reading page. Reading is where an article is read, by everyone including the people who write them. Writing one starts in Editorial, which is the queue of what still needs a person, and whose rows already open the editor rather than the reader — so an administrator reading the library and an administrator working through it are two different doors, which is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2e4e4000a5
commit
3e03aa78cc
3 changed files with 97 additions and 63 deletions
|
|
@ -148,12 +148,12 @@ export default function ArticlesPage() {
|
|||
<h1>Topic reading</h1>
|
||||
<p className="articles-subtitle">Educator-reviewed articles linked to questions and cards.</p>
|
||||
</div>
|
||||
{user?.is_moderator && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary" onClick={() => { setError(''); setShowAi(v => !v); setShowCreate(false) }}>AI draft</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(v => !v)}>New article</button>
|
||||
</div>
|
||||
)}
|
||||
{/* No Draft and no New article here. Reading is where an article is
|
||||
read, by everyone including the people who write them; writing one
|
||||
starts in Editorial, which is the queue of what still needs a
|
||||
person. Both panels still open on this page — Editorial sends you
|
||||
here with ?draft=1 or ?new=1 — because this is where the library
|
||||
they land in lives. */}
|
||||
</div>
|
||||
{showAi && user?.is_moderator && (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
|
|
|
|||
|
|
@ -3,62 +3,91 @@
|
|||
* Its own file, imported by its own page. These rules lived in
|
||||
* FlashcardsPage.css — the deck *list* — and each page is its own bundle, so
|
||||
* opening a deck's study page directly loaded the markup with none of its
|
||||
* appearance: no card, no box, no pinned verdict bar. It looked right only if
|
||||
* you happened to arrive via the list.
|
||||
* appearance. It looked right only if you happened to arrive via the list.
|
||||
*
|
||||
* Shaped like the session player, deliberately. Studying a deck is the same
|
||||
* kind of work as sitting a paper: one thing at a time, filling the window,
|
||||
* with the state of play fixed at the top and the controls fixed at the foot.
|
||||
* A deck floating in the middle of an empty page reads as an aside; this reads
|
||||
* as work.
|
||||
*/
|
||||
/* ── Studying a deck ──────────────────────────────────────────────────
|
||||
Boxed, like the session player: the deck's header at the top, the verdict at
|
||||
the foot, and only the card between them scrolls. A card with a picture on
|
||||
it is taller than the window, and the two buttons the whole exercise turns
|
||||
on were below the fold. */
|
||||
.fc-study-main { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
|
||||
|
||||
/* The navbar carries 32px of margin under it everywhere else on the site, and
|
||||
the shell below is measured against the window minus the header — so that
|
||||
margin was 32px the arithmetic did not know about, which is exactly how far
|
||||
off the bottom of the screen the verdict bar ended up. */
|
||||
body:has(.fc-study) .navbar { margin-bottom: 0; }
|
||||
|
||||
.fc-study {
|
||||
/* The header and its section strip, and nothing else. */
|
||||
height: calc(100dvh - 98px);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.fc-study-main {
|
||||
flex: 1; min-height: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
/* The deck's own bar: what you are studying and how far in. Fixed, like the
|
||||
player's — the counter leaving the screen exactly when a long card makes you
|
||||
want it is the thing this prevents. */
|
||||
.fc-head { flex: none; padding: 14px 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.fc-head-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.fc-head h2 { margin: 0 0 2px; font-size: 1.05rem; }
|
||||
.fc-head-meta { margin: 0; font-size: .82rem; color: var(--text-muted); }
|
||||
.fc-head-tools { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.fc-head .progress-bar { margin-top: 10px; height: 4px; }
|
||||
|
||||
/* The card takes what is left. It scrolls inside its own frame rather than
|
||||
pushing the verdict off the screen, which is what a radiograph did. */
|
||||
.fc-card {
|
||||
/* Shrinks to the card, up to the height it has. A one-line front in a box
|
||||
four hundred pixels tall is mostly emptiness; a card with a radiograph on
|
||||
it scrolls inside its own frame rather than pushing the verdict buttons
|
||||
off the screen. */
|
||||
flex: 0 1 auto; min-height: 180px; overflow-y: auto;
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 32px 28px; text-align: center; cursor: pointer;
|
||||
gap: 12px; padding: 40px 32px; text-align: center; cursor: pointer;
|
||||
background: var(--card-bg); border: 2px solid var(--border);
|
||||
border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .08);
|
||||
border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.fc-card.is-flipped { border-color: var(--primary); }
|
||||
.fc-card .imgfig { margin: 0; }
|
||||
.fc-face { max-width: 62ch; }
|
||||
|
||||
/* The three things, in the player's own shape: the way out at the left, the
|
||||
navigation in the middle, the verdict beside it. Nothing here ends anything
|
||||
— a deck has no block to hand in — so the left is the way back to the decks
|
||||
rather than an Exit session. */
|
||||
.fc-foot {
|
||||
position: sticky; bottom: 0; z-index: 2;
|
||||
padding: 10px 0 6px; background: var(--bg);
|
||||
flex: none;
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fc-foot-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
|
||||
.fc-keys { margin: 8px 0 0; text-align: center; font-size: .74rem; color: var(--text-muted); }
|
||||
.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
|
||||
.fc-keys { flex: none; margin: 0; font-size: .74rem; color: var(--text-subtle); }
|
||||
|
||||
/* Named rather than styled inline: green for the one that means "done with
|
||||
this", red-lettered for the one that means "again". */
|
||||
.fc-known { background: #22c55e; border-color: #22c55e; color: #fff; }
|
||||
.fc-known:hover { background: #16a34a; border-color: #16a34a; }
|
||||
.fc-again { color: #ef4444; border-color: #ef4444; background: none; }
|
||||
.fc-again:hover { background: #fef2f2; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
/* Tall enough to be worth boxing: the header and the verdict stay put and
|
||||
the card scrolls between them. `max-height` rather than `height`, so a
|
||||
short card is short — the box is a ceiling, not a shape to fill. */
|
||||
.fc-study-main { max-height: calc(100dvh - 150px); }
|
||||
/* Reading an article beside the deck. The deck keeps the middle and does not
|
||||
reflow as one reference is followed after another. */
|
||||
.fc-study.has-split .fc-study-main { display: none; }
|
||||
@media (min-width: 1100px) {
|
||||
.fc-study.has-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 40%); gap: 16px; }
|
||||
.fc-study.has-split .fc-study-main { display: flex; }
|
||||
.fc-study.has-split .article-split-pane { max-height: 100%; }
|
||||
}
|
||||
|
||||
/* What you can do to one card in the browse list: one strip, wrapping onto a
|
||||
second line on a narrow screen rather than becoming a column. */
|
||||
.fc-row-actions { display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0; align-items: flex-start; }
|
||||
.fc-row-actions .btn { white-space: nowrap; }
|
||||
.fc-row-delete { color: var(--text-muted); }
|
||||
.fc-row-delete:hover { color: var(--wrong-fg, #b91c1c); border-color: currentColor; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* Under the card rather than beside it: at 390px a strip of four buttons
|
||||
beside the text leaves the stem two words wide. */
|
||||
.fc-row-actions { width: 100%; }
|
||||
@media (max-width: 820px) {
|
||||
/* No room to box it: the page scrolls, and the verdict sticks to the bottom
|
||||
of the window so the two buttons the exercise turns on are always there. */
|
||||
.fc-study { height: auto; padding: 0 12px; }
|
||||
.fc-card { min-height: 46dvh; padding: 24px 16px; }
|
||||
.fc-foot { position: sticky; bottom: 0; background: var(--bg); z-index: 2; }
|
||||
.fc-keys { display: none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './FlashcardStudyPage.css'
|
||||
import { useClaimFullBleed } from '../context/SessionChrome'
|
||||
import RichText from '../components/RichText'
|
||||
import ArticleSplitPane from '../components/ArticleSplitPane'
|
||||
import { SplitViewProvider } from '../context/SplitViewContext'
|
||||
|
|
@ -113,6 +114,11 @@ export default function FlashcardStudyPage() {
|
|||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [next, prev, currentCard])
|
||||
|
||||
// The window, minus its gutters. Studying a deck is the same kind of work as
|
||||
// sitting a session: one thing at a time, filling the screen. The site's
|
||||
// navigation stays — a deck is not a paper you can leave half-marked.
|
||||
useClaimFullBleed(true)
|
||||
|
||||
const [splitTrail, setSplitTrail] = useState([])
|
||||
const pushSplit = useCallback((slug) => setSplitTrail(
|
||||
trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
|
||||
|
|
@ -132,22 +138,20 @@ export default function FlashcardStudyPage() {
|
|||
<SplitViewProvider value={splitView}>
|
||||
<div className={splitSlug ? 'fc-study has-split' : 'fc-study'}>
|
||||
<div className="fc-study-main">
|
||||
{/* The way out, above the title and in the same words as everywhere
|
||||
else in the app — not a grey button at the end of a row of controls
|
||||
that change what you are studying. */}
|
||||
<Link to="/flashcards" className="articles-back">← Cards</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
{/* The deck's bar: what you are studying, how far in, and the controls
|
||||
that change the run. Fixed at the top the way the player's is — a
|
||||
counter that scrolls away with the content is a counter that leaves
|
||||
exactly when a long card makes you want it. */}
|
||||
<div className="fc-head">
|
||||
<div className="fc-head-row">
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
<h2>{deck.title}</h2>
|
||||
<p className="fc-head-meta">
|
||||
{cards.length} cards · {counts.due} due · {counts.unseen} new
|
||||
{known.size > 0 && ` · ${known.size} answered`}
|
||||
{known.size > 0 && ` · ${known.size} answered`} · {progress}% mastered
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<div className="fc-head-tools">
|
||||
<button className={`btn btn-sm ${mode === 'all' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('all'); setCurrentIdx(0); setFlipped(false) }}>All ({cards.length})</button>
|
||||
{review.size > 0 && (
|
||||
<button className={`btn btn-sm ${mode === 'review' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('review'); setCurrentIdx(0); setFlipped(false) }}>Review ({review.size})</button>
|
||||
|
|
@ -156,12 +160,8 @@ export default function FlashcardStudyPage() {
|
|||
<button className="btn btn-sm btn-secondary" onClick={reset}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="progress-bar" style={{ height: 6 }}>
|
||||
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progress}% mastered</div>
|
||||
<div className="progress-bar">
|
||||
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -222,8 +222,13 @@ export default function FlashcardStudyPage() {
|
|||
window, and the two buttons the whole exercise turns on were
|
||||
below the fold — you scrolled to read, then scrolled back to say
|
||||
whether you knew it. */}
|
||||
{/* The player's own three-part bar: the way out at the left, the
|
||||
navigation and the verdict in the middle, the keys at the right.
|
||||
No Exit session — a deck has no block to hand in, and the
|
||||
controls that change the run are at the top where they belong. */}
|
||||
<div className="fc-foot">
|
||||
<div className="fc-foot-row">
|
||||
<Link to="/flashcards" className="btn btn-secondary btn-sm">← All decks</Link>
|
||||
<div className="fc-foot-nav">
|
||||
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>← Prev</button>
|
||||
{flipped ? (
|
||||
<>
|
||||
|
|
@ -235,7 +240,7 @@ export default function FlashcardStudyPage() {
|
|||
)}
|
||||
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next →</button>
|
||||
</div>
|
||||
<p className="fc-keys">Space or Enter flips · ← → moves · 1 got it · 2 review</p>
|
||||
<p className="fc-keys">Space flips · ← → moves · 1 got it · 2 review</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue