Files
Learn_System/frontend/live-quiz.html
T
Maxim Dolgolyov be4d43105e 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>
2026-04-12 10:10:37 +03:00

854 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Live-квиз — 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" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
/* ── page header ── */
.lq-header {
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
padding: 24px 28px 20px; position: relative; overflow: hidden; flex-shrink: 0;
}
.lq-header-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 22px 22px; pointer-events: none;
}
.lq-header-inner { position: relative; z-index: 1; }
.lq-title {
font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 800;
color: #fff; display: flex; align-items: center; gap: 10px;
}
.lq-session-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 14px; border-radius: 999px;
background: rgba(6,214,160,0.15); border: 1.5px solid rgba(6,214,160,0.3);
font-size: 0.74rem; font-weight: 700; color: #06D6A0;
font-family: 'Unbounded', sans-serif; margin-top: 10px;
}
.lq-session-chip .dot {
width: 7px; height: 7px; border-radius: 50%; background: #06D6A0;
animation: pulse-dot 1.5s ease infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.7); }
}
/* ── body ── */
.lq-body {
flex: 1; display: flex; gap: 0; overflow: hidden; min-height: 0;
}
/* ── left panel ── */
.lq-left {
width: 300px; flex-shrink: 0;
background: #fff; border-right: 1.5px solid rgba(15,23,42,0.08);
overflow-y: auto; display: flex; flex-direction: column;
}
.lq-panel-head {
padding: 16px 18px 12px;
border-bottom: 1px solid rgba(15,23,42,0.07);
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; display: flex; align-items: center; gap: 8px;
}
.class-list { padding: 10px 10px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.class-card {
border: 1.5px solid rgba(15,23,42,0.08); border-radius: 14px;
padding: 13px 14px; cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 12px;
}
.class-card:hover { border-color: rgba(155,93,229,0.25); background: rgba(155,93,229,0.03); }
.class-card.active {
border-color: rgba(155,93,229,0.4); background: rgba(155,93,229,0.06);
box-shadow: 0 2px 10px rgba(155,93,229,0.1);
}
.class-card-icon {
width: 38px; height: 38px; border-radius: 10px;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: #fff;
flex-shrink: 0;
}
.class-card-name { font-size: 0.85rem; font-weight: 700; color: #0F172A; }
.class-card-meta { font-size: 0.72rem; color: #8898AA; margin-top: 2px; }
.lq-session-area { padding: 14px 12px; border-top: 1px solid rgba(15,23,42,0.07); }
.btn-start {
width: 100%; padding: 12px; border: none; border-radius: 12px;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #fff; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-start:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(155,93,229,0.3); }
.btn-start:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; }
.btn-end {
width: 100%; padding: 10px; border: 1.5px solid rgba(239,71,111,0.3); border-radius: 12px;
background: rgba(239,71,111,0.06);
font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 800;
color: #EF476F; cursor: pointer; transition: all 0.15s; margin-top: 8px;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-end:hover { background: rgba(239,71,111,0.12); border-color: #EF476F; }
.active-status {
padding: 10px 12px; border-radius: 12px;
background: rgba(6,214,160,0.08); border: 1.5px solid rgba(6,214,160,0.25);
margin-bottom: 8px;
}
.as-label { font-size: 0.7rem; font-weight: 700; color: #8898AA; margin-bottom: 3px; }
.as-val {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #059652; display: flex; align-items: center; gap: 6px;
}
.as-dot { width: 7px; height: 7px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; }
/* ── right panel ── */
.lq-right { flex: 1; overflow-y: auto; padding: 20px 24px 60px; min-width: 0; }
/* question search */
.lq-search-wrap { position: relative; margin-bottom: 16px; }
.lq-search {
width: 100%; padding: 10px 14px 10px 40px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
font-family: 'Manrope', sans-serif; font-size: 0.86rem; font-weight: 500;
color: #0F172A; background: #fff; outline: none; transition: border-color 0.15s;
}
.lq-search:focus { border-color: var(--violet); }
.lq-search-icon {
position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
color: #8898AA; pointer-events: none; width: 16px; height: 16px;
}
/* section header */
.lq-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; margin: 0 0 12px; display: flex; align-items: center; gap: 8px;
}
/* question pick cards */
.lq-q-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
.lq-q-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 14px; padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 1px 4px rgba(15,23,42,0.04); transition: all 0.15s;
}
.lq-q-card:hover { border-color: rgba(155,93,229,0.25); }
.lq-q-card.launched { border-color: rgba(6,214,160,0.4); background: rgba(6,214,160,0.03); }
.lq-q-body { flex: 1; min-width: 0; }
.lq-q-text {
font-size: 0.84rem; font-weight: 600; color: #0F172A;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
.btn-launch {
padding: 7px 16px; border: none; border-radius: 999px;
background: var(--grad-1); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 800;
cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; flex-shrink: 0;
display: flex; align-items: center; gap: 5px;
}
.btn-launch:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(155,93,229,0.3); }
.btn-launch:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; }
/* ── active question display ── */
.lq-active-wrap {
background: #fff; border: 1.5px solid rgba(155,93,229,0.25);
border-radius: 20px; padding: 22px 24px;
box-shadow: 0 4px 16px rgba(155,93,229,0.08); margin-bottom: 20px;
}
.lq-active-label {
font-size: 0.68rem; font-weight: 700; color: var(--violet);
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.lq-active-text {
font-size: 0.96rem; font-weight: 700; color: #0F172A;
line-height: 1.55; margin-bottom: 16px;
}
.lq-active-options { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.lq-active-opt {
padding: 8px 14px; border-radius: 10px;
border: 1.5px solid rgba(15,23,42,0.08);
font-size: 0.82rem; font-weight: 500; color: #3D4F6B;
display: flex; align-items: center; gap: 10px;
}
.lq-active-opt.correct {
border-color: rgba(6,214,160,0.4); background: rgba(6,214,160,0.06);
color: #059652; font-weight: 700;
}
.lq-opt-letter {
width: 22px; height: 22px; border-radius: 6px;
background: rgba(15,23,42,0.07); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.72rem; font-weight: 800; color: #8898AA;
}
.lq-active-opt.correct .lq-opt-letter { background: #06D6A0; color: #fff; }
/* answer counter */
.lq-counter {
display: flex; align-items: center; gap: 16px;
padding: 14px 18px; background: rgba(155,93,229,0.05);
border-radius: 14px; margin-bottom: 14px;
}
.lq-counter-val {
font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 900;
color: var(--violet);
}
.lq-counter-label { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
.lq-counter-bar-wrap {
flex: 1; height: 8px; background: rgba(155,93,229,0.1); border-radius: 999px; overflow: hidden;
}
.lq-counter-bar {
height: 100%; background: linear-gradient(90deg, #9B5DE5, #06D6E0);
border-radius: 999px; transition: width 0.4s ease;
}
.btn-show-results {
padding: 10px 24px; border: none; border-radius: 999px;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #fff; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s;
display: flex; align-items: center; gap: 7px;
}
.btn-show-results:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(155,93,229,0.3); }
/* ── results chart ── */
.lq-results-wrap {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px; padding: 20px 22px; margin-top: 14px;
}
.lq-results-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;
}
.result-bars { display: flex; flex-direction: column; gap: 10px; }
.result-bar-row { display: flex; align-items: center; gap: 10px; }
.result-bar-label {
min-width: 120px; font-size: 0.78rem; font-weight: 600; color: #3D4F6B;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: flex; align-items: center; gap: 6px;
}
.result-bar-label.correct-lbl { color: #059652; font-weight: 700; }
.rb-correct-marker { color: #06D6A0; }
.result-bar-track { flex: 1; height: 18px; background: rgba(15,23,42,0.05); border-radius: 999px; overflow: hidden; position: relative; }
.result-bar-fill {
height: 100%; border-radius: 999px; transition: width 0.5s ease;
background: rgba(155,93,229,0.35);
}
.result-bar-fill.correct-fill { background: linear-gradient(90deg, #06D6A0, #06D6E0); }
.result-bar-count {
min-width: 36px; text-align: right;
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: #0F172A;
}
/* no session state */
.lq-no-session {
text-align: center; padding: 80px 20px; color: #8898AA;
}
.lq-no-session-icon { margin-bottom: 14px; opacity: 0.2; }
/* badges */
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 0.66rem; font-weight: 700; }
.badge-diff-1 { background: rgba(6,214,160,0.12); color: #059652; }
.badge-diff-2 { background: rgba(255,179,71,0.15); color: #B8860B; }
.badge-diff-3 { background: rgba(239,71,111,0.12); color: #EF476F; }
.badge-type { background: rgba(6,214,224,0.12); color: #0891B2; }
/* ── nav ── */
.nav-active {
background: rgba(155,93,229,0.08) !important;
border-color: var(--violet) !important;
color: var(--violet) !important;
cursor: default; pointer-events: none;
}
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: rgba(155,93,229,0.04); }
.notif-item.unread { background: rgba(155,93,229,0.05); }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
@media (max-width: 768px) {
.lq-body { flex-direction: column; }
.lq-left { width: 100%; max-height: 220px; border-right: none; border-bottom: 1.5px solid rgba(15,23,42,0.08); }
.lq-right { padding: 14px 12px 60px; }
}
</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" onclick="toggleSidebar()" 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"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link"><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>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<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"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link nav-active"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-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="toggleNotifDrop()">
<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">
<!-- Header -->
<div class="lq-header">
<div class="lq-header-dots"></div>
<div class="lq-header-inner">
<div class="lq-title"><i data-lucide="radio" style="width:22px;height:22px;opacity:0.5"></i> Live-квиз</div>
<div id="session-header-chip" style="display:none" class="lq-session-chip">
<div class="dot"></div>
<span id="session-header-label">Сессия активна</span>
</div>
</div>
</div>
<!-- Body -->
<div class="lq-body">
<!-- Left: class selector + session control -->
<div class="lq-left">
<div class="lq-panel-head">
<i data-lucide="graduation-cap" style="width:14px;height:14px;opacity:0.5"></i>
Выберите класс
</div>
<div class="class-list" id="class-list">
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
<div class="spinner" style="margin:0 auto 10px"></div>
Загрузка классов…
</div>
</div>
<div class="lq-session-area" id="session-area">
<div id="active-status-wrap" style="display:none">
<div class="active-status">
<div class="as-label">Статус сессии</div>
<div class="as-val"><div class="as-dot"></div> <span id="as-students-text">Активна</span></div>
</div>
</div>
<button class="btn-start" id="btn-start" onclick="startSession()" disabled>
<i data-lucide="play" style="width:14px;height:14px"></i>
Начать сессию
</button>
<button class="btn-end" id="btn-end" onclick="endSession()" style="display:none">
<i data-lucide="square" style="width:14px;height:14px"></i>
Завершить сессию
</button>
</div>
</div>
<!-- Right: question launcher + active question -->
<div class="lq-right">
<!-- No session state -->
<div id="no-session-state">
<div class="lq-no-session">
<div class="lq-no-session-icon"><i data-lucide="radio" style="width:64px;height:64px"></i></div>
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:1rem;color:#0F172A;margin-bottom:8px">Нет активной сессии</div>
<div style="font-size:0.84rem">Выберите класс и нажмите «Начать сессию»,<br>чтобы запустить Live-квиз</div>
</div>
</div>
<!-- Session active state -->
<div id="session-state" style="display:none">
<!-- Active question -->
<div id="active-q-wrap" style="display:none">
<div class="lq-section-title" style="margin-bottom:10px">
<i data-lucide="zap" style="width:14px;height:14px;color:#FFB347"></i>
Активный вопрос
</div>
<div class="lq-active-wrap" id="active-q-card">
<!-- filled by JS -->
</div>
</div>
<!-- Question search & list -->
<div class="lq-section-title">
<i data-lucide="list" style="width:14px;height:14px;opacity:0.5"></i>
Вопросы для запуска
</div>
<div class="lq-search-wrap">
<i data-lucide="search" class="lq-search-icon"></i>
<input class="lq-search" id="q-search" type="text" placeholder="Поиск вопросов…" />
</div>
<div class="lq-q-list" id="q-list">
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
<div class="spinner" style="margin:0 auto 10px"></div>
Загрузка вопросов…
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
document.getElementById('nav-avatar').textContent =
(user?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
const isTeacher = ['admin', 'teacher'].includes(user?.role);
if (!isTeacher) { location.href = '/dashboard'; throw new Error(); }
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
/* ── helpers ── */
function fmtTime(s) {
const d = new Date(s && s.includes('T') ? s : (s || '').replace(' ', 'T') + 'Z');
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
const TYPE_LABELS = {
single: 'Один ответ', multi: 'Несколько', true_false: 'Да/Нет',
short_answer: 'Краткий ответ', matching: 'Соответствие',
'fill-blank': 'Заполни пробел', ordering: 'Порядок',
};
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
lucide.createIcons();
}
if (localStorage.getItem('ls_sb_collapsed'))
document.querySelector('.app-layout').classList.add('sb-collapsed');
/* ── notifications ── */
function toggleNotifDrop() {
const btn = document.getElementById('notif-btn');
const drop = document.getElementById('notif-drop');
const r = btn.getBoundingClientRect();
drop.style.left = (r.right + 8) + 'px';
drop.style.top = r.top + 'px';
if (drop.classList.toggle('open')) loadNotifs();
}
async function loadNotifs() {
const drop = document.getElementById('notif-drop');
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
try {
const data = await LS.api('/api/notifications?limit=20');
const items = data.items || [];
const badge = document.getElementById('notif-badge');
const unread = items.filter(n => !n.is_read).length;
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${n.link || '#'}" onclick="markRead(${n.id})">
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
</a>`).join('');
} catch {}
}
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method: 'POST' }); } catch {} }
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method: 'POST' }); loadNotifs(); } catch {} }
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
drop.classList.remove('open');
});
/* ── state ── */
let selectedClass = null;
let activeSession = null;
let currentQuestion = null;
let memberCount = 0;
let answerCount = 0;
let sseSource = null;
let allQuestions = [];
let filteredQuestions = [];
let searchTimeout = null;
/* ── load classes ── */
async function loadClasses() {
try {
const classes = await LS.api('/api/classes');
const list = document.getElementById('class-list');
if (!(classes || []).length) {
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Нет доступных классов</div>';
lucide.createIcons();
return;
}
let html = '';
(classes || []).forEach(c => {
const initials = (c.name || '?').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
html += `<div class="class-card" id="cc-${c.id}" onclick="selectClass(${c.id}, '${esc(c.name)}', ${c.members_count || 0})">
<div class="class-card-icon">${initials}</div>
<div>
<div class="class-card-name">${esc(c.name)}</div>
<div class="class-card-meta">${c.members_count || 0} учеников</div>
</div>
</div>`;
});
list.innerHTML = html;
lucide.createIcons();
} catch {
document.getElementById('class-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки</div>';
}
}
async function selectClass(id, name, members) {
if (activeSession) {
if (!await LS.confirm('Выбрать другой класс?', { title: 'Завершить текущую сессию?', confirmText: 'Завершить', danger: true })) return;
endSession(true);
}
selectedClass = { id, name, members };
memberCount = members;
document.querySelectorAll('.class-card').forEach(el => el.classList.remove('active'));
const card = document.getElementById('cc-' + id);
if (card) card.classList.add('active');
document.getElementById('btn-start').disabled = false;
lucide.createIcons();
}
/* ── start session ── */
async function startSession() {
if (!selectedClass || activeSession) return;
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;margin:0"></div> Создаём…';
try {
const session = await LS.api('/api/live', {
method: 'POST',
body: JSON.stringify({ class_id: selectedClass.id }),
});
activeSession = session;
onSessionActive();
loadQuestions();
startSSE();
} catch (e) {
LS.toast(e.message || 'Ошибка создания сессии', 'error');
btn.disabled = false;
btn.innerHTML = '<i data-lucide="play" style="width:14px;height:14px"></i> Начать сессию';
lucide.createIcons();
}
}
function onSessionActive() {
document.getElementById('no-session-state').style.display = 'none';
document.getElementById('session-state').style.display = '';
document.getElementById('active-status-wrap').style.display = '';
document.getElementById('btn-start').style.display = 'none';
document.getElementById('btn-end').style.display = '';
document.getElementById('session-header-chip').style.display = '';
document.getElementById('session-header-label').innerHTML =
'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Активна · ' + selectedClass.name;
updateStudentCounter(0);
lucide.createIcons();
}
function updateStudentCounter(count) {
answerCount = count;
document.getElementById('as-students-text').textContent =
'Ответили: ' + count + ' / ' + memberCount;
}
/* ── end session ── */
async function endSession(silent = false) {
if (!activeSession) return;
if (!silent && !await LS.confirm('Все участники увидят, что сессия завершена.', { title: 'Завершить Live-квиз?', confirmText: 'Завершить', danger: true })) return;
const id = activeSession.id;
activeSession = null;
currentQuestion = null;
if (sseSource) { sseSource.close(); sseSource = null; }
document.getElementById('no-session-state').style.display = '';
document.getElementById('session-state').style.display = 'none';
document.getElementById('active-q-wrap').style.display = 'none';
document.getElementById('active-status-wrap').style.display = 'none';
document.getElementById('btn-start').style.display = '';
document.getElementById('btn-end').style.display = 'none';
document.getElementById('session-header-chip').style.display = 'none';
const btn = document.getElementById('btn-start');
btn.disabled = !selectedClass;
btn.innerHTML = '<i data-lucide="play" style="width:14px;height:14px"></i> Начать сессию';
lucide.createIcons();
try { await LS.api('/api/live/' + id, { method: 'DELETE' }); } catch {}
}
/* ── SSE for real-time answer count ── */
function startSSE() {
const token = localStorage.getItem('ls_token');
if (!token) return;
try {
sseSource = new EventSource('/api/notifications/stream?token=' + encodeURIComponent(token));
sseSource.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'live_answer_count' && activeSession && msg.liveId === activeSession.id) {
updateStudentCounter(msg.count || 0);
// update counter in active-q-card if visible
const counterEl = document.getElementById('lq-answer-count');
if (counterEl) counterEl.textContent = (msg.count || 0) + ' / ' + memberCount;
const barEl = document.getElementById('lq-answer-bar');
if (barEl) {
const pct = memberCount > 0 ? Math.round(((msg.count || 0) / memberCount) * 100) : 0;
barEl.style.width = pct + '%';
}
}
} catch {}
};
sseSource.onerror = () => {
sseSource.close();
sseSource = null;
// retry after 5s if session still active
if (activeSession) setTimeout(startSSE, 5000);
};
} catch {}
}
/* ── load questions ── */
async function loadQuestions() {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
try {
const data = await LS.api('/api/questions?limit=50');
allQuestions = data.rows || [];
filteredQuestions = allQuestions;
renderQuestionList();
} catch {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
}
}
function renderQuestionList() {
const list = document.getElementById('q-list');
if (!filteredQuestions.length) {
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
lucide.createIcons();
return;
}
let html = '';
filteredQuestions.forEach(q => {
const diffCls = 'badge-diff-' + (q.difficulty || 1);
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
const isLaunched = currentQuestion && currentQuestion.id === q.id;
html += `<div class="lq-q-card${isLaunched ? ' launched' : ''}">
<div class="lq-q-body">
<div class="lq-q-text">${esc(q.text)}</div>
<div class="lq-q-meta">
<span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
${q.topic ? `<span>${esc(q.topic)}</span>` : ''}
</div>
</div>
<button class="btn-launch" onclick="launchQuestion(${q.id})" ${isLaunched ? 'disabled' : ''}>
${isLaunched ? '<i data-lucide="check" style="width:13px;height:13px"></i> Запущен' : '<i data-lucide="zap" style="width:13px;height:13px"></i> Запустить'}
</button>
</div>`;
});
list.innerHTML = html;
lucide.createIcons();
}
/* search questions */
document.getElementById('q-search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = document.getElementById('q-search').value.trim().toLowerCase();
filteredQuestions = q
? allQuestions.filter(item => item.text.toLowerCase().includes(q) || (item.topic || '').toLowerCase().includes(q))
: allQuestions;
renderQuestionList();
}, 280);
});
/* ── launch question ── */
async function launchQuestion(questionId) {
if (!activeSession) return;
const q = allQuestions.find(x => x.id === questionId);
if (!q) return;
try {
await LS.api('/api/live/' + activeSession.id + '/question', {
method: 'PUT',
body: JSON.stringify({ question_id: questionId }),
});
currentQuestion = q;
answerCount = 0;
updateStudentCounter(0);
renderActiveQuestion(q);
renderQuestionList();
} catch (e) {
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
}
}
function renderActiveQuestion(q) {
const wrap = document.getElementById('active-q-wrap');
const card = document.getElementById('active-q-card');
wrap.style.display = '';
const diffCls = 'badge-diff-' + (q.difficulty || 1);
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
const pct = memberCount > 0 ? Math.round((answerCount / memberCount) * 100) : 0;
let optionsHtml = '';
if ((q.options || []).length) {
optionsHtml = '<div class="lq-active-options">';
q.options.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
optionsHtml += `<div class="lq-active-opt${opt.is_correct ? ' correct' : ''}">
<div class="lq-opt-letter">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
</div>`;
});
optionsHtml += '</div>';
}
card.innerHTML = `
<div class="lq-active-label">
<div class="dot" style="width:7px;height:7px;border-radius:50%;background:#FFB347;animation:pulse-dot 1.5s ease infinite"></div>
В эфире · <span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
</div>
<div class="lq-active-text">${esc(q.text)}</div>
${optionsHtml}
<div class="lq-counter">
<div>
<div class="lq-counter-val" id="lq-answer-count">${answerCount} / ${memberCount}</div>
<div class="lq-counter-label">студентов ответили</div>
</div>
<div class="lq-counter-bar-wrap">
<div class="lq-counter-bar" id="lq-answer-bar" style="width:${pct}%"></div>
</div>
</div>
<button class="btn-show-results" onclick="showResults()">
<i data-lucide="bar-chart-2" style="width:14px;height:14px"></i>
Показать результаты
</button>
<div id="results-area"></div>
`;
lucide.createIcons();
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── show results ── */
async function showResults() {
if (!activeSession || !currentQuestion) return;
const resultsArea = document.getElementById('results-area');
if (!resultsArea) return;
resultsArea.innerHTML = '<div class="spinner" style="margin:20px auto"></div>';
try {
const data = await LS.api('/api/live/' + activeSession.id + '/results');
renderResults(data, resultsArea);
} catch (e) {
resultsArea.innerHTML = `<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.84rem">${esc(e.message || 'Ошибка загрузки результатов')}</div>`;
}
}
function renderResults(data, container) {
const { answers = [], options = [] } = data;
// count answers per option
const countMap = {};
answers.forEach(a => {
const key = a.option_id ?? a.answer ?? 'other';
countMap[key] = (countMap[key] || 0) + 1;
});
const totalAnswers = answers.length;
const maxCount = Math.max(...Object.values(countMap), 1);
// use options from current question if not in results
const opts = options.length ? options : (currentQuestion?.options || []);
if (!opts.length) {
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
return;
}
let html = `<div class="lq-results-wrap">
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты · ${totalAnswers} ответов</div>
<div class="result-bars">`;
opts.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
const key = opt.id ?? idx;
const count = countMap[key] || 0;
const pct = Math.round((count / Math.max(maxCount, 1)) * 100);
const isCorrect = opt.is_correct;
html += `<div class="result-bar-row">
<div class="result-bar-label${isCorrect ? ' correct-lbl' : ''}">
${isCorrect ? '<span class="rb-correct-marker"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : letter + '.'}
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px">${esc(opt.text)}</span>
</div>
<div class="result-bar-track">
<div class="result-bar-fill${isCorrect ? ' correct-fill' : ''}" style="width:${pct}%"></div>
</div>
<div class="result-bar-count">${count}</div>
</div>`;
});
html += '</div></div>';
container.innerHTML = html;
lucide.createIcons();
}
lucide.createIcons();
loadClasses();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>