be4d43105e
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>
830 lines
35 KiB
HTML
830 lines
35 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
/* ── 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>
|