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
+829
View File
@@ -0,0 +1,829 @@
<!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.9/dist/katex.min.css" crossorigin="anonymous" />
<style>
.sb-content { padding: 0; }
.cw-wrap {
min-height: 100vh;
padding: 28px 24px 60px;
max-width: 1100px; margin: 0 auto; width: 100%;
}
/* ── Header ── */
.cw-header { display:flex; align-items:center; gap:14px; margin-bottom:20px; }
.cw-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background: linear-gradient(135deg,rgba(6,214,224,.2),rgba(155,93,229,.2));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.cw-icon svg { width:26px; height:26px; stroke:#06D6E0; stroke-width:1.8; fill:none; }
.cw-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; letter-spacing:-.02em; }
.cw-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
.cw-header-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
/* ── Subject filter ── */
.cw-filter { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
.cw-pill {
padding:6px 16px; border-radius:99px; border:1.5px solid rgba(255,255,255,.12);
font-size:.8rem; font-weight:600; cursor:pointer; transition:all .15s;
background:transparent; color:var(--text-2);
}
.cw-pill:hover { border-color:rgba(6,214,224,.4); color:#06D6E0; }
.cw-pill.active { background:rgba(6,214,224,.15); border-color:#06D6E0; color:#06D6E0; }
/* ── Main layout ── */
.cw-main { display:flex; gap:20px; align-items:flex-start; }
/* ── Grid ── */
.cw-grid-wrap {
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; padding:14px; flex-shrink:0;
max-width:480px;
box-shadow: var(--shadow);
}
.cw-grid { display:inline-grid; gap:2px; }
.cw-cell {
width:30px; height:30px;
border-radius:5px;
position:relative; cursor:pointer;
display:flex; align-items:center; justify-content:center;
font-size:13px; font-weight:700;
font-family:'Manrope',sans-serif; text-transform:uppercase;
transition: background .1s, border-color .1s;
user-select:none;
}
.cw-cell.black { background:rgba(15,23,42,.1); cursor:default; }
.cw-cell.white {
background:#fff;
border:1.5px solid rgba(15,23,42,.22);
color:var(--text);
box-shadow:0 1px 3px rgba(15,23,42,.08);
}
.cw-cell.white:hover { background:rgba(6,214,224,.12); border-color:rgba(6,214,224,.5); }
.cw-cell.selected { background:rgba(6,214,224,.25) !important; border-color:#06D6E0 !important; }
.cw-cell.highlighted { background:rgba(6,214,224,.1); border-color:rgba(6,214,224,.35); }
.cw-cell.correct { background:rgba(56,217,90,.2) !important; border-color:#38D95A !important; color:#1a7a40 !important; }
.cw-cell.wrong { background:rgba(249,65,68,.15) !important; border-color:#F94144 !important; color:#c0392b !important; }
.cw-num {
position:absolute; top:2px; left:2px;
font-size:7px; font-weight:800; color:var(--text-2);
line-height:1; pointer-events:none;
}
.cw-letter { pointer-events:none; }
/* ── Clues panel ── */
.cw-clues {
flex:1; min-width:0;
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; padding:14px 16px;
max-height:260px; overflow-y:auto;
box-shadow: var(--shadow);
}
.cw-clues-title {
font-family:'Unbounded',sans-serif; font-size:.78rem; font-weight:800;
color:var(--text-2); text-transform:uppercase; letter-spacing:.05em;
margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,.08);
}
.cw-clue-item {
display:flex; gap:8px; align-items:flex-start;
padding:7px 8px; border-radius:8px; cursor:pointer;
transition: background .1s;
margin-bottom:3px;
}
.cw-clue-item:hover { background:rgba(255,255,255,.05); }
.cw-clue-item.active { background:rgba(6,214,224,.12); }
.cw-clue-num {
font-family:'Unbounded',sans-serif; font-size:.7rem; font-weight:800;
color:#06D6E0; flex-shrink:0; min-width:18px; padding-top:1px;
}
.cw-clue-text { font-size:.82rem; color:var(--text); line-height:1.5; }
.cw-clue-meta { font-size:.72rem; color:var(--text-2); margin-top:2px; }
.cw-clue-done { text-decoration:line-through; opacity:.5; }
/* ── Controls ── */
.cw-controls {
display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center;
}
.cw-btn {
padding:8px 18px; border-radius:99px;
border:1.5px solid rgba(255,255,255,.15);
font-family:'Manrope',sans-serif; font-size:.82rem; font-weight:700;
cursor:pointer; transition:all .15s; color:var(--text); background:transparent;
display:flex; align-items:center; gap:6px;
}
.cw-btn:hover { background:rgba(255,255,255,.08); }
.cw-btn.primary { background:#06D6E0; border-color:#06D6E0; color:#0d1117; }
.cw-btn.primary:hover { background:#04b8c0; }
.cw-btn.danger { border-color:rgba(249,65,68,.4); color:#F94144; }
.cw-btn.danger:hover { background:rgba(249,65,68,.1); }
.cw-btn svg { width:14px; height:14px; stroke:currentColor; fill:none; stroke-width:2; }
.cw-progress {
margin-left:auto; font-size:.82rem; color:var(--text-2); font-weight:600;
}
.cw-progress span { color:#06D6E0; font-family:'Unbounded',sans-serif; font-weight:800; }
/* ── Result overlay ── */
.cw-result {
position:fixed; inset:0; z-index:200;
background:rgba(0,0,0,.7); backdrop-filter:blur(8px);
display:none; align-items:center; justify-content:center;
}
.cw-result.visible { display:flex; }
.cw-result-card {
background:var(--surface); border:1.5px solid rgba(255,255,255,.12);
border-radius:24px; padding:36px 40px; text-align:center; max-width:420px;
animation: resIn .4s cubic-bezier(.34,1.56,.64,1);
}
@keyframes resIn { from{opacity:0;transform:scale(.7)} to{opacity:1;transform:scale(1)} }
.cw-res-icon { width:64px; height:64px; margin:0 auto 12px; }
.cw-res-icon svg { width:64px; height:64px; }
.cw-res-title { font-family:'Unbounded',sans-serif; font-size:1.4rem; font-weight:800; margin-bottom:8px; }
.cw-res-sub { font-size:.88rem; color:var(--text-2); margin-bottom:20px; }
.cw-res-xp { font-family:'Unbounded',sans-serif; font-size:1.1rem; font-weight:800; color:#F9C74F; margin-bottom:24px; }
.cw-res-actions { display:flex; gap:10px; justify-content:center; }
/* ── Loading / empty ── */
.cw-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:60px; color:var(--text-2); }
.cw-loading-spin {
width:40px; height:40px; border-radius:50%;
border:3px solid rgba(6,214,224,.15); border-top-color:#06D6E0;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
/* ── Toast ── */
.cw-toast {
position:fixed; bottom:24px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--surface); border:1.5px solid rgba(255,255,255,.15);
border-radius:12px; padding:10px 20px;
font-size:.84rem; font-weight:600; color:var(--text);
transition:transform .3s cubic-bezier(.34,1.56,.64,1);
z-index:300; white-space:nowrap;
}
.cw-toast.show { transform:translateX(-50%) translateY(0); }
@media (max-width:768px) {
.cw-main { flex-direction:column; }
.cw-clues { max-height:300px; }
.cw-grid-wrap { max-width:100%; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="sbl-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="sbl-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<span class="sb-link active"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></span>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="sbl-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding:4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="cw-wrap">
<div class="cw-header">
<div class="cw-icon">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</div>
<div>
<div class="cw-title">Кроссворд</div>
<div class="cw-sub">Угадай биологические термины по вопросам</div>
</div>
<div class="cw-header-right">
<button class="cw-btn primary" id="btn-new-game" onclick="loadCrossword()">
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Новый
</button>
</div>
</div>
<!-- Subject filter -->
<div class="cw-filter" id="cw-filter">
<button class="cw-pill active" data-slug="" onclick="setSubject(this,'')">Все предметы</button>
<button class="cw-pill" data-slug="bio" onclick="setSubject(this,'bio')">Биология</button>
<button class="cw-pill" data-slug="chem" onclick="setSubject(this,'chem')">Химия</button>
<button class="cw-pill" data-slug="math" onclick="setSubject(this,'math')">Математика</button>
<button class="cw-pill" data-slug="phys" onclick="setSubject(this,'phys')">Физика</button>
</div>
<!-- Controls -->
<div class="cw-controls" id="cw-controls" style="display:none">
<button class="cw-btn" id="btn-check" onclick="checkAnswers()">
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Проверить
</button>
<button class="cw-btn" id="btn-reveal" onclick="revealSelected()">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Открыть слово
</button>
<button class="cw-btn danger" id="btn-give-up" onclick="giveUp()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Сдаться
</button>
<div class="cw-progress">Угадано: <span id="cw-solved">0</span>/<span id="cw-total">0</span></div>
</div>
<!-- Main content -->
<div id="cw-loading" class="cw-loading">
<div class="cw-loading-spin"></div>
<div>Генерируем кроссворд…</div>
</div>
<div class="cw-main" id="cw-main" style="display:none">
<div class="cw-grid-wrap">
<div class="cw-grid" id="cw-grid"></div>
</div>
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:12px;">
<div class="cw-clues" id="cw-clues-across">
<div class="cw-clues-title"><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> По горизонтали</div>
<div id="clues-across-list"></div>
</div>
<div class="cw-clues" id="cw-clues-down">
<div class="cw-clues-title"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> По вертикали</div>
<div id="clues-down-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Result overlay -->
<div class="cw-result" id="cw-result">
<div class="cw-result-card">
<div class="cw-res-icon" id="res-icon"></div>
<div class="cw-res-title" id="res-title"></div>
<div class="cw-res-sub" id="res-sub"></div>
<div class="cw-res-xp" id="res-xp" style="display:none"></div>
<div class="cw-res-actions">
<button class="cw-btn primary" onclick="loadCrossword(); closeResult()">
<svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Новый кроссворд
</button>
<button class="cw-btn" onclick="closeResult()">Остаться</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="cw-toast" id="cw-toast"></div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
/* ── State (must be before the IIFE to avoid TDZ) ── */
let _data = null;
let _userGrid = [];
let _revealed = new Set();
let _sel = { r: -1, c: -1, dir: 'across' };
let _hintsUsed = 0;
let _solved = new Set();
let _finished = false;
let _subjectSlug = '';
(async () => {
if (!LS.requireAuth()) return;
const user = LS.getUser();
LS.applyRoleSidebar(user);
if (user) {
document.getElementById('nav-avatar').textContent =
(user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
document.getElementById('nav-user').textContent = user.name || '—';
if (user.role === 'admin')
document.getElementById('sbl-admin').style.display = '';
LS.showBoardIfAllowed();
if (user.role !== 'student') {
document.getElementById('sbl-classes').style.display = '';
}
}
LS.sidebar?.init();
lucide.createIcons();
// Feature gate
const feats = await LS.loadFeatures();
if (feats.crossword === false) {
document.getElementById('cw-loading').innerHTML =
'<div style="color:var(--text-2);text-align:center">Кроссворд отключён администратором.</div>';
LS.hideDisabledFeatures();
return;
}
LS.hideDisabledFeatures();
await loadCrossword();
})();
/* ── Load ── */
async function loadCrossword() {
_finished = false;
_data = null;
_userGrid = [];
_revealed = new Set();
_solved = new Set();
_hintsUsed = 0;
_sel = { r: -1, c: -1, dir: 'across' };
document.getElementById('cw-loading').style.display = '';
document.getElementById('cw-main').style.display = 'none';
document.getElementById('cw-controls').style.display = 'none';
const qs = _subjectSlug ? `?subject_slug=${_subjectSlug}` : '';
const data = await LS.api(`/api/games/crossword/generate${qs}`).catch(() => null);
if (!data || !data.grid) {
document.getElementById('cw-loading').innerHTML =
'<div style="color:var(--text-2);text-align:center">Недостаточно тем для кроссворда.<br>Попробуйте другой предмет.</div>';
return;
}
_data = data;
_userGrid = data.grid.map(row => row.map(cell => cell === null ? null : ''));
document.getElementById('cw-loading').style.display = 'none';
document.getElementById('cw-main').style.display = '';
document.getElementById('cw-controls').style.display = '';
renderGrid();
renderClues();
updateProgress();
}
function setSubject(el, slug) {
document.querySelectorAll('.cw-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
_subjectSlug = slug;
loadCrossword();
}
/* ── Render grid ── */
function renderGrid() {
if (!_data) return;
const { grid, words } = _data;
const rows = grid.length, cols = grid[0].length;
// Build number map
const numMap = {};
for (const w of words) numMap[`${w.row},${w.col}`] = w.num;
// Dynamic cell size: fit grid in 452px (480px wrap 28px padding)
const isMobile = window.innerWidth <= 768;
const maxPx = isMobile ? Math.min(window.innerWidth - 40, 420) : 452;
const cs = Math.max(18, Math.min(32, Math.floor((maxPx - 4) / Math.max(cols, rows) - 2)));
const fs = Math.round(cs * 0.44); // letter font-size
const ns = Math.max(7, Math.round(cs * 0.24)); // number font-size
const container = document.getElementById('cw-grid');
container.style.gridTemplateColumns = `repeat(${cols}, ${cs}px)`;
container.innerHTML = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = document.createElement('div');
cell.className = grid[r][c] === null ? 'cw-cell black' : 'cw-cell white';
cell.dataset.r = r;
cell.dataset.c = c;
cell.style.width = cs + 'px';
cell.style.height = cs + 'px';
cell.style.fontSize = fs + 'px';
if (grid[r][c] !== null) {
const num = numMap[`${r},${c}`];
if (num) {
const n = document.createElement('span');
n.className = 'cw-num';
n.textContent = num;
n.style.fontSize = ns + 'px';
cell.appendChild(n);
}
const ltr = document.createElement('span');
ltr.className = 'cw-letter';
ltr.id = `cell-${r}-${c}`;
ltr.textContent = _userGrid[r][c] || '';
cell.appendChild(ltr);
cell.addEventListener('click', () => onCellClick(r, c));
}
container.appendChild(cell);
}
}
document.addEventListener('keydown', onKeyDown);
}
function renderClues() {
const { across, down } = _data;
const buildList = (words, container) => {
container.innerHTML = '';
for (const w of words) {
const item = document.createElement('div');
item.className = 'cw-clue-item';
item.id = `clue-${w.dir}-${w.num}`;
item.dataset.num = w.num;
item.dataset.dir = w.dir;
item.onclick = () => selectWord(w);
item.innerHTML = `
<span class="cw-clue-num">${w.num}</span>
<div>
<div class="cw-clue-text">${escHtml(w.clue)}</div>
<div class="cw-clue-meta">${escHtml(w.subjectName)} · ${w.word.length} букв</div>
</div>`;
container.appendChild(item);
}
};
buildList(across, document.getElementById('clues-across-list'));
buildList(down, document.getElementById('clues-down-list'));
if (window.renderMathInElement) {
const mathOpts = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
};
renderMathInElement(document.getElementById('clues-across-list'), mathOpts);
renderMathInElement(document.getElementById('clues-down-list'), mathOpts);
}
}
function escHtml(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ── Selection ── */
function _findWordAt(r, c, dir) {
return _data.words.find(w =>
w.dir === dir &&
(dir === 'across'
? w.row === r && c >= w.col && c < w.col + w.word.length
: w.col === c && r >= w.row && r < w.row + w.word.length)
) || null;
}
function onCellClick(r, c) {
if (_data.grid[r][c] === null || _finished) return;
if (_sel.r === r && _sel.c === c) {
// Toggle direction — only if there's actually a word in the other direction
const other = _sel.dir === 'across' ? 'down' : 'across';
if (_findWordAt(r, c, other)) _sel.dir = other;
} else {
_sel.r = r; _sel.c = c;
// Auto-pick direction: keep current if valid, otherwise try the other
if (!_findWordAt(r, c, _sel.dir)) {
const other = _sel.dir === 'across' ? 'down' : 'across';
if (_findWordAt(r, c, other)) _sel.dir = other;
}
}
updateHighlights();
updateClueHighlight();
}
function selectWord(word) {
_sel.r = word.row; _sel.c = word.col; _sel.dir = word.dir;
updateHighlights();
updateClueHighlight();
}
function updateHighlights() {
// Clear all highlights
document.querySelectorAll('.cw-cell.highlighted, .cw-cell.selected').forEach(el => {
el.classList.remove('highlighted', 'selected');
});
if (_sel.r < 0) return;
// Find the word that contains the selected cell in the current direction
const word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (word) {
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
const el = getCellEl(wr, wc);
if (el) el.classList.add('highlighted');
}
}
const selEl = getCellEl(_sel.r, _sel.c);
if (selEl) { selEl.classList.remove('highlighted'); selEl.classList.add('selected'); }
}
function updateClueHighlight() {
document.querySelectorAll('.cw-clue-item.active').forEach(el => el.classList.remove('active'));
if (_sel.r < 0) return;
const word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (word) {
const el = document.getElementById(`clue-${word.dir}-${word.num}`);
if (el) { el.classList.add('active'); el.scrollIntoView({ block: 'nearest' }); }
}
}
function getCellEl(r, c) {
return document.querySelector(`.cw-cell[data-r="${r}"][data-c="${c}"]`);
}
/* ── Keyboard input ── */
function onKeyDown(e) {
if (_finished || _sel.r < 0) return;
const { r, c, dir } = _sel;
if (e.key === 'Tab') {
e.preventDefault();
const words = _data.words.filter(w => w.dir === dir);
const cur = words.find(w =>
dir === 'across'
? w.row === r && c >= w.col && c < w.col + w.word.length
: w.col === c && r >= w.row && r < w.row + w.word.length
);
const idx = cur ? words.indexOf(cur) : -1;
const next = words[(idx + (e.shiftKey ? -1 : 1) + words.length) % words.length];
if (next) { _sel.r = next.row; _sel.c = next.col; updateHighlights(); updateClueHighlight(); }
return;
}
if (e.key === 'ArrowRight') { _sel.dir = 'across'; _sel.r = r; moveCursor(0, 1); return; }
if (e.key === 'ArrowLeft') { _sel.dir = 'across'; _sel.r = r; moveCursor(0,-1); return; }
if (e.key === 'ArrowDown') { _sel.dir = 'down'; _sel.c = c; moveCursor(1, 0); return; }
if (e.key === 'ArrowUp') { _sel.dir = 'down'; _sel.c = c; moveCursor(-1,0); return; }
if (e.key === 'Backspace') {
if (_userGrid[r][c]) {
_userGrid[r][c] = '';
updateCellDisplay(r, c);
removeCellState(r, c);
} else {
moveCursor(dir === 'across' ? 0 : -1, dir === 'across' ? -1 : 0);
const { r: nr, c: nc } = _sel;
if (_userGrid[nr]?.[nc] !== undefined && _userGrid[nr][nc] !== null) {
_userGrid[nr][nc] = '';
updateCellDisplay(nr, nc);
removeCellState(nr, nc);
}
}
return;
}
// Letter key
const ch = e.key.toUpperCase();
if (/^[А-ЯЁA-Z]$/.test(ch) && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (_data.grid[r][c] === null) return;
_userGrid[r][c] = ch;
updateCellDisplay(r, c);
removeCellState(r, c);
// Advance cursor
moveCursor(dir === 'across' ? 0 : 1, dir === 'across' ? 1 : 0);
// Auto-check word completion
checkWordCompletion();
}
}
function moveCursor(dr, dc) {
let nr = _sel.r + dr, nc = _sel.c + dc;
// Skip null/out-of-bounds cells
while (nr >= 0 && nc >= 0 && nr < _data.grid.length && nc < _data.grid[0].length && _data.grid[nr][nc] === null) {
nr += dr; nc += dc;
}
if (nr >= 0 && nc >= 0 && nr < _data.grid.length && nc < _data.grid[0].length && _data.grid[nr][nc] !== null) {
_sel.r = nr; _sel.c = nc;
}
updateHighlights();
updateClueHighlight();
}
function updateCellDisplay(r, c) {
const el = document.getElementById(`cell-${r}-${c}`);
if (el) el.textContent = _userGrid[r][c] || '';
}
function removeCellState(r, c) {
const el = getCellEl(r, c);
if (el) { el.classList.remove('correct', 'wrong'); }
}
/* ── Auto-check word ── */
function checkWordCompletion() {
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
let complete = true;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
if (!_userGrid[wr][wc]) { complete = false; break; }
}
if (!complete) continue;
// Check if correct
let correct = true;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
if (_userGrid[wr][wc] !== w.word[i]) { correct = false; break; }
}
if (correct) {
_solved.add(`${w.dir}-${w.num}`);
markWord(w, 'correct');
updateProgress();
showToast(`<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Слово #${w.num} угадано!`);
const clueEl = document.getElementById(`clue-${w.dir}-${w.num}`);
if (clueEl) clueEl.querySelector('.cw-clue-text')?.classList.add('cw-clue-done');
if (_solved.size === _data.words.length) setTimeout(finishGame, 400);
}
}
}
function markWord(word, state) {
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('correct','wrong'); el.classList.add(state); }
}
}
function updateProgress() {
document.getElementById('cw-solved').textContent = _solved.size;
document.getElementById('cw-total').textContent = _data?.words.length || 0;
}
/* ── Check answers ── */
function checkAnswers() {
if (!_data || _finished) return;
let anyWrong = false;
let filledCount = 0;
let emptyCount = 0;
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
const val = _userGrid[wr][wc];
if (!val) { emptyCount++; continue; }
filledCount++;
const el = getCellEl(wr, wc);
if (!el) continue;
el.classList.remove('correct','wrong');
if (val === w.word[i]) el.classList.add('correct');
else { el.classList.add('wrong'); anyWrong = true; }
}
}
if (filledCount === 0) showToast('Сначала введите ответы');
else if (anyWrong) showToast('Есть ошибки — выделены красным');
else if (emptyCount > 0) showToast(`Верно! Осталось заполнить ${emptyCount} клеток`);
else showToast('Всё верно! <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>');
}
/* ── Reveal selected word ── */
function revealSelected() {
if (!_data || _finished) return;
if (_sel.r < 0) { showToast('Выберите клетку в кроссворде'); return; }
let word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (!word) {
const otherDir = _sel.dir === 'across' ? 'down' : 'across';
word = _findWordAt(_sel.r, _sel.c, otherDir);
if (word) _sel.dir = otherDir;
}
if (!word) { showToast('Выберите клетку со словом'); return; }
if (_solved.has(`${word.dir}-${word.num}`)) { showToast('Слово уже угадано'); return; }
_hintsUsed++;
_revealed.add(`${word.dir}-${word.num}`);
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
_userGrid[wr][wc] = word.word[i];
updateCellDisplay(wr, wc);
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('wrong','correct'); el.classList.add('correct'); }
}
_solved.add(`${word.dir}-${word.num}`);
updateProgress();
const clueEl = document.getElementById(`clue-${word.dir}-${word.num}`);
if (clueEl) clueEl.querySelector('.cw-clue-text')?.classList.add('cw-clue-done');
if (_solved.size === _data.words.length) setTimeout(finishGame, 300);
}
/* ── Give up ── */
function giveUp() {
if (!_data || _finished) return;
_finished = true;
// Reveal all words
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
_userGrid[wr][wc] = w.word[i];
updateCellDisplay(wr, wc);
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('wrong','selected','highlighted'); el.classList.add('correct'); }
}
}
LS.api('/api/games/crossword/complete', { method: 'POST', body: JSON.stringify({ completed: false, hintsUsed: _hintsUsed }) }).catch(()=>{});
showResult(false, _solved.size);
}
/* ── Finish ── */
async function finishGame() {
_finished = true;
const resp = await LS.api('/api/games/crossword/complete', {
method: 'POST',
body: JSON.stringify({ completed: true, hintsUsed: _hintsUsed }),
}).catch(() => ({ xp: 0 }));
showResult(true, _data.words.length, resp.xp || 0);
}
function showResult(won, solvedCount, xp = 0) {
const resIcon = document.getElementById('res-icon');
const resTitle = document.getElementById('res-title');
const resSub = document.getElementById('res-sub');
const resXP = document.getElementById('res-xp');
if (won) {
resIcon.innerHTML = `<svg viewBox="0 0 24 24" style="stroke:#F9C74F;fill:none;stroke-width:1.5">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
<path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
<path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg>`;
resTitle.textContent = 'Кроссворд решён!';
resSub.textContent = _hintsUsed ? `Использовано подсказок: ${_hintsUsed}` : 'Без единой подсказки!';
if (xp) { resXP.textContent = `+${xp} XP`; resXP.style.display = ''; }
} else {
resIcon.innerHTML = `<svg viewBox="0 0 24 24" style="stroke:#F94144;fill:none;stroke-width:1.5">
<circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`;
resTitle.textContent = 'Не получилось';
resSub.textContent = `Угадано: ${solvedCount} из ${_data?.words.length || 0} слов`;
resXP.style.display = 'none';
}
document.getElementById('cw-result').classList.add('visible');
}
function closeResult() {
document.getElementById('cw-result').classList.remove('visible');
}
/* ── Toast ── */
function showToast(msg) {
const el = document.getElementById('cw-toast');
el.innerHTML = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>