Axum + SQLite full-stack copy of the ZenithPrep PYQ practice app. Paper builder with unseen-question exclusion, timed CBT screen with palette and autosave, +4/-1 grading with numerical tolerance, per-question analysis, history, dashboard with weak-subtopic tracking and coach notes.
133 lines
No EOL
4.7 KiB
JavaScript
133 lines
No EOL
4.7 KiB
JavaScript
// Exam screen: pane navigation, palette sync, per-question timing and autosave.
|
|
(() => {
|
|
const panes = [...document.querySelectorAll('.qpane')];
|
|
const palBtns = [...document.querySelectorAll('#pal .pal')];
|
|
const timerEl = document.getElementById('timer');
|
|
let cur = 0;
|
|
|
|
const entered = new Map();
|
|
for (const p of panes) {
|
|
const box = p.querySelector('input');
|
|
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
|
|
}
|
|
|
|
function show(i) {
|
|
cur = Math.max(0, Math.min(panes.length - 1, i));
|
|
panes.forEach((p, k) => { p.hidden = k !== cur; });
|
|
palBtns.forEach((b, k) => b.classList.toggle('cur', k === cur));
|
|
const focusable = panes[cur].querySelector('input:not([type=hidden])');
|
|
if (focusable && matchMedia('(pointer: fine)').matches) focusable.focus({ preventScroll: true });
|
|
history.replaceState(null, '', '#q' + (cur + 1));
|
|
}
|
|
|
|
function questionId(pane) {
|
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
|
if (radios.length) return Number(radios[0].name.replace('q-', ''));
|
|
return Number(pane.querySelector('input[type=text]').name.replace('num-', ''));
|
|
}
|
|
|
|
function collect(pane) {
|
|
const i = pane.dataset.i;
|
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
|
const picked = radios.find(r => r.checked);
|
|
const num = pane.querySelector('input[type=text]');
|
|
return {
|
|
question_id: questionId(pane),
|
|
selected: picked ? picked.value : (num ? num.value.trim() : ''),
|
|
marked_review: pane.dataset.review === '1',
|
|
time_secs: Number(entered.get(i) || 0),
|
|
};
|
|
}
|
|
|
|
function syncPalette(pane) {
|
|
const btn = palBtns[Number(pane.dataset.i)];
|
|
if (!btn) return;
|
|
const answered = collect(pane).selected !== '';
|
|
const review = pane.dataset.review === '1';
|
|
btn.classList.toggle('ans', answered && !review);
|
|
btn.classList.toggle('rev', answered && review);
|
|
btn.classList.toggle('skip', !answered);
|
|
btn.classList.remove('cur');
|
|
}
|
|
|
|
let saving = false;
|
|
async function save(all) {
|
|
if (saving) return;
|
|
saving = true;
|
|
const panesToSave = all ? panes : [panes[cur]];
|
|
const attemptId = document.getElementById('exam-form').dataset.attempt;
|
|
try {
|
|
await fetch(`/attempt/${attemptId}/answer`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ answers: panesToSave.map(collect) }),
|
|
});
|
|
} catch (_) {
|
|
// Server unreachable: the selection stays on screen and travels with the form submit.
|
|
}
|
|
saving = false;
|
|
}
|
|
|
|
for (const pane of panes) {
|
|
pane.addEventListener('change', () => {
|
|
pane.querySelectorAll('.opt').forEach(o => {
|
|
const input = o.querySelector('input');
|
|
o.classList.toggle('sel', !!input && input.checked);
|
|
});
|
|
syncPalette(pane);
|
|
save(false);
|
|
});
|
|
pane.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-act]');
|
|
if (!btn) return;
|
|
const act = btn.dataset.act;
|
|
if (act === 'save') { save(false).then(() => show(cur + 1)); }
|
|
if (act === 'review') {
|
|
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
|
|
btn.classList.toggle('danger', pane.dataset.review === '1');
|
|
syncPalette(pane);
|
|
save(false);
|
|
}
|
|
if (act === 'clear') {
|
|
pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; });
|
|
const num = pane.querySelector('input[type=text]');
|
|
if (num) num.value = '';
|
|
pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
|
|
syncPalette(pane);
|
|
save(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
palBtns.forEach((b, i) => b.addEventListener('click', () => show(i)));
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
|
|
if (e.key === 'ArrowRight') show(cur + 1);
|
|
if (e.key === 'ArrowLeft') show(cur - 1);
|
|
});
|
|
|
|
let left = Number(timerEl.dataset.secs || 0);
|
|
if (left > 0) {
|
|
setInterval(() => {
|
|
left -= 1;
|
|
if (left <= 0) {
|
|
clearInterval(this);
|
|
document.getElementById('exam-form').requestSubmit();
|
|
return;
|
|
}
|
|
const h = String(Math.floor(left / 3600)).padStart(2, '0');
|
|
const m = String(Math.floor(left / 60) % 60).padStart(2, '0');
|
|
const s = String(left % 60).padStart(2, '0');
|
|
timerEl.textContent = `${h}:${m}:${s}`;
|
|
}, 1000);
|
|
}
|
|
|
|
document.getElementById('exam-form').addEventListener('submit', e => {
|
|
if (!confirm('Submit this paper? You cannot change answers afterwards.')) e.preventDefault();
|
|
});
|
|
|
|
const hash = Number((location.hash.match(/q(\d+)/) || [])[1]);
|
|
show(hash ? hash - 1 : 0);
|
|
if (cur === 0 && !hash) panes.forEach(syncPalette);
|
|
})(); |