LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,853 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user