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:
@@ -41,6 +41,15 @@ function createSession(req, res) {
|
|||||||
|
|
||||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||||
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
|
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
|
||||||
|
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
|
||||||
|
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
|
||||||
|
try {
|
||||||
|
const sse = require('../../sse');
|
||||||
|
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
|
||||||
|
if (class_id) sse.emitToClass(class_id, payload);
|
||||||
|
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
|
||||||
|
sse.emit(teacher.id, payload);
|
||||||
|
} catch { /* SSE недоступен — не критично */ }
|
||||||
res.json(session);
|
res.json(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +83,17 @@ function endSession(req, res) {
|
|||||||
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
||||||
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
|
||||||
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
||||||
|
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
|
||||||
|
try {
|
||||||
|
const sse = require('../../sse');
|
||||||
|
const payload = { type: 'classroom_live', state: 'ended', sessionId };
|
||||||
|
if (session.class_id) sse.emitToClass(session.class_id, payload);
|
||||||
|
else {
|
||||||
|
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||||
|
for (const r of invited) sse.emit(r.user_id, payload);
|
||||||
|
}
|
||||||
|
sse.emit(session.teacher_id, payload);
|
||||||
|
} catch { /* SSE недоступен — не критично */ }
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,32 @@
|
|||||||
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
||||||
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
||||||
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
.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 {
|
.hero-card {
|
||||||
position: relative; border-radius: 18px; padding: 18px 20px;
|
position: relative; border-radius: 18px; padding: 18px 20px;
|
||||||
display: flex; flex-direction: column; min-height: 196px;
|
display: flex; flex-direction: column; min-height: 196px;
|
||||||
@@ -1532,6 +1558,13 @@
|
|||||||
|
|
||||||
<div class="container">
|
<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) -->
|
<!-- Gamification Bar (students only) -->
|
||||||
<div class="gam-bar" id="gam-bar" style="display:none">
|
<div class="gam-bar" id="gam-bar" style="display:none">
|
||||||
<div class="gam-level">
|
<div class="gam-level">
|
||||||
@@ -4543,13 +4576,42 @@
|
|||||||
loadDashboardStats();
|
loadDashboardStats();
|
||||||
applyDashboardPrefs();
|
applyDashboardPrefs();
|
||||||
}
|
}
|
||||||
|
loadLiveLesson();
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
|
||||||
LS.notif.init();
|
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)
|
// Real-time SSE for page-specific events (notif handled by notifications.js)
|
||||||
LS.connectSSE(ev => {
|
LS.connectSSE(ev => {
|
||||||
if (ev.type === 'assignment') {
|
if (ev.type === 'assignment') {
|
||||||
LS.toast(ev.message, 'info');
|
LS.toast(ev.message, 'info');
|
||||||
isTeacher ? loadAdminAssignments() : loadAssignments();
|
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') {
|
} else if (ev.type === 'session') {
|
||||||
LS.toast(ev.message, 'info');
|
LS.toast(ev.message, 'info');
|
||||||
if (isTeacher) loadAdminSessions();
|
if (isTeacher) loadAdminSessions();
|
||||||
|
|||||||
Reference in New Issue
Block a user