From 758e1bf6cba7f613f57d0aa1934c0539ee4f2461 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 23 Jun 2026 14:24:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=C2=AB=D0=B8=D0=B4=D1=91=D1=82=20=D0=BE=D0=BD?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BD-=D1=83=D1=80=D0=BE=D0=BA=C2=BB=20?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=B8=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На дашборде ученика/учителя — баннер активной 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) --- backend/src/controllers/classroom/sessions.js | 20 ++++++ frontend/dashboard.html | 62 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/backend/src/controllers/classroom/sessions.js b/backend/src/controllers/classroom/sessions.js index 160a6d2..61fdb82 100644 --- a/backend/src/controllers/classroom/sessions.js +++ b/backend/src/controllers/classroom/sessions.js @@ -41,6 +41,15 @@ function createSession(req, res) { 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 }); + // Баннер «идёт онлайн-урок» на дашбордах — через 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); } @@ -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_muted WHERE session_id=?').run(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 }); } diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 3a85a0a..99a6949 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -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 @@
+ + +