feat: attending tip, per-question notes, save to folder, session clock
The question toolbar now carries what a learner actually reaches for. An attending tip — one sentence of the kind said at the bedside, stored separately from the explanation because it is read before the answer is known and must not give it away. A note of their own on that question, replacing a single global note that was one page for everything and so was never about the question in front of you. Saving to a folder, which the collections API has supported all along with nothing in the player able to call it. And the share link, which previously only appeared on the start screen. Panels open one at a time under the toolbar; two at once would push the options off screen. Reset question resets one question, not the attempt: a misclick should cost the answer you just gave, not the nineteen before it. The clock shows session time, time on this question and the running average, in study mode as well as exam mode — four minutes on one question is the number that says whether you are learning or stuck, countdown or no countdown. It pauses, because time spent making tea is not time spent thinking. 208 backend, 249 frontend green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
d1388f3335
commit
d34233d87b
6 changed files with 365 additions and 2 deletions
|
|
@ -0,0 +1,47 @@
|
|||
"""An attending's one-line pearl, and a learner's own note on a question.
|
||||
|
||||
Revision ID: d8e9f0a1b2c3
|
||||
Revises: c7d8e9f0a1b2
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has(table: str, column: str) -> bool:
|
||||
return column in {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
# The sentence an attending would say at the bedside — one idea, not a
|
||||
# summary. Separate from the explanation because it is read before the
|
||||
# answer is known, and must therefore never give it away.
|
||||
if not _has("questions", "attending_tip"):
|
||||
op.add_column("questions", sa.Column("attending_tip", sa.Text, nullable=True))
|
||||
|
||||
if "question_notes" not in inspect(op.get_bind()).get_table_names():
|
||||
op.create_table(
|
||||
"question_notes",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("question_id", sa.Integer,
|
||||
sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||
# A note belongs to one learner on one question; a second note on the
|
||||
# same question is an edit of the first, not another note.
|
||||
sa.UniqueConstraint("user_id", "question_id", name="uq_question_note"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("question_notes")
|
||||
if _has("questions", "attending_tip"):
|
||||
op.drop_column("questions", "attending_tip")
|
||||
|
|
@ -27,6 +27,9 @@ class Question(Base):
|
|||
explanation_image_path = Column(String, nullable=True)
|
||||
option_explanations = Column(JSON, nullable=True) # {option_text: explanation}
|
||||
key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links
|
||||
# One sentence an attending would say at the bedside. Read before the answer
|
||||
# is known, so it must point at the thinking without giving the answer away.
|
||||
attending_tip = Column(Text, nullable=True)
|
||||
difficulty = Column(String(10), nullable=True) # easy | medium | hard
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
|
@ -16,3 +16,21 @@ class UserNote(Base):
|
|||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="note")
|
||||
|
||||
|
||||
class QuestionNote(Base):
|
||||
"""A learner's own note on one question.
|
||||
|
||||
Separate from the single global note, which was one page for everything and
|
||||
so was never about the question in front of you.
|
||||
"""
|
||||
|
||||
__tablename__ = "question_notes"
|
||||
__table_args__ = (UniqueConstraint("user_id", "question_id", name="uq_question_note"),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ class QuestionEdit(BaseModel):
|
|||
additional_category_ids: list[int] | None = None # Full set of extra (non-primary) categories.
|
||||
option_explanations: dict | None = None
|
||||
key_points: list | None = None
|
||||
attending_tip: str | None = None
|
||||
difficulty: Literal['easy', 'medium', 'hard'] | None = None
|
||||
image_path: str | None = None
|
||||
explanation_image_path: str | None = None
|
||||
|
|
@ -384,6 +385,7 @@ def get_question_bank(
|
|||
"explanation_image_path": qu.explanation_image_path,
|
||||
"option_explanations": qu.option_explanations,
|
||||
"key_points": qu.key_points,
|
||||
"attending_tip": qu.attending_tip,
|
||||
"difficulty": qu.difficulty,
|
||||
"user_id": qu.user_id,
|
||||
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
||||
|
|
@ -415,6 +417,7 @@ class ManualQuestionCreate(BaseModel):
|
|||
question_category_id: int | None = None
|
||||
option_explanations: dict | None = None
|
||||
key_points: list | None = None
|
||||
attending_tip: str | None = None
|
||||
difficulty: Literal['easy', 'medium', 'hard'] | None = None
|
||||
image_path: str | None = None
|
||||
explanation_image_path: str | None = None
|
||||
|
|
@ -453,6 +456,7 @@ def create_question_manually(
|
|||
explanation_image_path=images["explanation_image_path"],
|
||||
option_explanations=option_explanations,
|
||||
key_points=key_points,
|
||||
attending_tip=(data.attending_tip or None),
|
||||
difficulty=data.difficulty,
|
||||
user_id=current_user.id,
|
||||
is_shared=1,
|
||||
|
|
@ -708,7 +712,7 @@ def bulk_question_action(
|
|||
MAX_VERSIONS = 5
|
||||
|
||||
VERSIONED_FIELDS = ("question_text", "question_type", "options", "correct_answer",
|
||||
"explanation", "option_explanations", "key_points", "difficulty",
|
||||
"explanation", "option_explanations", "key_points", "attending_tip", "difficulty",
|
||||
"question_category_id", "image_path", "explanation_image_path")
|
||||
|
||||
|
||||
|
|
@ -732,6 +736,47 @@ def _snapshot_question(db, question, user_id) -> None:
|
|||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
class QuestionNoteIn(BaseModel):
|
||||
content: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
@router.get("/detail/{question_id}/note")
|
||||
def read_question_note(question_id: int, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""This learner's own note on this question."""
|
||||
from app.models.user_note import QuestionNote
|
||||
|
||||
row = db.query(QuestionNote).filter_by(
|
||||
user_id=current_user.id, question_id=question_id).first()
|
||||
return {"question_id": question_id, "content": row.content if row else "",
|
||||
"updated_at": row.updated_at if row else None}
|
||||
|
||||
|
||||
@router.put("/detail/{question_id}/note")
|
||||
def write_question_note(question_id: int, data: QuestionNoteIn,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""Save or clear it. Empty means delete, so an emptied note leaves no trace."""
|
||||
from app.models.user_note import QuestionNote
|
||||
|
||||
if not db.query(Question.id).filter(Question.id == question_id).first():
|
||||
raise HTTPException(404, "Question not found")
|
||||
row = db.query(QuestionNote).filter_by(
|
||||
user_id=current_user.id, question_id=question_id).first()
|
||||
content = data.content.strip()
|
||||
if not content:
|
||||
if row:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"question_id": question_id, "content": ""}
|
||||
if row:
|
||||
row.content = content
|
||||
else:
|
||||
db.add(QuestionNote(user_id=current_user.id, question_id=question_id, content=content))
|
||||
db.commit()
|
||||
return {"question_id": question_id, "content": content}
|
||||
|
||||
|
||||
@router.get("/detail/{question_id}/versions")
|
||||
def list_question_versions(
|
||||
question_id: int,
|
||||
|
|
@ -808,6 +853,7 @@ def get_question_detail(
|
|||
"explanation": question.explanation,
|
||||
"option_explanations": question.option_explanations,
|
||||
"key_points": question.key_points,
|
||||
"attending_tip": question.attending_tip,
|
||||
"difficulty": question.difficulty,
|
||||
"question_category_id": question.question_category_id,
|
||||
"question_category_name": category.name if category else None,
|
||||
|
|
|
|||
|
|
@ -199,6 +199,46 @@ function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange
|
|||
)
|
||||
}
|
||||
|
||||
const clock = (seconds) => {
|
||||
const s = Math.max(0, Math.round(seconds))
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Session time, time on this question, and the running average.
|
||||
*
|
||||
* Shown in study mode as well as exam mode: knowing you have spent four minutes
|
||||
* on one question is exactly as useful when nothing is counting down, and it is
|
||||
* the number that tells you whether you are learning or stuck. It can be paused,
|
||||
* because time spent making tea is not time spent thinking.
|
||||
*/
|
||||
function SessionClock({ sessionSeconds, questionSeconds, answered, paused, onTogglePause }) {
|
||||
const average = answered > 0 ? sessionSeconds / answered : null
|
||||
return (
|
||||
<div className="quiz-clock">
|
||||
<button type="button" className="quiz-clock-pause" aria-pressed={paused}
|
||||
onClick={onTogglePause} title={paused ? 'Resume the clock' : 'Pause the clock'}>
|
||||
{paused ? '▶' : '⏸'}
|
||||
</button>
|
||||
<span className="quiz-clock-cell">
|
||||
<strong>{Math.floor(sessionSeconds / 3600)}h {String(Math.floor((sessionSeconds % 3600) / 60)).padStart(2, '0')}m</strong>
|
||||
<em>session</em>
|
||||
</span>
|
||||
<span className="quiz-clock-cell">
|
||||
<strong>{clock(questionSeconds)}</strong>
|
||||
<em>question</em>
|
||||
</span>
|
||||
{average != null && (
|
||||
<span className="quiz-clock-cell">
|
||||
<strong>{clock(average)}</strong>
|
||||
<em>average</em>
|
||||
</span>
|
||||
)}
|
||||
{paused && <span className="quiz-clock-paused">paused</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimerDisplay({ seconds, total }) {
|
||||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||||
const mins = Math.floor(seconds / 60), secs = seconds % 60
|
||||
|
|
@ -354,11 +394,23 @@ export default function QuizPage() {
|
|||
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||||
const [imageZoom, setImageZoom] = useState(1)
|
||||
const [startedAt, setStartedAt] = useState(null)
|
||||
// Wall-clock seconds spent in this session and on the question in front of
|
||||
// you. Kept here rather than derived from startedAt so pausing can stop them.
|
||||
const [sessionSeconds, setSessionSeconds] = useState(0)
|
||||
const [questionSeconds, setQuestionSeconds] = useState(0)
|
||||
const [clockPaused, setClockPaused] = useState(false)
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||
const [manualHighlights, setManualHighlights] = useState({})
|
||||
const [draftAnswer, setDraftAnswer] = useState('')
|
||||
const [tool, setTool] = useState(null)
|
||||
// Which of the per-question panels is open. One at a time: they sit in the
|
||||
// same place under the stem, and two at once would push the options off screen.
|
||||
const [panel, setPanel] = useState(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [noteSaved, setNoteSaved] = useState(true)
|
||||
const [folders, setFolders] = useState([])
|
||||
const [shareUrl, setShareUrl] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [responseStats, setResponseStats] = useState(null)
|
||||
const [statsError, setStatsError] = useState('')
|
||||
|
|
@ -710,7 +762,64 @@ const timerStarted = timeLeft !== null
|
|||
}
|
||||
}, [attemptId, quizMode, saveProgressNow])
|
||||
|
||||
useEffect(() => {
|
||||
if (clockPaused || !attemptId) return
|
||||
const tick = setInterval(() => {
|
||||
setSessionSeconds(v => v + 1)
|
||||
setQuestionSeconds(v => v + 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(tick)
|
||||
}, [clockPaused, attemptId])
|
||||
|
||||
// Time on *this* question restarts when you move to another one.
|
||||
useEffect(() => { setQuestionSeconds(0) }, [current?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!current?.id) return
|
||||
let live = true
|
||||
setPanel(null)
|
||||
api.get(`/questions/detail/${current.id}/note`)
|
||||
.then(res => { if (live) { setNote(res.data?.content || ''); setNoteSaved(true) } })
|
||||
.catch(() => { if (live) { setNote(''); setNoteSaved(true) } })
|
||||
return () => { live = false }
|
||||
}, [current?.id])
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setShareUrl(quiz?.share_token ? `${window.location.origin}/share/${quiz.share_token}` : '')
|
||||
}, [quiz?.share_token])
|
||||
|
||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
|
||||
// Reset one question rather than the whole attempt: a misclick should cost
|
||||
// the answer you just gave, not the nineteen before it.
|
||||
const resetQuestion = (questionId) => {
|
||||
setAnswers(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[questionId]
|
||||
return next
|
||||
})
|
||||
setDraftAnswer('')
|
||||
}
|
||||
|
||||
const saveNote = async (questionId, content) => {
|
||||
setNoteSaved(false)
|
||||
try {
|
||||
await api.put(`/questions/detail/${questionId}/note`, { content })
|
||||
setNoteSaved(true)
|
||||
} catch { setNoteSaved(false) }
|
||||
}
|
||||
|
||||
const saveToFolder = async (collectionId, questionId) => {
|
||||
try {
|
||||
await api.put(`/collections/${collectionId}/questions/${questionId}`)
|
||||
setFolders(list => list.map(f => (f.id === collectionId
|
||||
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
|
||||
} catch { /* the row stays unticked, which is the honest signal */ }
|
||||
}
|
||||
const chooseAnswer = value => {
|
||||
if (!current || (isStudy && answers[current.id])) return
|
||||
if (isStudy) setDraftAnswer(value)
|
||||
|
|
@ -1115,6 +1224,23 @@ const timerStarted = timeLeft !== null
|
|||
/>
|
||||
</div>
|
||||
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
||||
{current.attending_tip && (
|
||||
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
||||
aria-pressed={panel === 'tip'}
|
||||
onClick={() => setPanel(p => (p === 'tip' ? null : 'tip'))}>
|
||||
⚕ <span>Attending tip</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={panel === 'note' ? 'is-on' : ''}
|
||||
aria-pressed={panel === 'note'}
|
||||
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
|
||||
✎ <span>{note ? 'Notes' : 'Add notes'}</span>
|
||||
</button>
|
||||
<button type="button" className={panel === 'save' ? 'is-on' : ''}
|
||||
aria-pressed={panel === 'save'}
|
||||
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
|
||||
⊞ <span>Save</span>
|
||||
</button>
|
||||
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
|
|
@ -1156,6 +1282,63 @@ const timerStarted = timeLeft !== null
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The panels sit under the toolbar, where the eye already is, and
|
||||
only one opens at a time: two would push the options off screen. */}
|
||||
{panel === 'tip' && current.attending_tip && (
|
||||
<div className="quiz-panel is-tip">
|
||||
<span className="quiz-panel-mark" aria-hidden="true">⚕</span>
|
||||
<RichText value={current.attending_tip} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'note' && (
|
||||
<div className="quiz-panel">
|
||||
<textarea className="quiz-note" value={note} rows={4}
|
||||
aria-label="Your note on this question"
|
||||
placeholder="Your own note on this question — Markdown supported. Only you can see it."
|
||||
onChange={e => { setNote(e.target.value); setNoteSaved(false) }}
|
||||
onBlur={() => saveNote(current.id, note)} />
|
||||
<div className="quiz-panel-foot">
|
||||
<span>{noteSaved ? 'Saved' : 'Unsaved'}</span>
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => saveNote(current.id, note)}>Save note</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'save' && (
|
||||
<div className="quiz-panel">
|
||||
<p className="quiz-panel-title">Save to a folder</p>
|
||||
{folders.length === 0 ? (
|
||||
<p className="quiz-panel-empty">No folders yet — make one in the question bank.</p>
|
||||
) : (
|
||||
<ul className="quiz-folders">
|
||||
{folders.map(folder => {
|
||||
const inIt = (folder.question_ids || []).includes(current.id)
|
||||
return (
|
||||
<li key={folder.id}>
|
||||
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
|
||||
onClick={() => saveToFolder(folder.id, current.id)}>
|
||||
{inIt ? '✓ ' : '+ '}{folder.title}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{shareUrl && (
|
||||
<div className="quiz-share">
|
||||
<p className="quiz-panel-title">Share this session</p>
|
||||
<input readOnly value={shareUrl} aria-label="Share link"
|
||||
onFocus={e => e.target.select()} />
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigator.clipboard?.writeText(shareUrl)}>Copy</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.image_path && (
|
||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
||||
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
|
||||
|
|
@ -1250,6 +1433,13 @@ const timerStarted = timeLeft !== null
|
|||
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
|
||||
{responseStats && <p className="quiz-stats-note">
|
||||
{showStats ? (responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet') : 'Response statistics hidden'}
|
||||
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
|
||||
answered={answeredCount} paused={clockPaused}
|
||||
onTogglePause={() => setClockPaused(v => !v)} />
|
||||
<button type="button" className="quiz-stats-toggle"
|
||||
onClick={() => resetQuestion(current.id)} disabled={!answers[current.id]}>
|
||||
↺ Reset question
|
||||
</button>
|
||||
<button type="button" className="quiz-stats-toggle" aria-pressed={showStats} onClick={toggleStats}>
|
||||
{showStats ? 'Hide stats' : 'Show stats'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -159,3 +159,62 @@
|
|||
.quiz-response-stat { font-size: .7rem; }
|
||||
}
|
||||
.quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; }
|
||||
|
||||
/* Per-question panels: attending tip, your note, saving to a folder. They open
|
||||
under the toolbar, one at a time, so the options stay on screen. */
|
||||
.quiz-panel {
|
||||
margin: 8px 0 14px; padding: 12px 14px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.quiz-panel.is-tip { display: flex; gap: 12px; align-items: flex-start; background: var(--expl-bg); border-color: var(--expl-bd); }
|
||||
.quiz-panel-mark { flex-shrink: 0; font-size: 1.2rem; line-height: 1.4; }
|
||||
.quiz-panel.is-tip .rich-text { font-style: italic; }
|
||||
.quiz-panel-title { margin: 0 0 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.quiz-panel-empty { margin: 0; color: var(--text-muted); font-size: 0.84rem; }
|
||||
.quiz-panel-foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 0.76rem; color: var(--text-muted); }
|
||||
|
||||
.quiz-note {
|
||||
width: 100%; min-height: 110px; padding: 10px 12px; resize: vertical;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.88rem; line-height: 1.6;
|
||||
}
|
||||
|
||||
.quiz-folders { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.quiz-folders button {
|
||||
min-height: 32px; padding: 5px 11px; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 999px; background: var(--card-bg);
|
||||
font: inherit; font-size: 0.8rem; color: var(--text);
|
||||
}
|
||||
.quiz-folders button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||
.quiz-folders button.is-in { background: var(--correct-bg); border-color: var(--correct-bd); color: var(--correct-fg); cursor: default; }
|
||||
|
||||
.quiz-share { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.quiz-share .quiz-panel-title { width: 100%; margin: 0; }
|
||||
.quiz-share input {
|
||||
flex: 1; min-width: 180px; padding: 7px 10px; font-size: 0.8rem;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Session, this question, and the running average — in study mode too, because
|
||||
"four minutes on one question" is the number that says whether you are
|
||||
learning or stuck, countdown or no countdown. */
|
||||
.quiz-clock { display: inline-flex; align-items: center; gap: 12px; margin-right: auto; }
|
||||
.quiz-clock-pause {
|
||||
width: 28px; height: 28px; flex-shrink: 0; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 7px; background: var(--card-bg);
|
||||
font-size: 0.72rem; color: var(--text-muted); line-height: 1;
|
||||
}
|
||||
.quiz-clock-pause:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.quiz-clock-cell { display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.quiz-clock-cell strong { font-size: 0.86rem; font-variant-numeric: tabular-nums; color: var(--text); }
|
||||
.quiz-clock-cell em {
|
||||
font-style: normal; font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.quiz-clock-paused { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #b45309; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.quiz-clock { width: 100%; margin-right: 0; margin-bottom: 8px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue