Files
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

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

436 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Гостевой просмотр — 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="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0b0920;
--bg2: #12093a;
--violet: #9B5DE5;
--cyan: #06D6E0;
--text: #e8e0f7;
--muted: rgba(232,224,247,0.45);
--border: rgba(155,93,229,0.2);
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Manrope', sans-serif; overflow: hidden; }
/* ── Name entry screen ── */
#guest-entry {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(155,93,229,0.12) 0%, transparent 70%), var(--bg);
}
.ge-box {
width: 100%; max-width: 400px; padding: 40px 36px 36px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 32px 80px rgba(0,0,0,0.55);
margin: 16px;
}
.ge-logo {
display: flex; align-items: center; gap: 10px;
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
color: var(--text); margin-bottom: 28px;
}
.ge-logo svg { width: 28px; height: 28px; }
.ge-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 6px; }
.ge-sub { font-size: 0.78rem; color: var(--muted); margin-bottom: 26px; line-height: 1.6; }
.ge-lesson-name {
font-size: 0.82rem; font-weight: 700; color: var(--violet);
margin-bottom: 22px; padding: 8px 12px;
background: rgba(155,93,229,0.08); border-radius: 8px;
border: 1px solid rgba(155,93,229,0.18);
display: none;
}
.ge-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.ge-input {
width: 100%; padding: 12px 14px;
background: rgba(255,255,255,0.05); border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 12px; color: var(--text); font-family: 'Manrope', sans-serif;
font-size: 0.9rem; outline: none; transition: border-color 0.15s;
}
.ge-input:focus { border-color: var(--violet); }
.ge-input::placeholder { color: rgba(255,255,255,0.22); }
.ge-btn {
width: 100%; margin-top: 18px; padding: 13px;
background: linear-gradient(135deg, var(--violet), #5e2fb5);
border: none; border-radius: 12px; color: #fff;
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
cursor: pointer; transition: opacity 0.15s, transform 0.12s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.ge-btn:hover { opacity: 0.88; transform: translateY(-1px); }
.ge-btn:disabled { opacity: 0.4; cursor: default; transform: none; }
.ge-disclaimer {
margin-top: 16px; font-size: 0.68rem; color: rgba(255,255,255,0.2);
text-align: center; line-height: 1.6;
}
.ge-error { margin-top: 12px; font-size: 0.75rem; color: #FF6B6B; text-align: center; display: none; }
/* ── Board layout ── */
#guest-board { display: none; flex-direction: column; height: 100vh; }
/* Header */
.gb-header {
height: 46px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
background: rgba(10,7,30,0.92); border-bottom: 1px solid rgba(255,255,255,0.07);
backdrop-filter: blur(8px);
z-index: 10;
}
.gb-header-left { display: flex; align-items: center; gap: 10px; }
.gb-header-right { display: flex; align-items: center; gap: 10px; }
.gb-logo { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
.gb-title { font-size: 0.78rem; font-weight: 700; color: var(--text); max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gb-sep { width: 1px; height: 18px; background: rgba(255,255,255,0.1); }
.gb-badge {
display: flex; align-items: center; gap: 5px; padding: 3px 9px;
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.22);
border-radius: 99px; font-size: 0.63rem; font-weight: 700; color: var(--violet);
}
.gb-badge-dot { width: 5px; height: 5px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
/* Page nav */
.gb-page-nav { display: flex; align-items: center; gap: 8px; }
.gb-page-btn {
width: 28px; height: 28px; border: 1px solid rgba(255,255,255,0.12);
border-radius: 7px; background: rgba(255,255,255,0.04);
color: var(--muted); cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.gb-page-btn:hover:not(:disabled) { border-color: rgba(155,93,229,0.4); color: var(--text); background: rgba(155,93,229,0.08); }
.gb-page-btn:disabled { opacity: 0.3; cursor: default; }
.gb-page-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
/* Canvas area */
.gb-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background: #2d5a2d; /* chalkboard green — same as classroom */
}
#guest-canvas { display: block; width: 100%; height: 100%; }
/* Ended overlay */
#gb-ended {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(11,9,32,0.92); backdrop-filter: blur(8px);
flex-direction: column; align-items: center; justify-content: center;
gap: 14px; text-align: center;
}
#gb-ended svg { width: 48px; height: 48px; color: var(--violet); }
#gb-ended h2 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
#gb-ended p { font-size: 0.8rem; color: var(--muted); }
/* Status toast */
#gb-toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 8px 18px; border-radius: 99px;
background: rgba(20,15,50,0.95); border: 1px solid rgba(155,93,229,0.3);
font-size: 0.76rem; font-weight: 600; color: var(--text);
pointer-events: none; opacity: 0; transition: opacity 0.25s;
white-space: nowrap; z-index: 500;
}
#gb-toast.show { opacity: 1; }
@media (max-width: 480px) {
.gb-logo { display: none; }
.gb-title { max-width: 140px; font-size: 0.72rem; }
}
</style>
</head>
<body>
<!-- ── Name entry screen ─────────────────────────────── -->
<div id="guest-entry">
<div class="ge-box">
<div class="ge-logo">
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="#9B5DE5"/><path d="M8 22V12l8-4 8 4v10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M13 22v-5h6v5" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
LearnSpace
</div>
<div class="ge-title">Гостевой просмотр</div>
<div class="ge-sub">Вы смотрите доску в режиме чтения. Рисовать нельзя.</div>
<div class="ge-lesson-name" id="ge-lesson-name"></div>
<div class="ge-label">Ваше имя</div>
<input class="ge-input" id="ge-name-input" type="text" placeholder="Введите ваше имя…" maxlength="40"
onkeydown="if(event.key==='Enter') guestJoin()">
<button class="ge-btn" id="ge-join-btn" onclick="guestJoin()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Войти как гость
</button>
<div class="ge-error" id="ge-error"></div>
<div class="ge-disclaimer">Ваше имя будет видно учителю в списке участников</div>
</div>
</div>
<!-- ── Board ─────────────────────────────────────────── -->
<div id="guest-board">
<header class="gb-header">
<div class="gb-header-left">
<span class="gb-logo">LearnSpace</span>
<div class="gb-sep"></div>
<span class="gb-title" id="gb-title">Онлайн-урок</span>
<div class="gb-badge">
<span class="gb-badge-dot"></span>
Гостевой просмотр
</div>
</div>
<div class="gb-header-right">
<div class="gb-page-nav">
<button class="gb-page-btn" id="gb-prev" onclick="gbPrevPage()" disabled title="Предыдущая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span class="gb-page-label" id="gb-page-label">1 / 1</span>
<button class="gb-page-btn" id="gb-next" onclick="gbNextPage()" disabled title="Следующая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</header>
<div class="gb-canvas-wrap">
<canvas id="guest-canvas"></canvas>
</div>
</div>
<!-- ── Lesson ended overlay ─── -->
<div id="gb-ended">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>
<h2>Урок завершён</h2>
<p>Учитель завершил урок. Спасибо за участие!</p>
</div>
<div id="gb-toast"></div>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
<script src="/js/whiteboard.js"></script>
<script>
const _token = new URLSearchParams(location.search).get('token');
let _guestId = null;
let _sessionId = null;
let _wb = null;
let _curPage = 1;
let _totalPages = 1;
let _es = null;
let _wbMaxSeq = 0;
let _pollTimer = null;
/* ── toast ── */
let _toastTimer;
function showToast(msg, dur = 2800) {
const el = document.getElementById('gb-toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), dur);
}
/* ── pre-load session info ── */
async function init() {
if (!_token) { showError('Неверная ссылка'); return; }
try {
const r = await fetch(`/api/classroom/guest/${_token}`);
if (!r.ok) { showError('Ссылка недействительна или урок уже завершён'); return; }
const info = await r.json();
if (info.status !== 'active') {
showError('Урок ещё не начался. Попробуйте позже или обратитесь к учителю.');
return;
}
const nameEl = document.getElementById('ge-lesson-name');
nameEl.textContent = info.title || 'Онлайн-урок';
nameEl.style.display = 'block';
_totalPages = info.page_count || 1;
_curPage = info.current_page || 1;
} catch {
showError('Не удалось подключиться к серверу');
}
}
function showError(msg) {
const el = document.getElementById('ge-error');
el.textContent = msg;
el.style.display = 'block';
}
/* ── join ── */
async function guestJoin() {
const nameInput = document.getElementById('ge-name-input');
const btn = document.getElementById('ge-join-btn');
const name = nameInput.value.trim() || 'Гость';
btn.disabled = true;
try {
const r = await fetch(`/api/classroom/guest/${_token}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
showError(err.error || 'Не удалось войти');
btn.disabled = false;
return;
}
const data = await r.json();
_guestId = data.guestId;
_sessionId = data.sessionId;
_totalPages = data.page_count || 1;
_curPage = data.current_page || 1;
document.getElementById('gb-title').textContent = data.title || 'Онлайн-урок';
startBoard();
} catch {
showError('Ошибка соединения');
btn.disabled = false;
}
}
/* ── board startup ── */
function startBoard() {
document.getElementById('guest-entry').style.display = 'none';
const boardEl = document.getElementById('guest-board');
boardEl.style.display = 'flex';
const canvas = document.getElementById('guest-canvas');
_wb = new Whiteboard(canvas, { readOnly: true, bg: 'chalk' });
_wb.fit();
window.addEventListener('resize', () => _wb.fit());
loadPage(_curPage);
connectSSE();
// Send goodbye on tab close
window.addEventListener('pagehide', leaveGuest);
}
/* ── load strokes for a page ── */
async function loadPage(pageNum) {
_curPage = pageNum;
updatePageNav();
_wbMaxSeq = 0;
_wb.clearPage();
try {
const r = await fetch(`/api/classroom/guest/${_token}/strokes?page_num=${pageNum}`);
if (!r.ok) return;
const data = await r.json();
_wbMaxSeq = data.seq || 0;
_wb.loadStrokes(data.strokes || []);
if (data.template) _wb.setTemplate(data.template);
} catch {}
}
function updatePageNav() {
document.getElementById('gb-page-label').textContent = `${_curPage} / ${_totalPages}`;
document.getElementById('gb-prev').disabled = _curPage <= 1;
document.getElementById('gb-next').disabled = _curPage >= _totalPages;
}
function gbPrevPage() { if (_curPage > 1) loadPage(_curPage - 1); }
function gbNextPage() { if (_curPage < _totalPages) loadPage(_curPage + 1); }
/* ── SSE ── */
function connectSSE() {
const url = `/api/classroom/guest/${_token}/stream?guestId=${encodeURIComponent(_guestId || '')}`;
_es = new EventSource(url);
_es.onmessage = (e) => {
try { handleEvent(JSON.parse(e.data)); } catch {}
};
_es.onerror = () => {
// Auto-reconnects — just show brief toast
setTimeout(() => { if (_es.readyState === EventSource.CONNECTING) showToast('Переподключение…'); }, 3000);
};
}
function handleEvent(data) {
if (!data.type) return;
switch (data.type) {
case 'classroom_strokes':
if (data.pageNum == _curPage) {
_wbMaxSeq = Math.max(_wbMaxSeq, ...(data.strokes || []).map(s => s.seq || 0));
_wb.addStrokes(data.strokes || []);
}
break;
case 'classroom_stroke_preview':
if (data.pageNum == _curPage) {
if (data.cancel) _wb.removeLiveStroke(data.liveId);
else _wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, '#06D6E0');
}
break;
case 'classroom_stroke_deleted':
_wb.removeStroke(data.strokeId);
break;
case 'classroom_stroke_updated':
if (data.pageNum == _curPage)
_wb.updateStroke(data.strokeId, data.data);
break;
case 'classroom_page_added':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_changed':
// Follow teacher
if (data.pageNum !== _curPage) loadPage(data.pageNum);
break;
case 'classroom_template_changed':
if (data.pageNum == _curPage) _wb.setTemplate(data.template);
break;
case 'classroom_page_cleared':
if (data.pageNum == _curPage) { _wbMaxSeq = 0; _wb.clearPage(); }
break;
case 'classroom_page_renamed':
// Nothing visible to guest
break;
case 'classroom_page_duplicated':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_deleted':
_totalPages = Math.max(1, _totalPages - 1);
if (_curPage > _totalPages) loadPage(_totalPages);
else updatePageNav();
break;
case 'classroom_ended':
showEnded();
break;
}
}
function showEnded() {
if (_es) { _es.close(); _es = null; }
document.getElementById('gb-ended').style.display = 'flex';
}
function leaveGuest() {
if (!_guestId) return;
navigator.sendBeacon(`/api/classroom/guest/${_token}/leave`,
new Blob([JSON.stringify({ guestId: _guestId })], { type: 'application/json' }));
}
/* ── boot ── */
init();
</script>
</body>
</html>