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>
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user