feat: universal sidebar via sidebar.js + stale ID cleanup

- Add js/sidebar.js: generates full sidebar HTML into #app-sidebar,
  handles role-based visibility, active link (with prefix matching),
  toggle wiring, collapsed state, board/features/notif init
- Replace <aside class="sidebar">...</aside> with <aside id="app-sidebar">
  across all 35 standard-layout pages via scripts/apply-sidebar.js
- Add notifications.js to 5 pages that were missing it
- Fix api.js initPage(): skip toggle re-wiring if data-sb-wired set,
  fix active link selector .sb-item → .sb-link
- Remove stale sbl-*/nav-admin/btn-upload-nav getElementById calls
  that crashed after sidebar replacement (lab, classes, collection,
  crossword, hangman, knowledge-map, library, pet, profile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 21:22:21 +03:00
parent fd29acbbdd
commit edb4c211a0
39 changed files with 380 additions and 1469 deletions
+83 -126
View File
@@ -2073,53 +2073,7 @@
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link nav-active"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
@@ -2984,6 +2938,8 @@
<script src="/js/whiteboard.js"></script>
<script src="/js/classroom-rtc.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
/* ── User prefs ─────────────────────────────────────────────────────────── */
const CR_PREFS_KEY = 'ls_cr_prefs';
@@ -3271,16 +3227,8 @@
_statusTimers.set(id, timer);
}
function startPolling() {
stopPolling();
_pollPart = setInterval(pollParticipants, 10_000); // participants backup (10s)
_pollChatTimer = setInterval(_pollChat, 15_000); // chat backup (15s)
}
function stopPolling() {
clearInterval(_pollPart); _pollPart = null;
clearInterval(_pollChatTimer); _pollChatTimer = null;
}
function startPolling() { /* backup polls removed — WS delivers all events with SSE fallback */ }
function stopPolling() { /* no-op */ }
async function _pollChat() {
if (!_sessionId) return;
@@ -3427,9 +3375,11 @@
_sessionId = data.session.id;
if (isTeacher) {
// Teacher: always reconnect — rejoin to refresh attendance
// Teacher: always reconnect — rejoin to refresh attendance, then fetch fresh data
await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => {});
enterActiveState(data.session);
const fresh = await LS.get(`/api/classroom/${_sessionId}`).catch(() => data.session);
_session = fresh;
enterActiveState(fresh);
loadChat();
return;
} else {
@@ -3474,13 +3424,18 @@
/* ── SSE handler ── */
/* eslint-disable eqeqeq */
function handleSSE(data) {
function handleSSE(data, fromWS = false) {
// When WS is active, classroom events are delivered via WS.
// Ignore classroom_ events that arrive via SSE to avoid duplicates.
// Events coming from WS (fromWS=true) are always processed.
if (!fromWS && _crWsReady && data.type?.startsWith('classroom_') && data.type !== '_sse_reconnect') return;
// Use == for sessionId comparison throughout — guards against string/number mismatch
// when _sessionId comes from JSON (number) vs SSE data (could be string in edge cases).
if (data.type === 'classroom_started') {
onClassroomStarted(data);
} else if (data.type === 'classroom_ended') {
if (_sessionId == data.sessionId) onClassroomEnded();
if (_sessionId == data.sessionId) onClassroomEnded(true); // teacher ended remotely → show toast
} else if (data.type === 'classroom_user_joined') {
if (_sessionId == data.sessionId) onUserJoined(data);
} else if (data.type === 'classroom_user_left') {
@@ -3707,7 +3662,8 @@
}
}
function onClassroomEnded() {
function onClassroomEnded(showToast = true) {
if (!_sessionId) return; // guard against double-call (direct + WS event)
stopPolling();
wbStopBatch();
_crWsClose();
@@ -3763,7 +3719,7 @@
// Close sim panel if open
if (_simActive) onSimClose();
updateParticipantsList();
LS.toast('Урок завершён', 'info');
if (showToast) LS.toast('Урок завершён', 'info');
}
function onUserJoined(data) {
@@ -4007,6 +3963,8 @@
document.getElementById('chat-no-session').style.display = active ? 'none' : 'flex';
document.getElementById('chat-active').style.display = active ? 'flex' : 'none';
document.getElementById('participants-no-session').style.display = active ? 'none' : 'flex';
// Hide the right panel entirely when no session is active (avoids empty strip on mobile)
document.getElementById('cr-right').style.display = active ? '' : 'none';
}
/* ── whiteboard ── */
@@ -4103,8 +4061,7 @@
// Read-only student: poll every 2s as a fallback in case SSE events are missed
wbStartPoll();
}
// Periodic draw permission check — catches missed SSE draw_permitted events
if (!_drawPermPollTimer) _drawPermPollTimer = setInterval(_checkDrawPermission, 5000);
// Draw permission delivered via WS — no periodic poll needed
}
}
@@ -4258,10 +4215,21 @@
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = LS.getToken() || '';
_crWs = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`);
_crWs.onopen = () => { _crWsReady = true; };
_crWs.onclose = () => { _crWsReady = false; };
_crWs.onopen = () => {
_crWsReady = true;
// Register in session so server delivers events via WS instead of SSE
if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId }));
};
_crWs.onclose = () => {
_crWsReady = false;
// Auto-reconnect after 2s (SSE stays active as fallback during gap)
if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000);
};
_crWs.onerror = () => { _crWsReady = false; };
// No incoming messages expected (server→client still uses SSE)
// All classroom server→client events arrive here (WS replaces SSE for classroom)
_crWs.onmessage = e => {
try { handleSSE(JSON.parse(e.data), true); } catch {}
};
}
function _crWsClose() {
@@ -5005,7 +4973,7 @@
if (!ok) return;
_wb.clearPage();
_wbBatch = [];
try { await LS.post(`/api/classroom/${_sessionId}/clear-page`, { page_num: _wbCurrentPage }); } catch {}
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum: _wbCurrentPage });
wbUpdateThumbnail(_wbCurrentPage);
}
@@ -5016,7 +4984,8 @@
if (!ok) return;
try {
await LS.del(`/api/classroom/${_sessionId}`);
onClassroomEnded();
onClassroomEnded(false);
LS.toast('Урок завершён', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
@@ -5024,7 +4993,11 @@
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/leave`);
onClassroomEnded();
onClassroomEnded(false);
LS.toast('Вы вышли из урока', 'info');
// If the session is still active, show the join banner so student can rejoin without refresh
const data = await LS.get('/api/classroom/my/session').catch(() => null);
if (data?.session) showJoinBanner(data.session);
} catch {}
}
@@ -5198,14 +5171,15 @@
const ids = Object.keys(_participants);
document.getElementById('participants-count').textContent = ids.length;
if (!_sessionId || !ids.length) {
// Always clear previous DOM entries first
list.querySelectorAll('.cr-participant').forEach(el => el.remove());
if (!_sessionId) {
noSession.style.display = 'flex';
return;
}
noSession.style.display = 'none';
const existing = list.querySelectorAll('.cr-participant');
existing.forEach(el => el.remove());
noSession.style.display = ids.length ? 'none' : 'flex';
if (!ids.length) return;
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
ids.forEach(uid => {
@@ -5271,22 +5245,14 @@
}
/* ── hand raise ── */
async function crToggleHand() {
function crToggleHand() {
if (!_sessionId) return;
_handRaised = !_handRaised;
const btn = document.getElementById('cr-hand-btn');
const lbl = document.getElementById('cr-hand-label');
btn.classList.toggle('raised', _handRaised);
lbl.textContent = _handRaised ? 'Опустить руку' : 'Поднять руку';
try {
if (_handRaised) await LS.post(`/api/classroom/${_sessionId}/hand`);
else await LS.del(`/api/classroom/${_sessionId}/hand`);
} catch {
// revert on error
_handRaised = !_handRaised;
btn.classList.toggle('raised', _handRaised);
lbl.textContent = _handRaised ? 'Опустить руку' : 'Поднять руку';
}
_crWsSend({ type: _handRaised ? 'hand_raise' : 'hand_lower', sessionId: _sessionId });
}
function onHandRaised(userId, userName) {
@@ -5429,10 +5395,10 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function wbSetPageTemplate(template) {
function wbSetPageTemplate(template) {
if (!_sessionId || !_wb) return;
_wb.setTemplate(template);
try { await LS.patch(`/api/classroom/${_sessionId}/page-template`, { template }); } catch {}
_crWsSend({ type: 'template_change', sessionId: _sessionId, pageNum: _wbCurrentPage, template });
wbUpdateThumbnail(_wbCurrentPage);
}
@@ -5498,10 +5464,10 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function wbPageClear(pageNum) {
function wbPageClear(pageNum) {
wbHidePageMenu();
if (!_sessionId) return;
try { await LS.post(`/api/classroom/${_sessionId}/clear-page`, { page_num: pageNum }); } catch {}
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum });
if (pageNum === _wbCurrentPage && _wb) { _wb.clearPage(); _wbMaxSeq = 0; _wbClearGen++; }
wbUpdateThumbnail(pageNum);
}
@@ -5543,15 +5509,15 @@
async function _wbChangePage(pageNum) {
if (!_sessionId) return;
_wbBatch = []; // discard unsent strokes from old page
try {
await LS.put(`/api/classroom/${_sessionId}/page`, { page_num: pageNum });
// SSE will echo back page_changed; teacher handles locally too
_wbCurrentPage = pageNum;
_wbMaxSeq = 0;
_wbClearGen++;
updatePageLabel();
if (_wb) {
_wb.clearPage();
// Send via WS (server updates DB + broadcasts to all)
_crWsSend({ type: 'page_change', sessionId: _sessionId, pageNum });
// Navigate locally without waiting for echo
_wbCurrentPage = pageNum;
_wbMaxSeq = 0; _wbClearGen++;
updatePageLabel();
if (_wb) {
_wb.clearPage();
try {
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${pageNum}`);
const strokes = res.strokes || [];
_wbUpdateMaxSeq(strokes);
@@ -5564,8 +5530,8 @@
if (res.name !== undefined) _pageNames[pageNum] = res.name || null;
_wbUpdateBgBtn();
wbRebuildThumbnails();
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
}
/* ── tabs ── */
@@ -6612,7 +6578,7 @@
if (_rtc.isSharing()) {
_rtc.stopScreenShare();
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
LS.del(`/api/classroom/${_sessionId}/screen`).catch(() => {});
_crWsSend({ type: 'screen_stop', sessionId: _sessionId });
} else {
crOpenScreenPicker();
}
@@ -6755,14 +6721,12 @@
};
}
document.getElementById('cr-screen-btn').classList.add('cr-btn-sharing');
LS.post(`/api/classroom/${_sessionId}/screen`).catch(() => {});
_crWsSend({ type: 'screen_start', sessionId: _sessionId });
}
async function onScreenShareStopped() {
function onScreenShareStopped() {
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
if (_sessionId) {
try { await LS.del(`/api/classroom/${_sessionId}/screen`); } catch {}
}
if (_sessionId) _crWsSend({ type: 'screen_stop', sessionId: _sessionId });
}
/* ── Simulation integration ──────────────────────────────────────────── */
@@ -6946,31 +6910,24 @@
}, 300);
});
async function crToggleDrawPermission(uid) {
function crToggleDrawPermission(uid) {
if (!_sessionId) return;
const numUid = Number(uid);
const hasPermit = _permittedStudents.has(numUid);
try {
if (hasPermit) {
await LS.del(`/api/classroom/${_sessionId}/allow-draw/${numUid}`);
_permittedStudents.delete(numUid);
} else {
await LS.post(`/api/classroom/${_sessionId}/allow-draw/${numUid}`);
_permittedStudents.add(numUid);
}
updateParticipantsList();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
if (hasPermit) {
_permittedStudents.delete(numUid);
_crWsSend({ type: 'revoke_draw', sessionId: _sessionId, targetUserId: numUid });
} else {
_permittedStudents.add(numUid);
_crWsSend({ type: 'allow_draw', sessionId: _sessionId, targetUserId: numUid });
}
updateParticipantsList();
}
async function crMutePeer(uid) {
function crMutePeer(uid) {
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/mute`, { user_id: uid });
if (_participants[uid]) {
_participants[uid].micMuted = true;
updateParticipantsList();
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
_crWsSend({ type: 'mute_peer', sessionId: _sessionId, targetUserId: Number(uid) });
if (_participants[uid]) { _participants[uid].micMuted = true; updateParticipantsList(); }
}
/* ── notifications ── */