Files
Learn_System/frontend/live-quiz.html
T
Maxim Dolgolyov 29aa985504 feat: add sound system (LS.sfx) — synthesized Web Audio API sounds for classroom, gamification, quiz
- New js/sound.js: shared LS.sfx module with 21 synthesized sounds (ADSR envelope, sequences, sweeps, noise)
- Classroom: lesson_start/end, user_joined/left, hand_raise, chat_message, muted, draw_permitted
- Dashboard: achievement, level_up, xp_gain, coin via SSE events
- Live quiz: quiz_start, quiz_end on question launch and results
- Settings panel: global enable toggle + volume slider + localStorage persistence
- Replaces old _crBeep() in classroom.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:43:13 +03:00

943 lines
43 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>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<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; }
/* question filters */
.lq-filter-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.lq-filter-select {
flex: 1; padding: 8px 10px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 500;
color: #0F172A; background: #fff; outline: none; cursor: pointer;
transition: border-color 0.15s; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
.lq-filter-select:focus { border-color: var(--violet); }
.lq-q-count { font-size: 0.7rem; color: #8898AA; font-weight: 600; white-space: nowrap; margin-bottom: 12px; }
/* load more */
.btn-load-more {
width: 100%; padding: 10px; border: 1.5px dashed rgba(155,93,229,0.3);
border-radius: 12px; background: transparent; margin-bottom: 24px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
color: var(--violet); cursor: pointer; transition: all 0.15s;
}
.btn-load-more:hover { background: rgba(155,93,229,0.05); border-color: var(--violet); }
.btn-load-more:disabled { opacity: 0.5; cursor: default; }
/* result stat cards */
.lq-result-stats { display: flex; gap: 8px; margin-bottom: 14px; }
.lq-result-stat { flex: 1; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,0.04); text-align: center; }
.lq-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 900; color: #0F172A; }
.lq-result-stat-lbl { font-size: 0.64rem; color: #8898AA; margin-top: 2px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.lq-result-stat.rs-correct { background: rgba(6,214,160,0.08); }
.lq-result-stat.rs-correct .lq-result-stat-val { color: #059652; }
.lq-result-stat.rs-wrong { background: rgba(239,71,111,0.07); }
.lq-result-stat.rs-wrong .lq-result-stat-val { color: #EF476F; }
/* explanation box */
.lq-explanation {
margin-top: 14px; padding: 13px 15px;
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,0.2);
border-radius: 12px; font-size: 0.82rem; color: #0F172A; line-height: 1.6;
}
.lq-explanation-label { font-size: 0.64rem; font-weight: 700; color: #0891B2; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.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: flex-start; 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;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.5; min-height: 1.5em;
}
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 5px; 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: 12px; 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" id="app-sidebar"></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-filter-row">
<select class="lq-filter-select" id="topic-filter" onchange="onTopicFilter()">
<option value="">Все темы</option>
</select>
<select class="lq-filter-select" id="diff-filter" onchange="onDiffFilter()" style="max-width:130px">
<option value="">Любой уровень</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
</div>
<div class="lq-q-count" id="q-count" style="display:none"></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>
<button class="btn-load-more" id="btn-load-more" style="display:none" onclick="loadMoreQuestions()">
Загрузить ещё
</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.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: 'Порядок',
};
/* ── math rendering ── */
const MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── 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 searchTimeout = null;
let _topicFilter = '';
let _diffFilter = '';
let _qPage = 0;
let _totalQ = 0;
const Q_LIMIT = 30;
/* ── 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);
loadTopics();
lucide.createIcons();
}
async function loadTopics() {
try {
const topics = await LS.api('/api/topics');
const sel = document.getElementById('topic-filter');
sel.innerHTML = '<option value="">Все темы</option>';
(topics || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
sel.appendChild(opt);
});
} catch {}
}
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(reset = true) {
if (reset) { _qPage = 0; allQuestions = []; }
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
const params = new URLSearchParams({ limit: Q_LIMIT, offset: _qPage * Q_LIMIT });
if (_topicFilter) params.set('topic_id', _topicFilter);
if (_diffFilter) params.set('difficulty', _diffFilter);
const sq = document.getElementById('q-search')?.value.trim();
if (sq) params.set('search', sq);
try {
const data = await LS.api('/api/questions?' + params.toString());
const rows = data.rows || [];
_totalQ = data.total ?? (reset ? rows.length : allQuestions.length + rows.length);
allQuestions = reset ? rows : [...allQuestions, ...rows];
renderQuestionList();
const btnMore = document.getElementById('btn-load-more');
const countEl = document.getElementById('q-count');
if (btnMore) btnMore.style.display = allQuestions.length < _totalQ ? '' : 'none';
if (countEl) { countEl.textContent = `Показано ${allQuestions.length} из ${_totalQ}`; countEl.style.display = ''; }
} catch {
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
}
}
async function loadMoreQuestions() {
const btn = document.getElementById('btn-load-more');
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
_qPage++;
await loadQuestions(false);
if (btn) { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
}
function onTopicFilter() { _topicFilter = document.getElementById('topic-filter').value; loadQuestions(true); }
function onDiffFilter() { _diffFilter = document.getElementById('diff-filter').value; loadQuestions(true); }
function renderQuestionList() {
const list = document.getElementById('q-list');
if (!allQuestions.length) {
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
lucide.createIcons();
return;
}
let html = '';
allQuestions.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" data-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;
list.querySelectorAll('.lq-q-text[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
}
/* search questions — server-side */
document.getElementById('q-search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadQuestions(true), 350);
});
/* ── launch question ── */
async function launchQuestion(questionId) {
if (!activeSession) return;
try {
const resp = await LS.api('/api/live/' + activeSession.id + '/question', {
method: 'PUT',
body: JSON.stringify({ question_id: questionId }),
});
currentQuestion = resp.question || allQuestions.find(x => x.id === questionId) || { id: questionId };
answerCount = 0;
updateStudentCounter(0);
renderActiveQuestion(currentQuestion);
renderQuestionList();
if (window.LS && LS.sfx) LS.sfx.play('quiz_start');
} 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 data-text="${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" data-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>
`;
card.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
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);
if (window.LS && LS.sfx) LS.sfx.play('quiz_end');
} 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 opts = data.options || [];
const q = data.question || {};
const stats = data.stats || {};
const total = stats.total || 0;
const correct= stats.correct|| 0;
const maxCount = Math.max(...opts.map(o => o.chosen_count || 0), 1);
if (!opts.length) {
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
return;
}
const pctCorrect = total > 0 ? Math.round(correct / total * 100) : 0;
const pctWrong = total > 0 ? 100 - pctCorrect : 0;
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> Результаты</div>
<div class="lq-result-stats">
<div class="lq-result-stat">
<div class="lq-result-stat-val">${total}</div>
<div class="lq-result-stat-lbl">Ответов</div>
</div>
<div class="lq-result-stat rs-correct">
<div class="lq-result-stat-val">${pctCorrect}%</div>
<div class="lq-result-stat-lbl">Верно</div>
</div>
<div class="lq-result-stat rs-wrong">
<div class="lq-result-stat-val">${pctWrong}%</div>
<div class="lq-result-stat-lbl">Неверно</div>
</div>
</div>
<div class="result-bars">`;
opts.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
const count = opt.chosen_count || 0;
const pct = Math.round(count / maxCount * 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 class="rb-opt-text" data-text="${esc(opt.text)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px"></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>';
if (q.explanation) {
html += `<div class="lq-explanation">
<div class="lq-explanation-label">Объяснение</div>
<div class="lq-exp-text" data-text="${esc(q.explanation)}"></div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
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>