feat(dashboard): статус «идёт онлайн-урок» с присоединением

На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 14:24:14 +03:00
parent 0d4c658d93
commit 758e1bf6cb
2 changed files with 82 additions and 0 deletions
+62
View File
@@ -82,6 +82,32 @@
.ab-btn:hover { background: rgba(255,255,255,0.25); }
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
/* ── Live online-lesson banner ── */
.live-lesson {
display: flex; align-items: center; gap: 14px; text-decoration: none;
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
}
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
@keyframes llPulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
@media (max-width: 480px) {
.live-lesson { padding: 12px 14px; gap: 10px; }
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
}
.hero-card {
position: relative; border-radius: 18px; padding: 18px 20px;
display: flex; flex-direction: column; min-height: 196px;
@@ -1532,6 +1558,13 @@
<div class="container">
<!-- Live online-lesson status (student/teacher) -->
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
<span class="ll-dot"></span>
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
<span class="ll-cta" id="ll-cta">Присоединиться</span>
</a>
<!-- Gamification Bar (students only) -->
<div class="gam-bar" id="gam-bar" style="display:none">
<div class="gam-level">
@@ -4543,13 +4576,42 @@
loadDashboardStats();
applyDashboardPrefs();
}
loadLiveLesson();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
LS.notif.init();
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
async function loadLiveLesson() {
const el = document.getElementById('live-lesson-banner');
if (!el) return;
let data;
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
const s = data && data.session;
if (!s) { el.style.display = 'none'; return; }
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
let sub;
if (isTeacher) {
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
sub = online ? (online + ' онлайн') : 'ожидание учеников';
} else {
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
}
document.getElementById('ll-sub').textContent = sub;
document.getElementById('ll-cta').textContent = isTeacher
? 'Вернуться к доске'
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
el.style.display = '';
}
// Real-time SSE for page-specific events (notif handled by notifications.js)
LS.connectSSE(ev => {
if (ev.type === 'assignment') {
LS.toast(ev.message, 'info');
isTeacher ? loadAdminAssignments() : loadAssignments();
} else if (ev.type === 'classroom_live') {
loadLiveLesson();
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();