LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+818
View File
@@ -0,0 +1,818 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тест — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
onload="window._katexReady = true; if(window._katexReadyCb) window._katexReadyCb()"></script>
<style>
.nav-timer { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 700; color: var(--text); }
.nav-timer.warning { color: var(--pink); }
/* ── layout ── */
.layout { display: grid; grid-template-columns: 260px 1fr; gap: 24px; max-width: 1100px; margin: 0 auto; padding: 28px 20px 60px; }
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; padding: 16px 14px 60px; }
.sidebar { order: 2; }
.q-card { padding: 20px 18px; }
.q-text { font-size: 0.95rem; }
.confirm-box { padding: 28px 20px; }
.confirm-actions { flex-wrap: wrap; }
.confirm-cancel, .confirm-ok { flex: 1; }
}
@media (max-width: 480px) {
.layout { padding: 12px 10px 60px; gap: 14px; }
.q-card { padding: 16px 14px; }
.dot { width: 24px; height: 24px; font-size: 0.64rem; border-radius: 6px; }
.q-opt { padding: 10px 12px; gap: 10px; }
.q-text { font-size: 0.9rem; }
.q-match-row { flex-direction: column; gap: 8px; }
.q-match-sel { width: 100%; }
}
/* ── sidebar ── */
.sidebar { display: flex; flex-direction: column; gap: 16px; }
.card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 20px; box-shadow: var(--shadow); }
.card-label { font-size: 0.75rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
.progress-bar { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; margin-bottom: 8px; }
.progress-fill { height: 100%; background: var(--grad-1); border-radius: 99px; transition: width 0.4s ease; }
.progress-text { font-size: 0.82rem; color: var(--text-2); }
.dots { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.dot { width: 28px; height: 28px; border-radius: 8px; border: 1.5px solid var(--border-h); background: transparent; font-size: 0.7rem; font-weight: 700; color: var(--text-3); cursor: pointer; transition: all var(--tr); display: flex; align-items: center; justify-content: center; }
.dot.answered { background: rgba(6,214,224,0.12); border-color: var(--cyan); color: var(--text); }
.dot.current { background: var(--violet); border-color: var(--violet); color: #fff; }
.btn-finish { width: 100%; padding: 13px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700; cursor: pointer; transition: transform var(--tr), box-shadow var(--tr); }
.btn-finish:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(6,214,224,0.4); }
.btn-finish:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* ── question ── */
.q-area { display: flex; flex-direction: column; gap: 0; }
.q-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px 32px; box-shadow: var(--shadow); display: none; }
.q-card.active { display: block; }
.q-num-badge { display: inline-flex; align-items: center; gap: 8px; font-size: 0.78rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 16px; }
.q-num-badge span { background: var(--grad-1); color: #fff; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.72rem; }
.q-type-hint { display: inline-block; font-size: 0.72rem; color: var(--text-3); background: rgba(15,23,42,0.05); padding: 2px 8px; border-radius: var(--r-pill); margin-left: 8px; }
.q-text { font-size: 1rem; line-height: 1.7; color: var(--text); margin-bottom: 24px; }
.q-options { display: flex; flex-direction: column; gap: 10px; }
.q-opt { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border: 1.5px solid var(--border-h); border-radius: 14px; cursor: pointer; transition: all var(--tr); user-select: none; }
.q-opt:hover { border-color: var(--violet); background: rgba(155,93,229,0.04); }
.q-opt.selected { border-color: var(--cyan); background: rgba(6,214,224,0.06); }
.q-opt-key { width: 28px; height: 28px; border-radius: 8px; background: rgba(15,23,42,0.06); display: flex; align-items: center; justify-content: center; font-size: 0.78rem; font-weight: 700; color: var(--text-3); flex-shrink: 0; transition: all var(--tr); }
.q-opt.selected .q-opt-key { background: var(--cyan); color: #fff; }
.q-opt-text { font-size: 0.9rem; color: var(--text); }
/* ── short answer ── */
.q-short-wrap { padding: 4px 0 16px; }
.q-text-input { width: 100%; padding: 13px 16px; border: 1.5px solid var(--border-h); border-radius: 14px; font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s, background 0.2s; }
.q-text-input:focus { border-color: var(--violet); background: #fff; }
.q-hint { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.q-match-wrap { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 16px; }
.q-match-hint { font-size: 0.78rem; color: var(--text-3); margin-bottom: 4px; }
.q-match-row { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 12px; transition: border-color var(--tr); }
.q-match-row:has(.q-match-sel:not([value=""])) { border-color: rgba(6,214,224,0.5); background: rgba(6,214,224,0.04); }
.q-match-left { flex: 1; font-size: 0.9rem; font-weight: 600; min-width: 100px; }
.q-match-sel { flex: 1; padding: 8px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s; cursor: pointer; }
.q-match-sel:focus { border-color: var(--cyan); }
/* ── navigation ── */
.q-nav { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
.btn-nav { padding: 10px 22px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--text); cursor: pointer; transition: all var(--tr); }
.btn-nav:hover { border-color: var(--violet); color: var(--violet); }
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── loading/error ── */
.state-screen { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; padding: 40px; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.state-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 700; }
.state-desc { font-size: 0.9rem; color: var(--text-2); }
.btn-primary { padding: 12px 28px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-block; }
/* ── auto-advance progress ring on selected option ── */
.q-opt.auto-advancing { border-color: var(--cyan); background: rgba(6,214,224,0.08); }
.q-opt.auto-advancing .q-opt-key { background: var(--cyan); color: #fff; }
@keyframes ring-fill { from { stroke-dashoffset: 56.5; } to { stroke-dashoffset: 0; } }
.advance-ring { position: absolute; top: 50%; right: 18px; transform: translateY(-50%); width: 22px; height: 22px; }
.advance-ring circle { fill: none; stroke: var(--cyan); stroke-width: 2.5; stroke-dasharray: 56.5; stroke-dashoffset: 56.5; stroke-linecap: round; animation: ring-fill 0.65s linear forwards; transform-origin: center; transform: rotate(-90deg); }
.q-opt { position: relative; }
/* ── confirm modal ── */
.confirm-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 300; display: none; align-items: center; justify-content: center; padding: 20px; }
.confirm-overlay.open { display: flex; }
.confirm-box { background: #fff; border-radius: 20px; padding: 36px 32px; width: 100%; max-width: 400px; box-shadow: 0 24px 80px rgba(15,23,42,0.2); text-align: center; }
.confirm-icon { font-size: 2.4rem; margin-bottom: 12px; }
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 10px; }
.confirm-desc { font-size: 0.88rem; color: var(--text-2); line-height: 1.6; margin-bottom: 24px; }
.confirm-skipped { display: inline-block; margin: 8px 0 4px; padding: 6px 16px; border-radius: var(--r-pill); background: rgba(255,179,71,0.12); color: #c47f00; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
.confirm-cancel { padding: 11px 26px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.confirm-cancel:hover { border-color: var(--violet); color: var(--violet); }
.confirm-ok { padding: 11px 26px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
.confirm-ok:hover { transform: translateY(-1px); }
/* ── btn-finish counter ── */
.btn-finish-count { font-size: 0.72rem; opacity: 0.75; font-weight: 600; display: block; margin-top: 2px; }
/* ── flag button ── */
.q-card { position: relative; }
.btn-flag { position: absolute; top: 16px; right: 20px; width: 34px; height: 34px; border-radius: 10px; border: 1.5px solid var(--border-h); background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--tr); color: var(--text-3); }
.btn-flag:hover { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.08); }
.btn-flag.flagged { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.12); }
.dot.flagged { border-color: #f59e0b !important; }
.dot.flagged:not(.answered) { color: #f59e0b; }
.dot.current.flagged { background: #f59e0b; border-color: #f59e0b; color: #fff; }
.dot.answered.flagged { background: rgba(245,158,11,0.18); }
/* ── Combo system ── */
.combo-toast {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%) scale(0.7);
z-index: 500; pointer-events: none;
display: flex; align-items: center; gap: 10px;
padding: 10px 22px; border-radius: 99px;
font-family: 'Unbounded', sans-serif; font-weight: 900;
opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
}
.combo-toast.show { opacity: 1; transform: translateX(-50%) scale(1); }
.combo-toast.hide { opacity: 0; transform: translateX(-50%) scale(0.7) translateY(-10px); }
.combo-toast.c3 { background: linear-gradient(135deg, rgba(6,214,224,0.15), rgba(6,214,100,0.1)); border: 2px solid rgba(6,214,224,0.3); color: #06B6D4; font-size: 0.82rem; }
.combo-toast.c5 { background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.1)); border: 2px solid rgba(155,93,229,0.35); color: #9B5DE5; font-size: 0.92rem; }
.combo-toast.c10 { background: linear-gradient(135deg, rgba(255,215,0,0.18), rgba(255,165,0,0.12)); border: 2px solid rgba(255,215,0,0.4); color: #D97706; font-size: 1.05rem; }
.combo-icon { font-size: 1.3em; }
.combo-break {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
z-index: 500; pointer-events: none;
padding: 6px 16px; border-radius: 99px;
background: rgba(241,91,181,0.1); border: 1.5px solid rgba(241,91,181,0.25);
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: #F15BB5;
opacity: 0; transition: all 0.3s ease;
}
.combo-break.show { opacity: 1; }
.combo-break.hide { opacity: 0; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<nav class="nav">
<a href="/dashboard" class="nav-logo">Learn<span>Space</span></a>
<div class="nav-right">
<span id="nav-subject">Тест</span>
<span id="nav-mode" style="font-size:0.68rem;padding:3px 10px;border-radius:99px;font-weight:700;display:none"></span>
<span class="nav-timer" id="nav-timer"></span>
</div>
</nav>
<!-- Загрузка -->
<div class="state-screen" id="screen-loading">
<div class="spinner"></div>
<div class="state-title">Загружаем тест...</div>
</div>
<!-- Ошибка -->
<div class="state-screen" id="screen-error" style="display:none">
<div class="state-title">Ошибка загрузки</div>
<div class="state-desc" id="error-msg"></div>
<a href="/dashboard" class="btn-primary">На главную</a>
</div>
<!-- Тест -->
<div class="layout" id="screen-test" style="display:none">
<aside class="sidebar">
<div class="card">
<div class="card-label" id="sidebar-label">Прогресс</div>
<div class="progress-bar"><div class="progress-fill" id="prog-fill" style="width:0%"></div></div>
<div class="progress-text"><span id="prog-answered">0</span> / <span id="prog-total">0</span> отвечено</div>
<div class="dots" id="dots"></div>
</div>
<div class="card">
<button class="btn-finish" id="btn-finish">Завершить тест</button>
</div>
<label style="display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:0.74rem;color:var(--text-2);cursor:pointer;user-select:none">
<input type="checkbox" id="auto-adv-toggle" style="accent-color:var(--violet)" /> Автопереход
</label>
</aside>
<div class="q-area" id="q-area"></div>
</div>
<!-- Confirm finish modal -->
<div class="confirm-overlay" id="confirm-overlay" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-box">
<div class="confirm-icon" id="confirm-icon"></div>
<div class="confirm-title">Завершить тест?</div>
<div class="confirm-desc" id="confirm-desc"></div>
<div class="confirm-actions">
<button class="confirm-cancel" onclick="closeConfirm()">Вернуться</button>
<button class="confirm-ok" onclick="doFinish()">Завершить</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error('Not logged in');
// Set confirm modal icon via lsIcon
document.getElementById('confirm-icon').innerHTML = lsIcon('clipboard', 36);
const params = new URLSearchParams(location.search);
const subject = params.get('subject') || 'bio';
const mode = params.get('mode') || 'exam';
const count = Number(params.get('count') || 25);
const topic_id = params.get('topic') || null;
const test_id = params.get('test') || null;
const assignmentMode = params.get('assignment_mode') || null; // 'exam' | 'repeat' | 'ct'
// Whether timer should run: exam mode or ct mode
const useTimer = !assignmentMode || assignmentMode === 'exam' || assignmentMode === 'ct';
// ct mode: split questions into Part A (single/true_false) and Part B (multi/short_answer)
const isCTMode = assignmentMode === 'ct';
let session_id, questions, currentIdx = 0;
let sessionMode = mode;
// answers[q.id] = option_id (single/true_false) | [opt_id,...] (multi) | "text" (short_answer)
let answers = {};
let flags = {}; // flags[q.id] = true → marked for review
let startTimes = {};
let timerInterval, secondsLeft;
let ctPartBStart = 0; // index where Part B begins in ct mode
const _matchShuffles = {}; // cache shuffled right-side values per question id
/* ── SessionStorage persistence ──────────────────────────────────── */
function _ssKey() { return `ls_test_${session_id}`; }
function saveToSession() {
if (!session_id) return;
try { sessionStorage.setItem(_ssKey(), JSON.stringify({ answers, flags, currentIdx })); } catch {}
}
function restoreFromSession() {
try {
const raw = sessionStorage.getItem(_ssKey());
if (!raw) return;
const d = JSON.parse(raw);
if (d.answers) answers = d.answers;
if (d.flags) flags = d.flags;
if (typeof d.currentIdx === 'number') currentIdx = d.currentIdx;
} catch {}
}
function clearSession() {
try { sessionStorage.removeItem(_ssKey()); } catch {}
}
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика' };
/* ── Инициализация ──────────────────────────────────────────────── */
async function init() {
try {
const existingSession = params.get('session');
const data = existingSession
? await LS.getSessionQuestions(existingSession)
: await LS.startSession(subject, mode, count, topic_id, test_id);
sessionMode = data.mode || mode;
document.getElementById('nav-subject').textContent =
SUBJECT_NAMES[data.subject_slug] || SUBJECT_NAMES[subject] || 'Тест';
// Show mode indicator
const modeBadge = document.getElementById('nav-mode');
const modeInfo = { practice: { label: 'Практика', bg: 'rgba(6,214,100,0.15)', color: '#059652' }, exam: { label: 'Экзамен', bg: 'rgba(241,91,181,0.12)', color: '#F15BB5' }, ct: { label: 'ЦТ/ЦЭ', bg: 'rgba(155,93,229,0.15)', color: '#9B5DE5' }, repeat: { label: 'Повтор', bg: 'rgba(6,214,224,0.15)', color: '#06B6D4' } };
const mi = modeInfo[sessionMode] || modeInfo[assignmentMode];
if (mi && modeBadge) { modeBadge.textContent = mi.label; modeBadge.style.background = mi.bg; modeBadge.style.color = mi.color; modeBadge.style.display = ''; }
session_id = data.session_id;
questions = data.questions;
restoreFromSession();
document.getElementById('prog-total').textContent = questions.length;
// For ct mode: count Part A / Part B boundary
if (isCTMode) {
ctPartBStart = questions.findIndex(q => !['single','true_false'].includes(q.type || 'single'));
if (ctPartBStart === -1) ctPartBStart = questions.length; // all Part A
const lbl = document.getElementById('sidebar-label');
if (lbl) lbl.innerHTML = `ЦТ/ЦЭ &nbsp;<span style="font-size:0.65rem;color:var(--violet)">А: ${ctPartBStart} / Б: ${questions.length - ctPartBStart}</span>`;
}
renderDots();
renderQuestion(currentIdx);
if (useTimer) {
let timerSec = data.time_limit_sec || questions.length * 90;
if (data.started_at && data.time_limit_sec) {
const elapsed = Math.round((Date.now() - new Date(data.started_at).getTime()) / 1000);
timerSec = Math.max(1, data.time_limit_sec - elapsed);
}
startTimer(timerSec);
}
show('screen-test');
} catch (err) {
document.getElementById('error-msg').textContent = err.message;
show('screen-error');
}
hide('screen-loading');
}
/* ── Таймер ─────────────────────────────────────────────────────── */
function startTimer(seconds) {
secondsLeft = seconds;
updateTimerDisplay();
timerInterval = setInterval(() => {
secondsLeft--;
updateTimerDisplay();
if (secondsLeft <= 0) { clearInterval(timerInterval); doFinish(); }
if (secondsLeft <= 120) document.getElementById('nav-timer').classList.add('warning');
}, 1000);
}
function updateTimerDisplay() {
const m = String(Math.floor(secondsLeft / 60)).padStart(2, '0');
const s = String(secondsLeft % 60).padStart(2, '0');
document.getElementById('nav-timer').textContent = `${m}:${s}`;
}
/* ── KaTeX ──────────────────────────────────────────────────────── */
function renderMath(el) {
if (!el) return;
const run = () => {
if (window.renderMathInElement) {
renderMathInElement(el, {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
});
}
};
if (window._katexReady) run(); else window._katexReadyCb = run;
}
/* ── Проверка: есть ли ответ на вопрос ─────────────────────────── */
function isAnswered(q) {
const a = answers[q.id];
if (q.type === 'short_answer') return !!(a && String(a).trim());
if (q.type === 'multi') return !!(Array.isArray(a) && a.length > 0);
if (q.type === 'matching') {
if (!a || typeof a !== 'object') return false;
return q.options.every(opt => a[opt.id] && a[opt.id] !== '');
}
return !!a;
}
/* ── Рендер вопроса ─────────────────────────────────────────────── */
function renderQuestion(idx) {
currentIdx = idx;
const q = questions[idx];
startTimes[q.id] = startTimes[q.id] || Date.now();
const type = q.type || 'single';
// Build options HTML
let bodyHtml = '';
if (type === 'matching') {
// Shuffle right-side values once per question (cached)
if (!_matchShuffles[q.id]) _matchShuffles[q.id] = q.options.map(o => o.match_pair).sort(() => Math.random() - 0.5);
const rightVals = _matchShuffles[q.id];
const curPairs = answers[q.id] || {};
bodyHtml = `
<div class="q-match-wrap">
<div class="q-match-hint">Выберите пару для каждого элемента</div>
${q.options.map(opt => `
<div class="q-match-row">
<div class="q-match-left">${esc(opt.text)}</div>
<select class="q-match-sel" data-opt-id="${opt.id}" onchange="onMatchChange(${q.id},${opt.id},this.value)">
<option value="">— выбрать —</option>
${rightVals.map(v => `<option value="${esc(v)}"${curPairs[opt.id]===v?' selected':''}>${esc(v)}</option>`).join('')}
</select>
</div>`).join('')}
</div>`;
} else if (type === 'short_answer') {
const curVal = typeof answers[q.id] === 'string' ? esc(answers[q.id]) : '';
bodyHtml = `
<div class="q-short-wrap">
<input type="text" class="q-text-input" id="q-input-${q.id}"
placeholder="Введите ваш ответ…" value="${curVal}" autocomplete="off" />
<div class="q-hint">Сравнение без учёта регистра и пробелов по краям</div>
</div>`;
} else {
const isMulti = type === 'multi';
const optHtml = q.options.map((opt, i) => {
const sel = isMulti
? (Array.isArray(answers[q.id]) && answers[q.id].includes(opt.id))
: answers[q.id] === opt.id;
const keyLabel = isMulti ? (sel ? lsIcon('check', 14) : lsIcon('square', 14)) : String.fromCharCode(65 + i);
return `<div class="q-opt${sel ? ' selected' : ''}" data-opt-id="${opt.id}" data-i="${i}">
<div class="q-opt-key">${keyLabel}</div>
<div class="q-opt-text">${esc(opt.text)}</div>
</div>`;
}).join('');
bodyHtml = `<div class="q-options" id="opts">${optHtml}</div>`;
if (isMulti) {
bodyHtml += `<div class="q-hint" style="margin-top:10px">Можно выбрать несколько вариантов</div>`;
}
}
const typeLabel = { single:'Один ответ', multi:'Несколько ответов', true_false:'Верно / Неверно', short_answer:'Краткий ответ', matching:'Сопоставление' }[type] || '';
const ctSection = isCTMode
? (idx < ctPartBStart ? '<span style="font-size:0.75rem;font-weight:800;color:var(--violet);margin-right:6px">Часть А</span>' : '<span style="font-size:0.75rem;font-weight:800;color:var(--amber);margin-right:6px">Часть Б</span>')
: '';
document.getElementById('q-area').innerHTML = `
<div class="q-card active">
<button class="btn-flag${flags[q.id] ? ' flagged' : ''}" id="btn-flag-${q.id}" title="Отметить для проверки" onclick="toggleFlag(${q.id})">
<svg viewBox="0 0 24 24" width="16" height="16" fill="${flags[q.id] ? '#f59e0b' : 'none'}" stroke="${flags[q.id] ? '#f59e0b' : 'currentColor'}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
</button>
<div class="q-num-badge">
${ctSection}Вопрос <span>${idx + 1} / ${questions.length}</span>
${typeLabel ? `<span class="q-type-hint">${typeLabel}</span>` : ''}
${flags[q.id] ? '<span style="font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px">отмечен</span>' : ''}
</div>
${q.image ? `<img src="${esc(q.image)}" alt="" style="max-width:100%;max-height:260px;border-radius:10px;margin-bottom:12px;display:block" />` : ''}
<p class="q-text">${esc(q.text)}</p>
${bodyHtml}
<div class="q-nav">
<button class="btn-nav" id="btn-prev" ${idx === 0 ? 'disabled' : ''}><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Назад</button>
<button class="btn-nav" id="btn-next">${idx === questions.length - 1 ? 'Завершить' : 'Вперёд <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'}</button>
</div>
</div>`;
// Wire events
if (type === 'short_answer') {
const inp = document.getElementById(`q-input-${q.id}`);
inp.addEventListener('input', () => {
answers[q.id] = inp.value;
updateDots();
updateProgress();
});
inp.addEventListener('blur', () => {
if (String(answers[q.id] || '').trim()) sendAnswerAsync(q);
});
inp.focus();
} else if (type === 'matching') {
// matching: handled via onMatchChange inline (see function below)
} else {
const isMulti = type === 'multi';
document.querySelectorAll('.q-opt').forEach(el => {
el.addEventListener('click', () => {
const optId = Number(el.dataset.optId);
if (isMulti) toggleMultiOpt(q, optId);
else selectSingleOpt(q, optId);
});
});
}
document.getElementById('btn-prev').addEventListener('click', () => {
cancelAutoAdvance();
flushShortAnswer(q);
renderQuestion(idx - 1);
});
document.getElementById('btn-next').addEventListener('click', () => {
cancelAutoAdvance();
flushShortAnswer(q);
if (idx === questions.length - 1) tryFinish();
else renderQuestion(idx + 1);
});
updateDots();
renderMath(document.getElementById('q-area'));
saveToSession();
}
/* ── Matching ── */
function onMatchChange(qid, optId, val) {
const q = questions.find(q => q.id === qid);
if (!q) return;
if (!answers[qid] || typeof answers[qid] !== 'object') answers[qid] = {};
answers[qid][optId] = val;
updateDots();
updateProgress();
if (isAnswered(q)) sendAnswerAsync(q);
}
/* send short_answer to server if not yet sent */
const _sentAnswers = new Set();
function flushShortAnswer(q) {
if ((q.type || 'single') !== 'short_answer') return;
const text = String(answers[q.id] || '').trim();
if (text && !_sentAnswers.has(q.id)) sendAnswerAsync(q);
}
/* ── Авто-переход ────────────────────────────────────────────────── */
let _autoAdvanceTimer = null;
function cancelAutoAdvance() {
if (_autoAdvanceTimer) { clearTimeout(_autoAdvanceTimer); _autoAdvanceTimer = null; }
// remove ring from all options
document.querySelectorAll('.advance-ring').forEach(r => r.remove());
document.querySelectorAll('.q-opt.auto-advancing').forEach(el => el.classList.remove('auto-advancing'));
}
/* ── Выбор ответа — одиночный ───────────────────────────────────── */
function selectSingleOpt(q, option_id) {
cancelAutoAdvance();
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
answers[q.id] = option_id;
document.querySelectorAll('.q-opt').forEach((el, i) => {
const sel = q.options[i].id === option_id;
el.classList.toggle('selected', sel);
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : String.fromCharCode(65 + i);
if (sel) {
// attach progress ring svg
el.classList.add('auto-advancing');
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('class','advance-ring');
svg.setAttribute('viewBox','0 0 22 22');
const circle = document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx','11'); circle.setAttribute('cy','11'); circle.setAttribute('r','9');
svg.appendChild(circle);
el.appendChild(svg);
}
});
updateDots();
updateProgress();
LS.sendAnswer(session_id, q.id, option_id, timeSpent)
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
.catch(console.error);
// auto-advance to next question (not on last question, can be disabled)
if (currentIdx < questions.length - 1 && localStorage.getItem('ls_auto_advance') !== '0') {
_autoAdvanceTimer = setTimeout(() => {
_autoAdvanceTimer = null;
if (answers[q.id] === option_id) renderQuestion(currentIdx + 1);
}, 1200);
}
}
/* ── Выбор ответа — множественный ───────────────────────────────── */
function toggleMultiOpt(q, option_id) {
if (!Array.isArray(answers[q.id])) answers[q.id] = [];
const arr = answers[q.id];
const idx = arr.indexOf(option_id);
if (idx === -1) arr.push(option_id); else arr.splice(idx, 1);
document.querySelectorAll('.q-opt').forEach(el => {
const id = Number(el.dataset.optId);
const sel = arr.includes(id);
el.classList.toggle('selected', sel);
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : lsIcon('square', 14);
});
updateDots();
updateProgress();
// debounce multi send (avoid hammering on rapid toggles)
clearTimeout(toggleMultiOpt._t);
toggleMultiOpt._t = setTimeout(() => sendAnswerAsync(q), 500);
}
/* ── Отправка ответа ─────────────────────────────────────────────── */
function sendAnswerAsync(q) {
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
const type = q.type || 'single';
let option_id = null, answer_text = null, chosen_options = null;
if (type === 'short_answer') {
answer_text = String(answers[q.id] || '').trim();
_sentAnswers.add(q.id);
} else if (type === 'multi') {
chosen_options = answers[q.id] || [];
} else if (type === 'matching') {
answer_text = JSON.stringify(answers[q.id] || {});
} else {
option_id = answers[q.id];
}
LS.sendAnswer(session_id, q.id, option_id, timeSpent, answer_text, chosen_options)
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
.catch(() => { LS.toast('Ответ не сохранён — нет связи с сервером', 'error'); });
}
/* ── Завершить ──────────────────────────────────────────────────── */
function tryFinish() {
cancelAutoAdvance();
if (questions[currentIdx]) flushShortAnswer(questions[currentIdx]);
const answered = questions.filter(isAnswered).length;
const total = questions.length;
const skipped = total - answered;
if (skipped > 0) {
const desc = document.getElementById('confirm-desc');
desc.innerHTML = skipped === total
? `Вы не ответили ни на один вопрос.<br>Результат будет нулевым.`
: `<span class="confirm-skipped">${lsIcon('warning', 16)} ${skipped} ${skipped === 1 ? 'вопрос без ответа' : skipped < 5 ? 'вопроса без ответа' : 'вопросов без ответа'}</span><br>Пропущенные вопросы будут засчитаны как неверные.`;
document.getElementById('confirm-overlay').classList.add('open');
return;
}
doFinish();
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
async function doFinish() {
closeConfirm();
clearInterval(timerInterval);
clearSession();
_finishing = true;
const btn = document.getElementById('btn-finish');
btn.disabled = true;
btn.innerHTML = 'Завершаем…';
try {
await LS.finishSession(session_id);
window.location.href = `/test-result?session=${session_id}`;
} catch (err) {
LS.toast('Ошибка: ' + err.message, 'error');
btn.disabled = false;
updateFinishBtn();
}
}
function updateFinishBtn() {
const answered = questions.filter(isAnswered).length;
const total = questions.length;
const btn = document.getElementById('btn-finish');
if (!btn) return;
if (answered === total) {
btn.innerHTML = 'Завершить тест ' + lsIcon('check', 14);
} else {
btn.innerHTML = `Завершить тест<span class="btn-finish-count">${answered} / ${total} отвечено</span>`;
}
}
/* ── Combo system ────────────────────────────────────────────────── */
let _combo = 0, _maxCombo = 0, _comboEl = null, _comboTimer = null;
function _showCombo(count) {
_clearComboEl();
const el = document.createElement('div');
let cls = '', icon = '', text = '';
if (count >= 10) { cls = 'c10'; icon = lsIcon('flame', 20); text = `COMBO x${count}! +бонус XP`; }
else if (count >= 5) { cls = 'c5'; icon = lsIcon('zap', 20); text = `Combo x${count}!`; }
else if (count >= 3) { cls = 'c3'; icon = lsIcon('sparkles', 20); text = `Combo x${count}`; }
else return;
el.className = `combo-toast ${cls}`;
el.innerHTML = `<span class="combo-icon">${icon}</span>${text}`;
document.body.appendChild(el);
_comboEl = el;
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
_comboTimer = setTimeout(() => {
el.classList.remove('show');
el.classList.add('hide');
setTimeout(() => el.remove(), 350);
if (_comboEl === el) _comboEl = null;
}, 1800);
}
function _showComboBreak(was) {
if (was < 3) return;
_clearComboEl();
const el = document.createElement('div');
el.className = 'combo-break';
el.textContent = `Комбо x${was} прервано`;
document.body.appendChild(el);
_comboEl = el;
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
setTimeout(() => {
el.classList.remove('show');
el.classList.add('hide');
setTimeout(() => el.remove(), 300);
if (_comboEl === el) _comboEl = null;
}, 1200);
}
function _clearComboEl() {
if (_comboTimer) { clearTimeout(_comboTimer); _comboTimer = null; }
if (_comboEl) { _comboEl.remove(); _comboEl = null; }
}
function _handleAnswerResult(isCorrect) {
if (isCorrect) {
_combo++;
if (_combo > _maxCombo) _maxCombo = _combo;
_showCombo(_combo);
} else {
const was = _combo;
_combo = 0;
_showComboBreak(was);
}
}
/* ── Навигационные точки ────────────────────────────────────────── */
function renderDots() {
const el = document.getElementById('dots');
el.innerHTML = '';
questions.forEach((q, i) => {
if (isCTMode && i === 0 && ctPartBStart > 0) {
const lbl = document.createElement('div');
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--text-3);text-transform:uppercase;letter-spacing:0.07em;margin-top:4px';
lbl.textContent = 'Часть А';
el.appendChild(lbl);
}
if (isCTMode && i === ctPartBStart && ctPartBStart > 0 && ctPartBStart < questions.length) {
const lbl = document.createElement('div');
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--amber);text-transform:uppercase;letter-spacing:0.07em;margin-top:8px';
lbl.textContent = 'Часть Б';
el.appendChild(lbl);
}
const dot = document.createElement('button');
dot.className = 'dot';
dot.textContent = i + 1;
dot.addEventListener('click', () => { cancelAutoAdvance(); renderQuestion(i); });
el.appendChild(dot);
});
}
function updateDots() {
document.querySelectorAll('.dot').forEach((dot, i) => {
dot.classList.toggle('current', i === currentIdx);
dot.classList.toggle('answered', isAnswered(questions[i]));
dot.classList.toggle('flagged', !!flags[questions[i].id]);
});
}
/* ── Flag toggle ─────────────────────────────────────────────────── */
function toggleFlag(qid) {
flags[qid] = !flags[qid];
// Update flag button
const btn = document.getElementById(`btn-flag-${qid}`);
if (btn) {
btn.classList.toggle('flagged', flags[qid]);
btn.querySelector('svg').setAttribute('fill', flags[qid] ? '#f59e0b' : 'none');
btn.querySelector('svg').setAttribute('stroke', flags[qid] ? '#f59e0b' : 'currentColor');
}
// Update badge label
const badge = document.querySelector('.q-num-badge');
if (badge) {
const existing = badge.querySelector('.flag-label');
if (existing) existing.remove();
if (flags[qid]) {
const lbl = document.createElement('span');
lbl.className = 'flag-label';
lbl.style.cssText = 'font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px';
lbl.textContent = 'отмечен';
badge.appendChild(lbl);
}
}
updateDots();
saveToSession();
}
function updateProgress() {
const n = questions.filter(isAnswered).length;
document.getElementById('prog-answered').textContent = n;
document.getElementById('prog-fill').style.width = `${(n / questions.length) * 100}%`;
updateFinishBtn();
saveToSession();
}
/* ── utils ──────────────────────────────────────────────────────── */
function show(id) { document.getElementById(id).style.display = ''; }
function hide(id) { document.getElementById(id).style.display = 'none'; }
document.getElementById('btn-finish')?.addEventListener('click', tryFinish);
// Auto-advance toggle
const _aaToggle = document.getElementById('auto-adv-toggle');
_aaToggle.checked = localStorage.getItem('ls_auto_advance') !== '0';
_aaToggle.addEventListener('change', () => {
localStorage.setItem('ls_auto_advance', _aaToggle.checked ? '1' : '0');
if (!_aaToggle.checked) cancelAutoAdvance();
});
/* ── Leave-page warning ──────────────────────────────────────────── */
let _finishing = false;
window.addEventListener('beforeunload', e => {
if (_finishing) return;
if (questions && questions.some(isAnswered)) {
e.preventDefault();
e.returnValue = 'Тест не завершён. Ваши ответы сохранены. Выйти?';
}
});
// Keyboard shortcuts: 1-4 / A-D to select answer options
document.addEventListener('keydown', e => {
if (!questions.length || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const q = questions[currentIdx];
if (!q || (q.type && q.type !== 'single' && q.type !== 'true_false')) return;
let idx = -1;
if (e.key >= '1' && e.key <= '4') idx = parseInt(e.key) - 1;
else if (e.key.toLowerCase() >= 'a' && e.key.toLowerCase() <= 'd') idx = e.key.toLowerCase().charCodeAt(0) - 97;
if (idx === -1) return;
const opts = document.querySelectorAll('#q-area .q-opt');
if (opts[idx]) opts[idx].click();
});
// Prevent bfcache from restoring a completed test when user presses Back
window.addEventListener('pageshow', e => {
if (e.persisted) window.location.replace('/dashboard');
});
init();
</script>
</body>
</html>