fd29acbbdd
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>
436 lines
18 KiB
HTML
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>
|