Replicate ZenithPrep active-exam UI: white question card with inline options, 5-state CBT palette, NTA action bar
This commit is contained in:
parent
a0fcfc93b6
commit
aae54300be
3 changed files with 252 additions and 44 deletions
69
src/views.rs
69
src/views.rs
|
|
@ -785,7 +785,7 @@ fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String {
|
|||
let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false);
|
||||
let rev = a.map(|(_, _, r)| *r).unwrap_or(false);
|
||||
let cls = match (sel, rev) {
|
||||
(true, true) => "pal rev",
|
||||
(true, true) => "pal ansrev",
|
||||
(true, false) => "pal ans",
|
||||
_ => "pal",
|
||||
};
|
||||
|
|
@ -828,31 +828,56 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
|
|||
.collect()
|
||||
};
|
||||
format!(
|
||||
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}"{hidden}>
|
||||
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}" data-review="{rev0}"{hidden}>
|
||||
<div class="pane-head">
|
||||
<span class="dim">question {n} of {total} · {subj}</span>
|
||||
<span class="dim">{qt} · +4 / −1{revflag}</span>
|
||||
<span class="dim">question {n}</span>
|
||||
<span class="qpill">{subj} · {qtword} · +4 / −1{revflag}</span>
|
||||
</div>
|
||||
<div class="stem">{stem}</div>
|
||||
<div class="qcard"><div class="stem">{stem}</div>{qopts}</div>
|
||||
<div class="dim meta-line">{sub2} · {ch} · {year} {shift} · {difficulty}</div>
|
||||
{opts}
|
||||
<div class="opt-actions">
|
||||
<button type="button" class="btn" data-act="prev"{prevdis}>previous</button>
|
||||
<button type="button" class="btn ok" data-act="save">save & next</button>
|
||||
<button type="button" class="btn" data-act="review"{revcls}>mark for review</button>
|
||||
<button type="button" class="btn" data-act="clear">clear</button>
|
||||
<div class="exam-actions">
|
||||
<div class="exam-actions-left">
|
||||
<button type="button" class="btn act-save" data-act="save">save & next</button>
|
||||
<button type="button" class="btn" data-act="clear">clear response</button>
|
||||
<button type="button" class="btn act-mark" data-act="marksave">save & mark for review</button>
|
||||
<button type="button" class="btn act-review" data-act="review"{revcls}>mark for review & next</button>
|
||||
</div>
|
||||
<div class="exam-actions-right">
|
||||
<button type="button" class="btn" data-act="prev"{prevdis}>‹ back</button>
|
||||
<button type="button" class="btn" data-act="next">next ›</button>
|
||||
<button class="btn primary" type="submit">submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>"#,
|
||||
hidden = if i == 0 { "" } else { " hidden" },
|
||||
rev0 = if rev { "1" } else { "0" },
|
||||
prevdis = if i == 0 { " disabled" } else { "" },
|
||||
revcls = if rev { " danger" } else { "" },
|
||||
total = questions.len(),
|
||||
n = i + 1,
|
||||
subj = esc(&q.subject),
|
||||
ch = esc(&q.chapter),
|
||||
qt = esc(&q.qtype),
|
||||
revflag = if rev { " · <span class=\"warn-t\">marked</span>" } else { "" },
|
||||
qtword = if q.qtype == "NUM" { "numerical" } else { "single correct" },
|
||||
revflag = format!(" · <span class=\"warn-t mflag\"{h}>marked</span>", h = if rev { "" } else { " hidden" }),
|
||||
stem = rich(&q.stem),
|
||||
qopts = if q.qtype == "NUM" {
|
||||
String::new()
|
||||
} else {
|
||||
// Read-only option text inside the white card (mirrors the
|
||||
// typeset block; the pills below are the click targets).
|
||||
let items: String = ["A", "B", "C", "D"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, _letter)| {
|
||||
format!(
|
||||
"<p>({n}) {t}</p>",
|
||||
n = k + 1,
|
||||
t = rich(&q.opts[k]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
items
|
||||
},
|
||||
sub2 = esc(&q.subtopic),
|
||||
year = q.year,
|
||||
shift = esc(&q.shift),
|
||||
|
|
@ -886,6 +911,11 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
|
|||
r#"<div class="exam-bar">
|
||||
<div class="exam-bar-inner">
|
||||
<div class="exam-title"><b>{title}</b><span class="dim" id="exam-progress">{n} questions</span></div>
|
||||
<div class="exam-cand">
|
||||
<span class="dim">candidate</span>
|
||||
<b>{user}</b>
|
||||
<span class="saved dim" id="save-dot" title="autosave state"></span>
|
||||
</div>
|
||||
<div class="exam-clock"><span class="dim">time left</span><div class="timer" id="timer" data-secs="{secs}" role="timer">{dur}</div></div>
|
||||
</div>
|
||||
<div class="exam-progress"><i id="exam-fill"></i></div>
|
||||
|
|
@ -896,12 +926,14 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
|
|||
<div>{qs}</div>
|
||||
<aside class="cbt-side" id="pal-wrap">
|
||||
<h2>palette</h2>
|
||||
<div class="palette" id="pal" aria-label="question palette">{pal}</div>
|
||||
<ul class="legend">
|
||||
<li><i class="sw ans"></i>answered</li>
|
||||
<li><i class="sw rev"></i>review</li>
|
||||
<li><i class="sw skip"></i>blank</li>
|
||||
<ul class="pal-stats" id="pal-stats">
|
||||
<li><i class="sw visit"></i>not visited <b data-c="visit">0</b></li>
|
||||
<li><i class="sw skip"></i>not answered <b data-c="skip">0</b></li>
|
||||
<li><i class="sw ans"></i>answered <b data-c="ans">0</b></li>
|
||||
<li><i class="sw rev"></i>marked for review <b data-c="rev">0</b></li>
|
||||
<li><i class="sw ansrev"></i>answered & marked <b data-c="ansrev">0</b></li>
|
||||
</ul>
|
||||
<div class="palette" id="pal" aria-label="question palette">{pal}</div>
|
||||
<p class="cbt-count dim" id="cbt-count">0 answered</p>
|
||||
<button class="btn primary block" type="submit">submit paper</button>
|
||||
<button type="button" class="btn block" data-close-palette>close palette</button>
|
||||
|
|
@ -911,6 +943,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
|
|||
</form>
|
||||
<script src="{b}/static/exam.js"></script>"#,
|
||||
title = esc(title),
|
||||
user = esc(user),
|
||||
n = questions.len(),
|
||||
dur = fmt_dur(duration_min as i64 * 60),
|
||||
secs = duration_min * 60,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
const box = p.querySelector('input');
|
||||
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
|
||||
}
|
||||
const visited = new Set();
|
||||
const saveDot = document.getElementById('save-dot');
|
||||
|
||||
function questionId(pane) {
|
||||
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
window._examShow = show;
|
||||
function show(i) {
|
||||
cur = Math.max(0, Math.min(panes.length - 1, i));
|
||||
visited.add(cur);
|
||||
panes.forEach((p, k) => { p.hidden = k !== cur; });
|
||||
palBtns.forEach((b, k) => {
|
||||
b.classList.toggle('cur', k === cur);
|
||||
|
|
@ -63,35 +66,45 @@
|
|||
if (!btn) return;
|
||||
const answered = collect(pane).selected !== '';
|
||||
const review = pane.dataset.review === '1';
|
||||
const seen = visited.has(Number(pane.dataset.i));
|
||||
// 5 CBT states: unseen (default), visited+blank (skip), answered, marked, answered+marked.
|
||||
btn.classList.toggle('ans', answered && !review);
|
||||
btn.classList.toggle('rev', answered && review);
|
||||
btn.classList.toggle('skip', !answered);
|
||||
btn.classList.toggle('ansrev', answered && review);
|
||||
btn.classList.toggle('rev', !answered && review);
|
||||
btn.classList.toggle('skip', !answered && !review && seen);
|
||||
btn.classList.remove('cur');
|
||||
const mark = answered ? (review ? 'R' : '✓') : '·';
|
||||
const mark = answered ? (review ? '✓R' : '✓') : '·';
|
||||
btn.setAttribute('aria-label', `question ${Number(pane.dataset.i) + 1}, ${answered ? 'answered' : 'blank'}${review ? ', marked for review' : ''}`);
|
||||
btn.dataset.mark = mark;
|
||||
paintProgress();
|
||||
}
|
||||
|
||||
function counts() {
|
||||
let answered = 0, review = 0;
|
||||
let answered = 0, review = 0, ansrev = 0, visit = 0;
|
||||
for (const p of panes) {
|
||||
const c = collect(p);
|
||||
if (c.selected !== '') answered++;
|
||||
if (c.marked_review) review++;
|
||||
const seen = visited.has(Number(p.dataset.i));
|
||||
if (!seen) visit++;
|
||||
if (c.marked_review) { c.selected !== '' ? ansrev++ : review++; }
|
||||
else if (c.selected !== '') answered++;
|
||||
}
|
||||
return { answered, review, left: panes.length - answered };
|
||||
return { answered, review, ansrev, visit, left: panes.length - answered - ansrev };
|
||||
}
|
||||
|
||||
function paintProgress() {
|
||||
const { answered, review, left } = counts();
|
||||
const done = answered + review;
|
||||
const { answered, review, ansrev, visit, left } = counts();
|
||||
const done = answered + review + ansrev;
|
||||
if (progressEl) {
|
||||
progressEl.textContent = `${done} of ${panes.length} marked · question ${cur + 1}`;
|
||||
}
|
||||
if (fillEl) fillEl.style.width = `${panes.length ? (done / panes.length) * 100 : 0}%`;
|
||||
if (countEl) {
|
||||
countEl.textContent = `${answered} answered · ${left} blank${review ? ` · ${review} review` : ''}`;
|
||||
countEl.textContent = `${answered + ansrev} answered · ${left} left${review + ansrev ? ` · ${review + ansrev} review` : ''}`;
|
||||
}
|
||||
// Five-state readout above the palette grid, mirroring the NTA CBT legend.
|
||||
for (const li of document.querySelectorAll('#pal-stats [data-c]')) {
|
||||
const c = li.dataset.c;
|
||||
li.textContent = String(c === 'visit' ? visit : c === 'skip' ? panes.length - answered - ansrev - review - visit : c === 'ans' ? answered : c === 'rev' ? review : ansrev);
|
||||
}
|
||||
const fabCount = document.getElementById('pal-fab-count');
|
||||
if (fabCount) fabCount.textContent = left ? `· ${left} left` : '· all answered';
|
||||
|
|
@ -105,6 +118,7 @@
|
|||
// A second change while a save is in flight is coalesced into one follow-up write.
|
||||
if (saving) { pending = all || pending; return; }
|
||||
saving = true;
|
||||
if (saveDot) saveDot.classList.add('busy');
|
||||
const panesToSave = all ? panes : [panes[cur]];
|
||||
try {
|
||||
await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, {
|
||||
|
|
@ -112,10 +126,13 @@
|
|||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ answers: panesToSave.map(collect) }),
|
||||
});
|
||||
if (saveDot) { saveDot.classList.remove('busy'); saveDot.classList.add('ok'); }
|
||||
} catch (_) {
|
||||
// Server unreachable: the selection stays on screen and travels with the form submit.
|
||||
if (saveDot) { saveDot.classList.remove('busy'); saveDot.classList.add('fail'); }
|
||||
}
|
||||
saving = false;
|
||||
if (saveDot && !saving) setTimeout(() => saveDot.classList.remove('ok', 'fail'), 1200);
|
||||
if (pending) { const again = pending; pending = false; save(again); }
|
||||
}
|
||||
|
||||
|
|
@ -133,10 +150,18 @@
|
|||
if (!btn) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'prev') { show(cur - 1); }
|
||||
if (act === 'next') { show(cur + 1); }
|
||||
if (act === 'save') { save(false).then(() => { if (cur < panes.length - 1) show(cur + 1); }); }
|
||||
if (act === 'marksave') {
|
||||
pane.dataset.review = '1';
|
||||
pane.querySelector('.mflag')?.removeAttribute('hidden');
|
||||
syncPalette(pane);
|
||||
save(false).then(() => { if (cur < panes.length - 1) show(cur + 1); });
|
||||
}
|
||||
if (act === 'review') {
|
||||
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
|
||||
btn.classList.toggle('danger', pane.dataset.review === '1');
|
||||
pane.querySelector('.mflag')?.toggleAttribute('hidden', pane.dataset.review !== '1');
|
||||
syncPalette(pane);
|
||||
save(false);
|
||||
}
|
||||
|
|
|
|||
182
static/style.css
182
static/style.css
|
|
@ -1107,6 +1107,48 @@ footer {
|
|||
text-rendering: auto;
|
||||
}
|
||||
|
||||
/* Question type pill (right side of pane head) */
|
||||
.qpill {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 3px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* White typeset question card, mirrors the NTA CBT question block */
|
||||
.qcard {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 22px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.qcard .stem,
|
||||
.qcard .stem p,
|
||||
.qcard .stem div,
|
||||
.qcard .stem span {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.qcard .stem {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.qcard p {
|
||||
margin: 8px 0;
|
||||
color: #111827;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.qcard .katex {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Multiple Choice Option Cards */
|
||||
.opt {
|
||||
display: flex;
|
||||
|
|
@ -1175,18 +1217,102 @@ footer {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.opt-actions {
|
||||
/* Exam action bar, mirrors the NTA CBT bottom strip */
|
||||
.exam-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.opt-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 130px;
|
||||
.exam-actions-left,
|
||||
.exam-actions-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-actions .btn {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.exam-actions-left,
|
||||
.exam-actions-right {
|
||||
width: 100%;
|
||||
}
|
||||
.exam-actions .btn {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ZenithPrep-style action colors */
|
||||
.btn.act-save {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.act-save:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.btn.act-mark {
|
||||
background: #d97706;
|
||||
border-color: #d97706;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.act-mark:hover {
|
||||
background: #b45309;
|
||||
}
|
||||
|
||||
.btn.act-review {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.act-review:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.btn.act-review.danger {
|
||||
background: #581c87;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Candidate + autosave indicator in the exam bar */
|
||||
.exam-cand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
margin-right: 18px;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
#save-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
#save-dot.busy {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
#save-dot.ok {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
#save-dot.fail {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* Palette Side Column */
|
||||
|
|
@ -1251,46 +1377,70 @@ footer {
|
|||
color: #fde68a;
|
||||
}
|
||||
|
||||
.pal.ansrev {
|
||||
background: #4c1d95;
|
||||
border-color: #8b5cf6;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
.pal.skip {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.legend {
|
||||
/* Five-state legend counts above the palette grid */
|
||||
.pal-stats {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 8px 0 14px;
|
||||
margin: 10px 0 4px;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legend li {
|
||||
.pal-stats li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.legend .sw {
|
||||
.pal-stats b {
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.pal-stats .sw {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend .sw.ans {
|
||||
.pal-stats .sw.visit {
|
||||
background: #ffffff;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.pal-stats .sw.skip {
|
||||
background: #b91c1c;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.pal-stats .sw.ans {
|
||||
background: #064e3b;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.legend .sw.rev {
|
||||
.pal-stats .sw.rev {
|
||||
background: #78350f;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.legend .sw.skip {
|
||||
background: var(--panel);
|
||||
.pal-stats .sw.ansrev {
|
||||
background: #4c1d95;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Floating Palette Button (Mobile) */
|
||||
|
|
@ -1529,7 +1679,7 @@ button.pal-fab {
|
|||
@media print {
|
||||
.topbar, .tabbar, footer, .exam-bar, .cbt-side, .opt-actions,
|
||||
.mistake-row, .pal-fab, #pal-toggle, form button, .btn, .subj-timer-bar,
|
||||
.exam-progress, .cbt-count, .legend, script, .preset-bar, .subject-tabs,
|
||||
.exam-progress, .cbt-count, .pal-stats, .legend, script, .preset-bar, .subject-tabs,
|
||||
.settings-col, .chapter-actions, .subtopics-tray-head {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue