const API = '/api'; /* ── токен ────────────────────────────────────────────────────────────── */ function getToken() { return localStorage.getItem('ls_token'); } function setToken(t) { localStorage.setItem('ls_token', t); } function removeToken() { localStorage.removeItem('ls_token'); } function getUser() { return JSON.parse(localStorage.getItem('ls_user') || 'null'); } function setUser(u) { localStorage.setItem('ls_user', JSON.stringify(u)); } function removeUser() { localStorage.removeItem('ls_user'); } function isLoggedIn() { return !!getToken(); } function logout() { removeToken(); removeUser(); window.location.href = '/login'; } /* ── базовый fetch ────────────────────────────────────────────────────── */ async function req(method, path, body) { const headers = { 'Content-Type': 'application/json' }; const token = getToken(); if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 401) { localStorage.removeItem('ls_token'); localStorage.removeItem('ls_user'); if (window.location.pathname !== '/login') { window.location.href = '/login'; return; } throw Object.assign(new Error(data.error || 'Unauthorized'), { status: 401, data }); } if (res.status === 403) { if (typeof LS !== 'undefined' && LS.toast) LS.toast('Нет доступа', 'error'); throw Object.assign(new Error(data.error || 'Forbidden'), { status: 403, data }); } throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); } return data; } /* ── auth ─────────────────────────────────────────────────────────────── */ async function register(email, password, name) { const data = await req('POST', '/auth/register', { email, password, name }); setToken(data.token); setUser(data.user); return data; } async function login(email, password) { const data = await req('POST', '/auth/login', { email, password }); setToken(data.token); setUser(data.user); return data; } async function fetchMe() { return req('GET', '/auth/me'); } async function updateProfile(data) { const result = await req('PATCH', '/auth/profile', data); if (result.token) setToken(result.token); if (result.user) setUser(result.user); return result; } /* ── subjects ─────────────────────────────────────────────────────────── */ async function getSubjects() { return req('GET', '/subjects'); } async function updateSubject(slug, data) { return req('PATCH', `/subjects/${slug}`, data); } async function getTopics(slug) { return req('GET', `/subjects/${slug}/topics`); } /* ── sessions ─────────────────────────────────────────────────────────── */ async function startSession(subject_slug, mode = 'exam', count = 25, topic_id = null, test_id = null) { return req('POST', '/sessions', { subject_slug, mode, count, topic_id, test_id }); } async function sendAnswer(session_id, question_id, option_id, time_spent_sec, answer_text, chosen_options) { // Retry up to 3x on network failures (loss of answer is unacceptable mid-test). // Server is idempotent: same (session, question) overwrites; safe to retry. // Don't retry on 4xx (validation/auth errors). const body = { question_id, option_id, time_spent_sec, answer_text, chosen_options }; let lastErr; for (let attempt = 0; attempt < 3; attempt++) { try { return await req('POST', `/sessions/${session_id}/answer`, body); } catch (e) { lastErr = e; const code = e?.status || 0; if (code >= 400 && code < 500) throw e; // permanent error — abort await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); // 300, 1200, 2700ms } } throw lastErr; } async function finishSession(session_id) { return req('POST', `/sessions/${session_id}/finish`); } async function getResult(session_id) { return req('GET', `/sessions/${session_id}/result`); } async function getHistory(page = 1, limit = 20) { return req('GET', `/sessions/history?page=${page}&limit=${limit}`); } async function getSessionQuestions(id) { return req('GET', `/sessions/${id}/questions`); } async function getWeakTopics() { return req('GET', '/sessions/weak-topics'); } async function getStudentStats() { return req('GET', '/sessions/stats'); } /* ── questions ────────────────────────────────────────────────────────── */ async function getQuestions(subject, topic_id, sort, page, limit) { const p = new URLSearchParams(); if (subject) p.set('subject', subject); if (topic_id) p.set('topic_id', topic_id); if (sort) p.set('sort', sort); if (page) p.set('page', page); if (limit) p.set('limit', limit); const data = await req('GET', `/questions?${p}`); // API returns { rows, total, page, limit } — extract rows for compat return Array.isArray(data) ? data : data.rows; } async function createQuestion(data) { return req('POST', '/questions', data); } async function duplicateQuestion(id) { return req('POST', `/questions/${id}/copy`, {}); } async function updateQuestion(id, data) { return req('PUT', `/questions/${id}`, data); } async function deleteQuestion(id) { return req('DELETE', `/questions/${id}`); } async function importQuestions(formData) { const token = getToken(); const headers = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(API + '/questions/import', { method: 'POST', headers, body: formData }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); return data; } /* ── admin ────────────────────────────────────────────────────────────── */ async function adminGetStats() { return req('GET', '/admin/stats'); } async function adminGetOverview() { return req('GET', '/admin/overview'); } async function adminGlobalSearch(q) { // Limits are hardcoded server-side (top 5 users / 3 tests / 3 classes). return req('GET', `/admin/search?q=${encodeURIComponent(q)}`); } async function adminGetUsers(params = {}) { const p = new URLSearchParams(); if (params.page) p.set('page', params.page); if (params.limit) p.set('limit', params.limit); if (params.role) p.set('role', params.role); if (params.q) p.set('q', params.q); // Returns { users, total, page, limit } (or { users, nextCursor, limit } if cursor used) return req('GET', `/admin/users?${p}`); } async function adminUpdateRole(id, role) { return req('PATCH', `/admin/users/${id}/role`, { role }); } async function adminGetUserSessions(id) { return req('GET', `/admin/users/${id}/sessions`); } async function adminGetSessions(params = {}) { const p = new URLSearchParams(); if (params.subject) p.set('subject', params.subject); if (params.user_id) p.set('user_id', params.user_id); if (params.limit) p.set('limit', params.limit); if (params.offset) p.set('offset', params.offset); return req('GET', `/admin/sessions?${p}`); } async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); } async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); } async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); } async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); } async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); } async function adminDeleteUser(id) { return req('DELETE', `/admin/users/${id}`); } /* ── classes (teacher/admin) ──────────────────────────────────────────── */ async function getClasses() { return req('GET', '/classes'); } async function createClass(data) { return req('POST', '/classes', data); } async function getClassDetail(id) { return req('GET', `/classes/${id}`); } async function updateClass(id, data) { return req('PATCH', `/classes/${id}`, data); } async function deleteClass(id) { return req('DELETE', `/classes/${id}`); } async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); } async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); } async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); } async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); } async function createDirectAssignment(data) { return req('POST', '/assignments', data); } async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); } async function deleteAssignment(id) { return req('DELETE', `/assignments/${id}`); } /* ── classes (student) ────────────────────────────────────────────────── */ async function joinClass(invite_code) { return req('POST', '/classes/join', { invite_code }); } async function myClasses() { return req('GET', '/classes/student/my'); } async function getStudents() { return req('GET', '/classes/students'); } async function classFeed(classId) { return req('GET', `/classes/${classId}/feed`); } /* ── assignments (student) ────────────────────────────────────────────── */ async function myAssignments() { return req('GET', '/assignments/my'); } async function teacherAssignments() { return req('GET', '/assignments/teacher'); } async function startAssignment(id) { return req('POST', `/assignments/${id}/start`); } async function assignmentResults(id) { return req('GET', `/assignments/${id}/results`); } async function assignmentSessionReview(assignment_id, session_id) { return req('GET', `/assignments/${assignment_id}/sessions/${session_id}/review`); } async function assignmentQuestionStats(id) { return req('GET', `/assignments/${id}/question-stats`); } /* ── tests ────────────────────────────────────────────────────────────── */ async function getTests(subject) { const p = subject ? `?subject=${subject}` : ''; return req('GET', `/tests${p}`); } async function createTest(data) { return req('POST', '/tests', data); } async function getTest(id) { return req('GET', `/tests/${id}`); } async function updateTest(id, data) { return req('PUT', `/tests/${id}`, data); } async function deleteTest(id) { return req('DELETE', `/tests/${id}`); } async function addQuestionsToTest(id, question_ids){ return req('POST', `/tests/${id}/questions`, { question_ids }); } async function removeQFromTest(tid, qid) { return req('DELETE', `/tests/${tid}/questions/${qid}`); } /* ── gamification ────────────────────────────────────────────────────── */ async function getGamificationMe() { return req('GET', '/gamification/me'); } async function getGamAchievements() { return req('GET', '/gamification/achievements'); } async function getLeaderboard(params = {}) { const p = new URLSearchParams(); if (params.class_id) p.set('class_id', params.class_id); if (params.period) p.set('period', params.period); return req('GET', `/gamification/leaderboard?${p}`); } async function getXPHistory(limit) { return req('GET', `/gamification/xp-history?limit=${limit || 20}`); } async function getChallenges() { return req('GET', '/gamification/challenges'); } async function claimChallenge(id) { return req('POST', `/gamification/challenges/${id}/claim`); } async function setGoalTier(tier) { return req('POST', '/gamification/goal-tier', { tier }); } async function getFrames() { return req('GET', '/gamification/frames'); } async function setFrame(frame) { return req('POST', '/gamification/frame', { frame }); } async function reportLabActivity(reactionsDiscovered) { return req('POST', '/gamification/lab-activity', { reactionsDiscovered: reactionsDiscovered || 0 }); } /* ── утилиты ──────────────────────────────────────────────────────────── */ function escapeHtml(str) { if (typeof str !== 'string') return str; return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } const esc = escapeHtml; function parseDate(dateStr) { if (!dateStr) return new Date(0); return new Date(dateStr.replace(' ', 'T') + (dateStr.includes('Z') || dateStr.includes('+') ? '' : 'Z')); } function fmtRelTime(dateStr) { const d = parseDate(dateStr); const m = Math.floor((Date.now() - d.getTime()) / 60000); if (m < 1) return 'только что'; if (m < 60) return `${m} мин назад`; const h = Math.floor(m / 60); if (h < 24) return `${h} ч назад`; return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' }); } function safeHref(link) { return link && /^\/[a-z]/.test(link) ? link : '#'; } function requireAuth() { if (!isLoggedIn()) { window.location.href = '/login'; return false; } return true; } /* ── SVG-иконки ──────────────────────────────────────────────────────── */ const _ICONS = { trophy: '', target: '', flame: '', zap: '', sparkles: '', diamond: '', 'book-open':'', running: '', 'check-circle':'', hundred: '100', school: '', 'file-text':'', books: '', star: '', crown: '', brain: '', party: '', 'thumbs-up':'', muscle: '', trash: '', 'help-circle':'', clipboard: '', lock: '', check: '', x: '', 'x-close': '', square: '', 'alert-tri':'', info: '', warning: '', 'check-sq': '', 'bar-chart':'', lightbulb: '', image: '', link: '', pin: '', video: '', explosion: '', clock: '', atom: '', droplet: '', compass: '', moon: '', 'grip-v': '', 'arrow-up': '', 'arrow-down':'', shuffle: '', coins: '', 'shopping-bag':'', gift: '', dna: '', }; function lsIcon(name, size = 18, cls = '') { const d = _ICONS[name]; if (!d) return ''; return `${d}`; } /* ── Toast-уведомления ────────────────────────────────────────────────── */ function lsToast(message, type = 'info', duration = 3500) { // нормализация типа: иначе неизвестный класс (напр. 'warning' вместо 'warn') // остаётся без фонового градиента → белый текст сливается со страницей const _tAlias = { warning: 'warn', danger: 'error', err: 'error', fail: 'error', ok: 'success' }; type = _tAlias[type] || type; if (!['success', 'error', 'info', 'warn'].includes(type)) type = 'info'; if (!document.getElementById('ls-toast-style')) { const s = document.createElement('style'); s.id = 'ls-toast-style'; s.textContent = ` #ls-toast-wrap{position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;} .ls-toast{display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:14px;min-width:220px;max-width:360px; font-family:'Manrope',sans-serif;font-size:0.875rem;font-weight:600;color:#fff;pointer-events:auto; box-shadow:0 8px 32px rgba(15,23,42,0.22);transform:translateX(120%);opacity:0; transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .22s ease;} .ls-toast.show{transform:translateX(0);opacity:1;} .ls-toast.hide{transform:translateX(120%);opacity:0;} .ls-toast.success{background:linear-gradient(135deg,#00C87A,#06B96E);} .ls-toast.error {background:linear-gradient(135deg,#F15BB5,#E0335E);} .ls-toast.info {background:linear-gradient(135deg,#06D6E0,#9B5DE5);} .ls-toast.warn {background:linear-gradient(135deg,#FF9F1C,#E07A00);} .ls-toast-icon{font-size:1.1rem;flex-shrink:0;} .ls-toast-msg{flex:1;line-height:1.4;} .ls-toast-close{background:none;border:none;color:rgba(255,255,255,0.7);font-size:1rem;cursor:pointer;padding:0 2px;flex-shrink:0;line-height:1;} .ls-toast-close:hover{color:#fff;} `; document.head.appendChild(s); } let wrap = document.getElementById('ls-toast-wrap'); if (!wrap) { wrap = document.createElement('div'); wrap.id = 'ls-toast-wrap'; wrap.setAttribute('aria-live', 'polite'); wrap.setAttribute('aria-atomic', 'false'); document.body.appendChild(wrap); } const _tIcons = { success: 'check-circle', error: 'x-close', info: 'info', warn: 'warning' }; const el = document.createElement('div'); el.className = `ls-toast ${type}`; el.setAttribute('role', type === 'error' ? 'alert' : 'status'); el.innerHTML = `${lsIcon(_tIcons[type] || 'info', 18)}`; el.querySelector('.ls-toast-msg').textContent = message; // Progress bar — thin line at bottom that drains over the toast duration const bar = document.createElement('span'); bar.className = 'ls-toast-bar'; bar.style.setProperty('--toast-dur', (duration / 1000).toFixed(2) + 's'); el.appendChild(bar); wrap.appendChild(el); requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))); const hide = () => { el.classList.add('hide'); setTimeout(() => el.remove(), 320); }; const timer = setTimeout(hide, duration); el.querySelector('.ls-toast-close').addEventListener('click', () => { clearTimeout(timer); hide(); }); } /* ── State helpers: единый паттерн loading/empty/error ─────────────────── Usage: LS.state.loading(el) // spinner LS.state.empty(el, msg, icon?) // "пусто" с иконкой LS.state.error(el, err, retry?) // ошибка + "Повторить" ──────────────────────────────────────────────────────────────────────── */ function _ensureStateStyles() { if (document.getElementById('ls-state-style')) return; const s = document.createElement('style'); s.id = 'ls-state-style'; s.textContent = ` .ls-state { padding: 40px 20px; text-align: center; color: #56687A; font-size: .9rem; } .ls-state-icon { width: 36px; height: 36px; margin: 0 auto 10px; opacity: .55; display: block; stroke: currentColor; fill: none; stroke-width: 1.6; } .ls-state-spin { width: 32px; height: 32px; border: 3px solid rgba(155,93,229,.15); border-top-color: #9B5DE5; border-radius: 50%; animation: ls-spin .8s linear infinite; margin: 8px auto; display: block; } @keyframes ls-spin { to { transform: rotate(360deg); } } .ls-state-title { font-family: 'Unbounded', sans-serif; font-weight: 700; color: #1F2937; margin-bottom: 4px; font-size: .92rem; } .ls-state-msg { line-height: 1.5; } .ls-state-btn { margin-top: 14px; padding: 8px 18px; border-radius: 999px; border: 1.5px solid #9B5DE5; background: transparent; color: #9B5DE5; font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700; cursor: pointer; transition: all .15s; } .ls-state-btn:hover { background: #9B5DE5; color: #fff; } `; document.head.appendChild(s); } const lsState = { loading(el, msg) { if (!el) return; _ensureStateStyles(); el.innerHTML = `
${msg ? `
${escapeHtml(msg)}
` : ''}
`; }, empty(el, msg = 'Пусто', icon = 'inbox') { if (!el) return; _ensureStateStyles(); el.innerHTML = `
${lsIcon(icon, 36) ? `
${lsIcon(icon, 36)}
` : ''}
${escapeHtml(msg)}
`; }, error(el, err, retryFn) { if (!el) return; _ensureStateStyles(); const msg = (err && err.message) || String(err || 'Ошибка'); const retryId = 'lse-retry-' + Math.random().toString(36).slice(2, 8); el.innerHTML = `
${lsIcon('alert-circle', 36)}
Не удалось загрузить
${escapeHtml(msg)}
${retryFn ? `` : ''}
`; if (retryFn) { const btn = el.querySelector('#' + retryId); if (btn) btn.addEventListener('click', () => { lsState.loading(el); retryFn(); }); } }, }; /* ── Skeleton-заглушки ────────────────────────────────────────────────── */ function lsSkeleton(count = 3, variant = 'card') { if (!document.getElementById('ls-skeleton-style')) { const s = document.createElement('style'); s.id = 'ls-skeleton-style'; s.textContent = ` @keyframes ls-shimmer{from{background-position:-600px 0}to{background-position:600px 0}} .ls-sk{ border-radius:12px; background:linear-gradient(90deg,rgba(155,93,229,0.06) 20%,rgba(155,93,229,0.15) 40%,rgba(255,255,255,0.65) 50%,rgba(155,93,229,0.15) 60%,rgba(155,93,229,0.06) 80%); background-size:1200px 100%; animation:ls-shimmer 1.8s infinite ease-in-out; } .ls-sk-card{display:flex;align-items:center;gap:18px;padding:18px 20px;border:1.5px solid rgba(155,93,229,0.08);border-radius:18px;margin-bottom:10px;background:rgba(255,255,255,0.7);} .ls-sk-icon{width:48px;height:48px;border-radius:12px;flex-shrink:0;} .ls-sk-body{flex:1;display:flex;flex-direction:column;gap:8px;} .ls-sk-title{height:14px;border-radius:6px;width:55%;} .ls-sk-meta{height:10px;border-radius:6px;width:80%;} .ls-sk-right{width:56px;display:flex;flex-direction:column;gap:8px;align-items:flex-end;} .ls-sk-pct{width:42px;height:22px;border-radius:8px;} .ls-sk-btn{width:80px;height:32px;border-radius:999px;} .ls-sk-row{height:48px;border-radius:12px;margin-bottom:10px;} `; document.head.appendChild(s); } if (variant === 'row') { return Array.from({ length: count }, () => `
` ).join(''); } return Array.from({ length: count }, () => `
`).join(''); } /* ── Красивый диалог подтверждения ───────────────────────────────────── */ function lsConfirm(message, { title = 'Подтверждение', confirmText = 'Подтвердить', danger = true } = {}) { return new Promise(resolve => { if (!document.getElementById('ls-confirm-style')) { const s = document.createElement('style'); s.id = 'ls-confirm-style'; s.textContent = ` .ls-ov{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center;padding:20px; background:rgba(15,23,42,0.45);backdrop-filter:blur(12px);opacity:0;transition:opacity .2s ease;} .ls-ov.open{opacity:1;} .ls-box{background:#fff;border-radius:24px;padding:36px 40px;width:100%;max-width:420px;text-align:center; box-shadow:0 40px 100px rgba(15,23,42,0.24);transform:scale(.9) translateY(12px);transition:transform .22s ease;} .ls-ov.open .ls-box{transform:scale(1) translateY(0);} .ls-icon{font-size:2.4rem;margin-bottom:12px;} .ls-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px;} .ls-msg{font-size:0.88rem;color:#3D4F6B;line-height:1.65;white-space:pre-line;margin-bottom:28px;} .ls-btns{display:flex;gap:10px;justify-content:center;} .ls-cancel{padding:10px 26px;min-height:44px;border:1.5px solid rgba(15,23,42,0.2);border-radius:999px;background:transparent; font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:600;color:#56687A;cursor:pointer;transition:all .2s;} .ls-cancel:hover{border-color:#9B5DE5;color:#9B5DE5;} .ls-ok{padding:10px 28px;min-height:44px;border:none;border-radius:999px;color:#fff; font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer;transition:opacity .2s; background:linear-gradient(135deg,#06D6E0,#9B5DE5);} .ls-ok.danger{background:linear-gradient(135deg,#F15BB5,#9B5DE5);} .ls-ok:hover{opacity:.88;} `; document.head.appendChild(s); } const prevFocus = document.activeElement; const el = document.createElement('div'); el.className = 'ls-ov'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-labelledby', 'ls-dlg-title'); el.innerHTML = `
${danger ? lsIcon('trash', 36) : lsIcon('help-circle', 36)}
`; el.querySelector('.ls-title').textContent = title; el.querySelector('.ls-msg').textContent = message; el.querySelector('.ls-ok').textContent = confirmText; document.body.appendChild(el); requestAnimationFrame(() => el.classList.add('open')); const done = result => { el.classList.remove('open'); setTimeout(() => { el.remove(); prevFocus?.focus(); }, 230); resolve(result); }; el.querySelector('.ls-cancel').onclick = () => done(false); el.querySelector('.ls-ok').onclick = () => done(true); el.addEventListener('click', e => { if (e.target === el) done(false); }); el.addEventListener('keydown', e => { if (e.key === 'Tab') { const btns = [...el.querySelectorAll('button')]; if (e.shiftKey && document.activeElement === btns[0]) { e.preventDefault(); btns[btns.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === btns[btns.length - 1]) { e.preventDefault(); btns[0].focus(); } } if (e.key === 'Enter') { e.preventDefault(); done(true); } if (e.key === 'Escape') { e.preventDefault(); done(false); } }); setTimeout(() => el.querySelector('.ls-cancel').focus(), 10); }); } /* ──────────────────────────────────────────────────────────────────────── LS.modal — universal form/content modal Companion to LS.confirm. Use for forms, pickers, editors — anything that's not a simple yes/no confirmation. Usage: const m = LS.modal({ title: 'Назначить чтение', content: htmlString | DOMElement, size: 'sm' | 'md' | 'lg', // 420 / 560 / 720 px actions: [ { label: 'Отмена', onClick: () => m.close() }, { label: 'Назначить', primary: true, onClick: async () => { ... } }, ], onClose: () => { ... }, }); // Returns { close, root, setBody, setActions, setError } ──────────────────────────────────────────────────────────────────────── */ function lsModal({ title = '', content = '', size = 'md', actions = [], onClose, dismissible = true } = {}) { if (!document.getElementById('ls-modal-style')) { const s = document.createElement('style'); s.id = 'ls-modal-style'; s.textContent = ` .ls-mov{position:fixed;inset:0;z-index:9000;display:flex;align-items:flex-start;justify-content:center; padding:60px 20px 20px;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px); opacity:0;transition:opacity .18s ease;overflow-y:auto;} .ls-mov.open{opacity:1;} .ls-mod{background:#fff;border-radius:18px;width:100%; box-shadow:0 24px 80px rgba(15,23,42,0.28); transform:scale(.96) translateY(-12px);transition:transform .22s ease; display:flex;flex-direction:column;max-height:calc(100vh - 80px);} .ls-mod.sm{max-width:420px;} .ls-mod.md{max-width:560px;} .ls-mod.lg{max-width:720px;} .ls-mov.open .ls-mod{transform:scale(1) translateY(0);} .ls-mod-hdr{display:flex;align-items:center;justify-content:space-between; padding:18px 22px 14px;border-bottom:1px solid rgba(15,23,42,.08);flex-shrink:0;} .ls-mod-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;} .ls-mod-x{width:32px;height:32px;border:none;background:transparent;color:#56687A; cursor:pointer;border-radius:8px;display:flex;align-items:center;justify-content:center; transition:background .12s;flex-shrink:0;margin-left:12px;} .ls-mod-x:hover{background:rgba(15,23,42,.06);color:#0F172A;} .ls-mod-x svg{width:18px;height:18px;} .ls-mod-body{padding:18px 22px;overflow-y:auto;flex:1;} .ls-mod-err{margin:0 22px 14px;padding:9px 12px;border-radius:8px;font-size:.84rem; background:rgba(241,91,68,.1);border:1px solid rgba(241,91,68,.3);color:#F94144;display:none;} .ls-mod-err.visible{display:block;} .ls-mod-act{display:flex;gap:10px;justify-content:flex-end; padding:14px 22px 18px;border-top:1px solid rgba(15,23,42,.08);flex-shrink:0;} .ls-mod-btn{padding:9px 18px;border-radius:10px;border:1.5px solid rgba(15,23,42,0.18); background:transparent;color:#0F172A;font-family:'Manrope',sans-serif; font-size:.88rem;font-weight:700;cursor:pointer;transition:all .15s;} .ls-mod-btn:hover{border-color:#9B5DE5;color:#9B5DE5;} .ls-mod-btn.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;} .ls-mod-btn.primary:hover{background:#7e3eca;border-color:#7e3eca;color:#fff;} .ls-mod-btn.danger{background:#F94144;border-color:#F94144;color:#fff;} .ls-mod-btn.danger:hover{background:#d62a2d;border-color:#d62a2d;} .ls-mod-btn:disabled{opacity:.55;cursor:not-allowed;} @media (max-width:540px){.ls-mov{padding:20px 12px;}.ls-mod-hdr,.ls-mod-body,.ls-mod-act{padding-left:16px;padding-right:16px;}} `; document.head.appendChild(s); } const prevFocus = document.activeElement; const ov = document.createElement('div'); ov.className = 'ls-mov'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.innerHTML = `
`; ov.querySelector('.ls-mod-title').textContent = title; const bodyEl = ov.querySelector('.ls-mod-body'); const errEl = ov.querySelector('.ls-mod-err'); const actEl = ov.querySelector('.ls-mod-act'); function setBody(c) { bodyEl.innerHTML = ''; if (typeof c === 'string') bodyEl.innerHTML = c; else if (c instanceof Node) bodyEl.appendChild(c); } function setError(msg) { if (!msg) { errEl.classList.remove('visible'); return; } errEl.textContent = msg; errEl.classList.add('visible'); } function setActions(arr) { actEl.innerHTML = ''; (arr || []).forEach((a, i) => { const b = document.createElement('button'); b.className = 'ls-mod-btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : ''); b.textContent = a.label || (a.primary ? 'OK' : 'Отмена'); if (a.id) b.id = a.id; b.addEventListener('click', e => { e.preventDefault(); if (typeof a.onClick === 'function') a.onClick(); else if (a.close !== false) close(); }); actEl.appendChild(b); }); actEl.style.display = (arr && arr.length) ? '' : 'none'; } setBody(content); setActions(actions); document.body.appendChild(ov); requestAnimationFrame(() => ov.classList.add('open')); function close() { ov.classList.remove('open'); setTimeout(() => { ov.remove(); prevFocus?.focus?.(); if (typeof onClose === 'function') onClose(); }, 230); } ov.querySelector('.ls-mod-x').onclick = () => { if (dismissible) close(); }; if (dismissible) { ov.addEventListener('click', e => { if (e.target === ov) close(); }); } const onKey = e => { if (e.key === 'Escape' && dismissible) { e.preventDefault(); close(); } }; document.addEventListener('keydown', onKey); const _close = close; close = () => { document.removeEventListener('keydown', onKey); _close(); }; // Focus first focusable element inside body, or close button setTimeout(() => { const focusable = bodyEl.querySelector('input,select,textarea,button') || ov.querySelector('.ls-mod-x'); focusable?.focus?.(); }, 50); return { close, root: ov, body: bodyEl, setBody, setActions, setError }; } /* ── renderNavAvatar — paint the sidebar avatar (image or initials) ── Exported via LS.refreshNavAvatar so pages that update avatar_url (profile preset picker, upload flow) can re-paint without reload. Stale-cache recovery: if the cached user has no `avatar_url` field (e.g. logged in before login was returning it), fetch /auth/me once in the background and re-paint when it returns. */ let _navAvatarFetchInflight = false; function renderNavAvatar(el, user) { if (!el) return; const u = user || getUser(); const url = u?.avatar_url; if (url) { el.innerHTML = ``; el.style.background = 'transparent'; return; } // No url yet — paint initials, then opportunistically refresh from /auth/me // if the field is absent (not just empty). `null` means "verified absent", // `undefined` means "we never fetched and might be stale". const initials = (u?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS'; el.textContent = initials; el.style.background = ''; if (u && u.avatar_url === undefined && !_navAvatarFetchInflight && isLoggedIn()) { _navAvatarFetchInflight = true; fetchMe().then(fresh => { if (!fresh) return; const merged = { ...getUser(), ...fresh, avatar_url: fresh.avatar_url ?? null }; setUser(merged); const newEl = document.getElementById('nav-avatar'); if (newEl) renderNavAvatar(newEl, merged); }).catch(() => {}).finally(() => { _navAvatarFetchInflight = false; }); } } function refreshNavAvatar() { renderNavAvatar(document.getElementById('nav-avatar')); } /* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */ function applyRoleSidebar(user) { if (!user) return; if (['teacher', 'admin'].includes(user.role)) { document.querySelectorAll('.sb-teacher-only').forEach(el => el.style.display = ''); } } /* ── initPage — consolidates page init boilerplate ─────────────────── */ function initPage({ requireLogin = true } = {}) { if (requireLogin && !requireAuth()) return null; const user = getUser(); const isTeacher = user && ['teacher', 'admin'].includes(user.role); const isAdmin = user?.role === 'admin'; // Nav avatar — render uploaded avatar if available, otherwise initials. const navUser = document.getElementById('nav-user'); const navAvatar = document.getElementById('nav-avatar'); if (navUser) navUser.textContent = user?.name || user?.email || ''; if (navAvatar) renderNavAvatar(navAvatar, user); // Sidebar collapsed state if (localStorage.getItem('ls_sb_collapsed') === '1') { document.querySelector('.app-layout')?.classList.add('sb-collapsed'); } // Sidebar toggle wiring (skip if sidebar.js already wired it via data-sb-wired) const togBtn = document.querySelector('.sb-toggle'); if (togBtn && !togBtn.dataset.sbWired) { togBtn.addEventListener('click', () => { const layout = document.querySelector('.app-layout'); const collapsed = layout.classList.toggle('sb-collapsed'); localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '0'); }); } // Sidebar active link (fallback for pages without sidebar.js) const currentPath = location.pathname.replace(/\.html$/, '').replace(/^\//, '') || 'dashboard'; document.querySelectorAll('.sidebar .sb-link').forEach(a => { const href = a.getAttribute('href')?.replace(/^\//, '').replace(/\.html$/, '') || ''; if (href && href === currentPath) a.classList.add('active'); }); // Cosmetics applyCosmetics(); // Hide disabled game features in sidebar hideDisabledFeatures(); // Board link — показываем всем залогиненным пользователям const boardEl = document.getElementById('btn-board') || document.getElementById('sbl-board'); if (boardEl) boardEl.style.display = ''; // Teacher-only sidebar items applyRoleSidebar(user); return { user, isTeacher, isAdmin }; } /* ── Feature flags (cached per page load, bust on demand) ───────────── */ let _featuresCache = null; /* Synchronous mirror of features.gamification, populated on first loadFeatures() resolve. Used by xp.js to bail out without network. */ let _gamificationEnabled = null; function isGamificationEnabled() { // Default to true when the cache hasn't resolved yet — page-load order // means some code runs before /api/features. The CSS kill-switch // (body.no-gamification) catches the visual side regardless. return _gamificationEnabled !== false; } async function loadFeatures() { if (_featuresCache) return _featuresCache; try { _featuresCache = await apiFetch('/api/features'); } catch { _featuresCache = {}; } _gamificationEnabled = _featuresCache.gamification !== false; return _featuresCache; } function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; } /** * Show board sidebar link only for teachers/admins and students in a class. * Call after LS.initPage(). Uses features cache (_no_class flag). */ async function showBoardIfAllowed() { const el = document.getElementById('btn-board') || document.getElementById('sbl-board'); if (!el) return; const user = getUser(); if (!user) return; if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; } // Student: check if in a class const feats = await loadFeatures(); if (!feats._no_class) el.style.display = ''; } async function hideDisabledFeatures() { const feats = await loadFeatures(); const map = { hangman: ['/hangman'], crossword: ['/crossword'], pet: ['/pet'], red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'], collection: ['/collection.html', '/collection'], lab: ['/lab'], knowledge_map: ['/knowledge-map'], flashcards: ['/flashcards'], board: ['/board'], biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], live_quiz: ['/live-quiz'], classroom: ['/classroom'], exam9: ['/exam9', '/exam9.html'], textbooks: ['/textbooks', '/textbooks.html', '/textbook'], }; for (const [key, hrefs] of Object.entries(map)) { if (feats[key] === false) { hrefs.forEach(href => { document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none'); }); // Redirect away if currently on a disabled page const cur = window.location.pathname; if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) { window.location.href = '/dashboard.html'; } } } if (feats.gamification === false) { document.body.classList.add('no-gamification'); // If student is already viewing achievements or shop tab, redirect to account tab const active = document.querySelector('#tab-achievements.active, #tab-shop.active'); if (active) { document.querySelector('[onclick*="tab-account"]')?.click(); } } // Student with no class — restrict to dashboard, homework, library, theory if (feats._no_class) { const classOnlyHrefs = [ '/board', '/lab', '/hangman', '/crossword', '/pet', '/collection', '/collection.html', '/knowledge-map', '/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html', '/flashcards', '/live-quiz', ]; classOnlyHrefs.forEach(href => { document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none'); }); // Redirect if currently on a class-required page const cur = window.location.pathname; const classOnlyPaths = [ '/board', '/lab', '/hangman', '/crossword', '/pet', '/collection', '/collection-rb', '/knowledge-map', '/red-book', '/red-book-ecosystem', '/red-book-biomes', '/red-book-games', '/flashcards', '/live-quiz', ]; if (classOnlyPaths.some(h => cur === h || cur === h + '.html')) { window.location.href = '/dashboard'; } document.body.classList.add('no-class'); document.body.classList.add('no-gamification'); // no class no gamification } } /* ── generic authenticated fetch (full path like /api/courses) ─────── */ async function apiFetch(path, options = {}) { const token = getToken(); const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; // Auto-stringify plain object bodies so callers can pass `{ body: { ... } }` // like LS.post does. Strings / FormData / Blob / URLSearchParams pass through. const opts = { ...options, headers }; if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData) && !(opts.body instanceof Blob) && !(opts.body instanceof URLSearchParams) && !(opts.body instanceof ArrayBuffer)) { opts.body = JSON.stringify(opts.body); } const res = await fetch(path, opts); if (res.status === 401) { removeToken(); removeUser(); window.location.href = '/login'; throw new Error('Session expired'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); return data; } /* ── Biochemistry API ────────────────────────────────────────────────── */ async function biochemGetElements() { return req('GET', '/biochem/elements'); } async function biochemGetMolecules(p={}) { return req('GET', `/biochem/molecules?${new URLSearchParams(p)}`); } async function biochemGetMolecule(id) { return req('GET', `/biochem/molecules/${id}`); } async function biochemValidate(atoms,bonds){ return req('POST','/biochem/validate',{atoms,bonds}); } async function biochemAnalyze(atoms,bonds){ return req('POST','/biochem/analyze',{atoms,bonds}); } async function biochemGetReactions() { return req('GET', '/biochem/reactions'); } async function biochemGetChallenges() { return req('GET', '/biochem/challenges'); } async function biochemSolveChallenge(id,payload) { return req('POST',`/biochem/challenges/${id}/solve`,payload); } async function biochemGetSaved() { return req('GET', '/biochem/saved'); } async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); } async function biochemDeleteSaved(id) { return req('DELETE',`/biochem/saved/${id}`); } async function biochemGetPathways() { return req('GET', '/biochem/pathways'); } async function biochemGetPathwayProgress() { return req('GET', '/biochem/pathways/progress'); } async function biochemSavePathwayProgress(pathway,step,completed){ return req('POST','/biochem/pathways/progress',{pathway,step,completed}); } /* ── LS.prefs — server-synced user preferences ────────────────────────── Keys use dot-notation: 'wb.color', 'dashboard.hidden', etc. Writes are debounced (1.5 s) before flushing to /api/preferences. ─────────────────────────────────────────────────────────────────────── */ const _prefsCache = {}; let _prefsDirty = false; let _prefsTimer = null; // SYNC DISABLED (debug mode) — раскомментировать для включения синхронизации async function _prefsLoad() { /* disabled */ } function _prefsFlush() { /* disabled */ } // async function _prefsLoad() { // if (!isLoggedIn()) return; // try { // const data = await apiFetch('/api/preferences', { method: 'GET' }); // Object.assign(_prefsCache, data); // } catch (e) {} // } // // function _prefsFlush() { // if (!_prefsDirty) return; // _prefsDirty = false; // if (!isLoggedIn()) return; // apiFetch('/api/preferences', { // method: 'PATCH', // body: JSON.stringify(_prefsCache), // }).catch(() => {}); // } const lsPrefs = { get(key, def) { const parts = key.split('.'); let cur = _prefsCache; for (const k of parts) { if (cur === undefined || cur === null) return def; cur = cur[k]; } return cur !== undefined ? cur : def; }, set(key, value) { const parts = key.split('.'); let cur = _prefsCache; for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object' || Array.isArray(cur[parts[i]])) { cur[parts[i]] = {}; } cur = cur[parts[i]]; } cur[parts[parts.length - 1]] = value; _prefsDirty = true; clearTimeout(_prefsTimer); _prefsTimer = setTimeout(_prefsFlush, 1500); }, async init() { await _prefsLoad(); }, flush() { clearTimeout(_prefsTimer); _prefsFlush(); }, }; window.LS = { getToken, setToken, removeToken, getUser, setUser, removeUser, isLoggedIn, logout, requireAuth, register, login, fetchMe, updateProfile, getSubjects, updateSubject, getTopics, startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions, adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, regenerateInviteCode, classJournal, joinClass, myClasses, getStudents, classFeed, getAnnouncements, createAnnouncement, deleteAnnouncement, getNotifications, markNotifRead, markAllNotifsRead, connectSSE, listTemplates, saveTemplate, deleteTemplate, bulkCreateAssignment, myAssignments, teacherAssignments, startAssignment, assignmentResults, assignmentSessionReview, assignmentQuestionStats, getTests, createTest, getTest, updateTest, deleteTest, addQuestionsToTest, removeQFromTest, getGamificationMe, getGamAchievements, getLeaderboard, getXPHistory, getChallenges, claimChallenge, setGoalTier, getFrames, setFrame, reportLabActivity, getFiles, uploadFile, uploadMaterialFile, downloadFileUrl, deleteFile, getFileAccess, assignFile, unassignFile, getFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, getPermissions, permissionsLog, setClassPermission, permissionsPresets, applyClassPreset, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, listRoles, createRole, updateRoleDef, deleteRole, rolePermissions, accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, globalSearch, getShopItems, purchaseItem, getUserPurchases, getCoins, getMyActiveCosmetics, activateShopItem, adminShopGetItems, adminShopCreateItem, adminShopUpdateItem, adminShopDeleteItem, adminShopAwardCoins, adminShopStats, adminGamAward, adminGamReset, adminGamStats, adminGamGetUser, parentGetLinks, parentCreateLink, parentUpdateLink, parentDeleteLink, crCreateSession, crGetSession, crEndSession, crGetActiveByClass, crGetMyActive, crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, initPage, applyRoleSidebar, icon: lsIcon, confirm: lsConfirm, modal: lsModal, toast: lsToast, state: lsState, skeleton: lsSkeleton, api: apiFetch, get: (path) => apiFetch(path, { method: 'GET' }), post: (path, body) => apiFetch(path, { method: 'POST', body: JSON.stringify(body) }), put: (path, body) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }), del: (path) => apiFetch(path, { method: 'DELETE' }), patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), applyCosmetics: applyCosmetics, refreshNavAvatar, renderNavAvatar, isGamificationEnabled, loadFeatures, clearFeaturesCache, hideDisabledFeatures, showBoardIfAllowed, biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze, biochemGetReactions, biochemGetChallenges, biochemSolveChallenge, biochemGetSaved, biochemSave, biochemDeleteSaved, biochemGetPathways, biochemGetPathwayProgress, biochemSavePathwayProgress, prefs: lsPrefs, }; /* ═══════════════════════════════════════════════════════════════════════ Global Cosmetics Applier — call on any page after auth ═══════════════════════════════════════════════════════════════════════ */ async function applyCosmetics() { if (!isLoggedIn()) return; // Skip the round-trip entirely when gamification is off. Server will // 404 anyway, but a synchronous bail-out keeps the network quiet. if (!isGamificationEnabled()) return; try { const c = await getMyActiveCosmetics(); if (!c) return; // ── Background: paint a fixed div behind the whole UI ── // The element is created on demand and reused on subsequent calls // so swapping backgrounds doesn't flash. A second fixed div sits // ON TOP of it (#ls-bg-veil, z-index:-1 vs bg-fx -2) acting as a // translucent veil so vibrant animations don't drown out the UI. // The tone attr on lets the veil darken for dark presets. const DARK_BG_SLUGS = new Set([ 'dark', 'stars', 'aurora', 'nebula', 'grid', // Phase migration-036 additions — all dark except 'clouds'. 'sunset', 'rain', 'snow', 'fireflies', 'cyber-grid', 'kaleidoscope', 'ocean', 'aurora-dance', ]); if (c.background && c.background.slug && c.background.slug !== 'none') { const slug = String(c.background.slug).replace(/[^a-z0-9_-]/gi, ''); let bgEl = document.getElementById('ls-bg-fx'); if (!bgEl) { bgEl = document.createElement('div'); bgEl.id = 'ls-bg-fx'; document.body.insertBefore(bgEl, document.body.firstChild); } bgEl.className = 'bg-' + slug; let veilEl = document.getElementById('ls-bg-veil'); if (!veilEl) { veilEl = document.createElement('div'); veilEl.id = 'ls-bg-veil'; // Insert just after bg-fx so it paints above (same z-index gap). bgEl.parentNode.insertBefore(veilEl, bgEl.nextSibling); } document.body.dataset.bgTone = DARK_BG_SLUGS.has(slug) ? 'dark' : 'light'; } else { // Active bg was cleared — remove both layers. document.getElementById('ls-bg-fx')?.remove(); document.getElementById('ls-bg-veil')?.remove(); delete document.body.dataset.bgTone; } // ── Frame: apply CSS to sidebar avatar on every page ── if (c.frame && c.frame.css) { const navAv = document.getElementById('nav-avatar'); if (navAv) { // Strip any previously applied frame styles so swaps don't stack. if (navAv.dataset.frameApplied) { navAv.style.cssText = navAv.dataset.frameOrig || ''; } else { navAv.dataset.frameOrig = navAv.style.cssText || ''; } navAv.style.cssText += ';' + c.frame.css; navAv.dataset.frameApplied = '1'; } } // ── Title: show under nav username ── if (c.title && c.title.text) { const nameEl = document.getElementById('nav-user'); if (nameEl && !document.getElementById('nav-title-badge')) { const badge = document.createElement('div'); badge.id = 'nav-title-badge'; badge.textContent = c.title.text; badge.style.cssText = `font-size:0.55rem;font-weight:700;color:${c.title.color || '#9B5DE5'};margin-top:-2px;letter-spacing:0.5px;text-transform:uppercase;`; nameEl.after(badge); } } // ── Effect: particle animation on avatar ── if (c.effect && c.effect.effect) { _applyEffect(c.effect.effect); } } catch {} } function _applyEffect(name) { const avatar = document.getElementById('nav-avatar'); if (!avatar) return; avatar.style.position = 'relative'; if (name === 'pulse') { if (!document.getElementById('ls-pulse-style')) { const s = document.createElement('style'); s.id = 'ls-pulse-style'; s.textContent = `@keyframes ls-pulse{0%,100%{box-shadow:0 0 0 0 rgba(155,93,229,0.5)}50%{box-shadow:0 0 0 8px rgba(155,93,229,0)}} .ls-effect-pulse{animation:ls-pulse 2s infinite}`; document.head.appendChild(s); } avatar.classList.add('ls-effect-pulse'); } if (name === 'sparkle') { if (!document.getElementById('ls-sparkle-style')) { const s = document.createElement('style'); s.id = 'ls-sparkle-style'; s.textContent = ` .ls-sparkle-wrap{position:relative;display:inline-flex} .ls-sparkle{position:absolute;width:4px;height:4px;border-radius:50%;background:#FFD166;pointer-events:none;animation:ls-sparkle-fly 1.4s ease-out infinite} @keyframes ls-sparkle-fly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--sx),var(--sy)) scale(0)}}`; document.head.appendChild(s); } const wrap = document.createElement('span'); wrap.className = 'ls-sparkle-wrap'; avatar.parentNode.insertBefore(wrap, avatar); wrap.appendChild(avatar); for (let i = 0; i < 5; i++) { const sp = document.createElement('span'); sp.className = 'ls-sparkle'; const angle = (i / 5) * Math.PI * 2; sp.style.cssText = `--sx:${Math.cos(angle) * 16}px;--sy:${Math.sin(angle) * 16}px;animation-delay:${i * 0.28}s;top:50%;left:50%;margin:-2px`; wrap.appendChild(sp); } } if (name === 'snow') { if (!document.getElementById('ls-snow-style')) { const s = document.createElement('style'); s.id = 'ls-snow-style'; s.textContent = ` .ls-snow-wrap{position:relative;display:inline-flex;overflow:visible} .ls-snowflake{position:absolute;width:3px;height:3px;border-radius:50%;background:#fff;pointer-events:none;opacity:0.7;animation:ls-snow-fall 2s linear infinite} @keyframes ls-snow-fall{0%{opacity:0.8;transform:translateY(-10px) translateX(0)}50%{transform:translateY(6px) translateX(4px)}100%{opacity:0;transform:translateY(18px) translateX(-2px)}}`; document.head.appendChild(s); } const wrap = document.createElement('span'); wrap.className = 'ls-snow-wrap'; avatar.parentNode.insertBefore(wrap, avatar); wrap.appendChild(avatar); for (let i = 0; i < 6; i++) { const fl = document.createElement('span'); fl.className = 'ls-snowflake'; fl.style.cssText = `left:${4 + i * 4}px;top:-4px;animation-delay:${i * 0.33}s`; wrap.appendChild(fl); } } } /* ── files (library) ──────────────────────────────────────────────────── */ async function getFiles(params = {}) { const p = new URLSearchParams(); if (params.subject) p.set('subject', params.subject); if (params.my) p.set('my', '1'); return req('GET', `/files?${p}`); } async function uploadFile(formData) { const token = getToken(); const res = await fetch(API + '/files', { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: formData, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); return data; } /* Personal image upload for «Мои материалы» — any logged-in user (students too). * Separate from uploadFile() which is the teacher library (role + permission). */ async function uploadMaterialFile(formData) { const token = getToken(); const res = await fetch(API + '/files/personal', { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: formData, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); return data; } function downloadFileUrl(id) { return `${API}/files/${id}/download`; } async function listMaterials() { return req('GET', '/materials'); } async function saveMaterial(data) { return req('POST', '/materials', data); } async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); } async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } async function shareMaterial(id, d) { return req('POST', `/materials/${id}/share`, d); } async function getActivity() { return req('GET', '/dashboard/activity'); } async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); } async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); } async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); } async function assistantContext() { return req('GET', '/assistant/context'); } async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); } async function assistantAsk(q, context, history) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined }); } async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); } async function adminGetAssistant() { return req('GET', '/admin/assistant'); } async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); } async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); } async function adminReindexTextbooks() { return req('POST', '/admin/assistant/reindex', {}); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); } async function deleteFile(id) { return req('DELETE', `/files/${id}`); } async function getFileAccess(id) { return req('GET', `/files/${id}/access`); } async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); } async function unassignFile(id, type, targetId) { return req('DELETE', `/files/${id}/assign/${encodeURIComponent(type)}/${targetId}`); } async function getFolders() { return req('GET', '/files/folders'); } async function createFolder(name) { return req('POST', '/files/folders', { name }); } async function renameFolder(id, name) { return req('PUT', `/files/folders/${id}`, { name }); } async function deleteFolder(id) { return req('DELETE', `/files/folders/${id}`); } async function moveFile(id, folder_id) { return req('PATCH', `/files/${id}/move`, { folder_id }); } async function getFolderAccess(id) { return req('GET', `/files/folders/${id}/access`); } async function clearFolderAccess(id) { return req('DELETE', `/files/folders/${id}/access`); } async function assignFolder(id, data) { return req('POST', `/files/folders/${id}/assign`, data); } async function unassignFolder(id, type, targetId) { return req('DELETE', `/files/folders/${id}/assign/${encodeURIComponent(type)}/${targetId}`); } async function getStudentsList() { const d = await req('GET', '/admin/users?role=student&limit=500'); return d.users || []; } /* ── class members (admin/teacher) ──────────────────────────────────────── */ async function addClassMember(classId, email, user_id) { return req('POST', `/classes/${classId}/members`, user_id ? { user_id } : { email }); } /* ── announcements ───────────────────────────────────────────────────────── */ async function getAnnouncements(classId) { return req('GET', `/classes/${classId}/announcements`); } async function createAnnouncement(classId, text){ return req('POST', `/classes/${classId}/announcements`, { text }); } async function deleteAnnouncement(classId, id) { return req('DELETE', `/classes/${classId}/announcements/${id}`); } /* ── submissions ─────────────────────────────────────────────────────────── */ async function submitWork(formData) { const token = getToken(); const res = await fetch(API + '/submissions', { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: formData, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); return data; } async function resubmitWork(id, formData) { const token = getToken(); const res = await fetch(API + `/submissions/${id}/resubmit`, { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: formData, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); return data; } async function getMySubmissions() { return req('GET', '/submissions/my'); } async function getClassSubmissions(classId) { return req('GET', `/submissions?class_id=${classId}`); } async function reviewSubmission(id, data) { return req('PATCH', `/submissions/${id}`, data); } async function deleteSubmission(id) { return req('DELETE',`/submissions/${id}`); } function submissionDownloadUrl(id) { return `${API}/submissions/${id}/download`; } /* ── permissions (admin only) ────────────────────────────────────────────── */ async function getPermissions() { return req('GET', '/permissions'); } async function permissionsLog(userId) { return req('GET', userId ? `/permissions/log?user_id=${userId}` : '/permissions/log'); } async function setClassPermission(classId, permission, enabled) { return req('POST', `/permissions/class/${classId}/bulk`, { permission, enabled }); } async function permissionsPresets() { return req('GET', '/permissions/presets'); } async function listRoles() { return req('GET', '/roles'); } async function createRole(name, label, baseRoles) { return req('POST', '/roles', { name, label, baseRoles }); } async function updateRoleDef(name, body) { return req('PUT', `/roles/${encodeURIComponent(name)}`, body); } async function deleteRole(name) { return req('DELETE', `/roles/${encodeURIComponent(name)}`); } async function rolePermissions(name) { return req('GET', `/roles/${encodeURIComponent(name)}/permissions`); } async function applyClassPreset(classId, preset) { return req('POST', `/permissions/class/${classId}/preset`, { preset }); } async function setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); } async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); } async function setUserPermission(uid, permission, enabled, days) { return req('POST', `/permissions/users/${uid}`, days ? { permission, enabled, days } : { permission, enabled }); } async function resetUserPermissions(uid, permission) { return req('DELETE', `/permissions/users/${uid}/reset`, permission ? { permission } : undefined); } /* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */ async function accessCatalog() { return req('GET', '/access/catalog'); } async function accessTargets() { return req('GET', '/access/targets'); } async function accessSummary() { return req('GET', '/access/summary'); } async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); } async function accessMatrix() { return req('GET', '/access/matrix'); } async function accessLog(content_type, content_ref) { const p = new URLSearchParams({ content_type, content_ref }); return req('GET', `/access/log?${p}`); } async function accessRules(content_type, content_ref) { const p = new URLSearchParams({ content_type, content_ref }); return req('GET', `/access/rules?${p}`); } async function accessSetRule(content_type, content_ref, scope, target_id, allow) { return req('POST', '/access/rules', { content_type, content_ref, scope, target_id, allow }); } /* ── notifications ───────────────────────────────────────────────────────── */ async function getNotifications() { return req('GET', '/notifications'); } async function markNotifRead(id) { return req('PATCH',`/notifications/${id}/read`); } async function markAllNotifsRead() { return req('POST', '/notifications/read-all'); } /* ── SSE real-time notifications ─────────────────────────────────────────── */ /* ── Shared SSE singleton — all listeners share one EventSource ─────── */ let _sseShared = null; let _sseRetryMs = 2000; let _sseEverConnected = false; // tracks whether SSE has successfully opened before const _sseListeners = new Set(); function _sseConnect() { const token = getToken(); if (!token) return; const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`; const es = new EventSource(url); _sseShared = es; es.onopen = () => { const isReconnect = _sseEverConnected; _sseEverConnected = true; _sseRetryMs = 2000; // Notify listeners of reconnect so they can re-sync missed state if (isReconnect) { for (const fn of _sseListeners) try { fn({ type: '_sse_reconnect' }); } catch {} } }; es.onmessage = e => { let data; try { data = JSON.parse(e.data); } catch { return; } for (const fn of _sseListeners) try { fn(data); } catch {} }; es.onerror = () => { es.close(); _sseShared = null; const delay = Math.min(_sseRetryMs, 30000); _sseRetryMs = Math.min(_sseRetryMs * 2, 30000); setTimeout(_sseConnect, delay); }; } function connectSSE(onEvent) { if (!getToken()) return null; _sseListeners.add(onEvent); if (!_sseShared) _sseConnect(); return { close: () => _sseListeners.delete(onEvent) }; } // Proactively close SSE when navigating away to prevent HTTP/1.1 // connection pool exhaustion during View Transition (old page stays alive ~260ms) window.addEventListener('pagehide', () => { if (_sseShared) { _sseShared.close(); _sseShared = null; } _sseListeners.clear(); }); /* ── assignment templates ─────────────────────────────────────────────────── */ async function listTemplates() { return req('GET', '/assignments/templates'); } async function saveTemplate(data) { return req('POST', '/assignments/templates', data); } async function deleteTemplate(id) { return req('DELETE', `/assignments/templates/${id}`); } async function bulkCreateAssignment(data){ return req('POST', '/assignments/bulk', data); } /* ── templates (course & lesson) ───────────────────────────────────────── */ async function getCourseTemplates(params = {}) { const p = new URLSearchParams(); if (params.subject) p.set('subject', params.subject); if (params.my) p.set('my', '1'); return req('GET', `/templates/courses?${p}`); } async function saveCourseTemplate(data) { return req('POST', '/templates/courses', data); } async function createFromCourseTemplate(id, d) { return req('POST', `/templates/courses/${id}/create`, d); } async function deleteCourseTemplate(id) { return req('DELETE', `/templates/courses/${id}`); } async function getLessonTemplates(params = {}) { const p = new URLSearchParams(); if (params.category) p.set('category', params.category); if (params.my) p.set('my', '1'); return req('GET', `/templates/lessons?${p}`); } async function saveLessonTemplate(data) { return req('POST', '/templates/lessons', data); } async function createFromLessonTemplate(id, d) { return req('POST', `/templates/lessons/${id}/create`, d); } async function deleteLessonTemplate(id) { return req('DELETE', `/templates/lessons/${id}`); } /* ── shop ──────────────────────────────────────────────────────────────── */ async function getShopItems() { return req('GET', '/shop/items'); } async function purchaseItem(itemId) { return req('POST', `/shop/items/${itemId}/purchase`); } async function getUserPurchases() { return req('GET', '/shop/purchases'); } async function getCoins() { return req('GET', '/shop/coins'); } async function getMyActiveCosmetics() { return req('GET', '/shop/my-active'); } async function activateShopItem(itemId, type) { return req('POST', '/shop/activate', { itemId, type }); } /* ── shop admin ────────────────────────────────────────────────────────── */ async function adminShopGetItems() { return req('GET', '/shop/admin/items'); } async function adminShopCreateItem(data) { return req('POST', '/shop/admin/items', data); } async function adminShopUpdateItem(id, data) { return req('PUT', `/shop/admin/items/${id}`, data); } async function adminShopDeleteItem(id) { return req('DELETE', `/shop/admin/items/${id}`); } async function adminShopAwardCoins(data) { return req('POST', '/shop/admin/award-coins', data); } async function adminShopStats() { return req('GET', '/shop/admin/stats'); } /* ── bookmarks ────────────────────────────────────────────────────────── */ async function getBookmarks(type) { const p = type ? `?type=${type}` : ''; return req('GET', `/bookmarks${p}`); } async function addBookmark(entityType, entityId) { return req('POST', '/bookmarks', { entityType, entityId }); } async function removeBookmark(id) { return req('DELETE', `/bookmarks/${id}`); } async function removeBookmarkByEntity(type, entityId) { return req('DELETE', `/bookmarks/entity/${type}/${entityId}`); } async function checkBookmark(type, entityId) { return req('GET', `/bookmarks/check/${type}/${entityId}`); } /* ── search ───────────────────────────────────────────────────────────── */ async function globalSearch(q, type, limit) { const p = new URLSearchParams({ q }); if (type) p.set('type', type); if (limit) p.set('limit', limit); return req('GET', `/search?${p}`); } /* ── parent link management (student side) ─────────────────────────────── */ async function parentGetLinks() { return req('GET', '/parent/my-links'); } async function parentCreateLink(label) { return req('POST', '/parent/links', { label }); } async function parentUpdateLink(id, d) { return req('PATCH', `/parent/links/${id}`, d); } async function parentDeleteLink(id) { return req('DELETE', `/parent/links/${id}`); } /* ── classroom (online lesson) ─────────────────────────────────────────── */ async function crCreateSession(data) { return req('POST', '/classroom', data); } async function crGetSession(id) { return req('GET', `/classroom/${id}`); } async function crEndSession(id) { return req('DELETE', `/classroom/${id}`); } async function crGetActiveByClass(classId) { return req('GET', `/classroom/class/${classId}/active`); } async function crGetMyActive() { return req('GET', '/classroom/my/active'); } async function crGetMySession() { return req('GET', '/classroom/my/session'); } async function crJoin(id) { return req('POST', `/classroom/${id}/join`); } async function crLeave(id) { return req('POST', `/classroom/${id}/leave`); } async function crSendChat(id, message) { return req('POST', `/classroom/${id}/chat`, { message }); } async function crGetChat(id) { return req('GET', `/classroom/${id}/chat`); } async function crGetAttendance(id) { return req('GET', `/classroom/${id}/attendance`); } async function crSignal(id, targetUserId, payload) { return req('POST', `/classroom/${id}/signal`, { target_user_id: targetUserId, payload }); } async function crGetOnlineStudents() { return req('GET', '/classroom/online-students'); } async function crGetMyHistory(page = 1) { return req('GET', `/classroom/my/history?page=${page}`); } async function crGetClassHistory(classId, page = 1, search = '') { const q = search ? `&search=${encodeURIComponent(search)}` : ''; return req('GET', `/classroom/class/${classId}/history?page=${page}${q}`); } async function crGetSessionSummary(id) { return req('GET', `/classroom/${id}/summary`); } async function crExportChatUrl(id) { return `/api/classroom/${id}/chat/export`; } async function crGetAllNotes(id) { return req('GET', `/classroom/${id}/notes/all`); } async function crDeleteHistory(id) { return req('DELETE', `/classroom/${id}/history`); } async function crAdminGetAllHistory(p = {}) { const q = new URLSearchParams(); if (p.page) q.set('page', p.page); if (p.limit) q.set('limit', p.limit); if (p.search) q.set('search', p.search); if (p.teacher) q.set('teacher', p.teacher); if (p.class_id) q.set('class_id', p.class_id); if (p.date_from) q.set('date_from', p.date_from); if (p.date_to) q.set('date_to', p.date_to); if (p.sort) q.set('sort', p.sort); return req('GET', `/classroom/admin/sessions?${q}`); } async function crAdminGetTeachersList() { return req('GET', '/classroom/admin/teachers-list'); } /* ── gamification admin ────────────────────────────────────────────────── */ async function adminGamAward(data) { return req('POST', '/gamification/admin/award', data); } async function adminGamReset(data) { return req('POST', '/gamification/admin/reset', data); } async function adminGamStats() { return req('GET', '/gamification/admin/stats'); } async function adminGamGetUser(id) { return req('GET', `/gamification/admin/user/${id}`); } /* ── Live Quiz Student Overlay ──────────────────────────────────────────── */ (function initLiveOverlay() { const token = getToken(); if (!token) return; let payload; try { payload = JSON.parse(atob(token.split('.')[1])); } catch { return; } if (!payload || payload.role === 'teacher' || payload.role === 'admin') return; const STYLE = ` #ls-live-overlay{position:fixed;inset:0;z-index:9000;background:rgba(10,2,32,0.92); backdrop-filter:blur(12px);display:none;align-items:center;justify-content:center;padding:20px;} #ls-live-overlay.open{display:flex;} .ls-live-box{background:#fff;border-radius:24px;padding:32px 28px;width:100%;max-width:520px; box-shadow:0 40px 120px rgba(0,0,0,0.5);position:relative;} .ls-live-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:99px; background:rgba(155,93,229,0.1);color:#9B5DE5;font-size:0.72rem;font-weight:800; text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;} .ls-live-badge::before{content:'';width:7px;height:7px;border-radius:50%; background:#9B5DE5;animation:ls-live-pulse 1s ease infinite;} @keyframes ls-live-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(1.3)}} .ls-live-q{font-size:1rem;line-height:1.65;color:#0F172A;margin-bottom:20px;font-weight:500;} .ls-live-opts{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;} .ls-live-opt{display:flex;align-items:center;gap:12px;padding:12px 16px;border:1.5px solid #E2E8F0; border-radius:14px;cursor:pointer;transition:all .15s;font-size:.9rem;} .ls-live-opt:hover{border-color:#9B5DE5;background:rgba(155,93,229,.04);} .ls-live-opt.selected{border-color:#06D6E0;background:rgba(6,214,224,.07);} .ls-live-opt.correct{border-color:#06D6A0!important;background:rgba(6,214,160,.1)!important;color:#059652;} .ls-live-opt.wrong{border-color:#EF476F!important;background:rgba(239,71,111,.06)!important;color:#EF476F;} .ls-live-opt-key{width:28px;height:28px;border-radius:8px;background:#F1F5F9; display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700; color:#64748B;flex-shrink:0;transition:.15s;} .ls-live-opt.selected .ls-live-opt-key{background:#06D6E0;color:#fff;} .ls-live-opt.correct .ls-live-opt-key{background:#06D6A0;color:#fff;} .ls-live-opt.wrong .ls-live-opt-key{background:#EF476F;color:#fff;} .ls-live-status{text-align:center;font-size:.84rem;color:var(--text-3);padding:8px 0;} .ls-live-result-bar-wrap{margin:4px 0;} .ls-live-result-bar{height:8px;border-radius:99px;background:#E2E8F0;margin-top:4px;overflow:hidden;} .ls-live-result-fill{height:100%;border-radius:99px;background:#9B5DE5;transition:width .6s ease;} .ls-live-result-fill.correct-fill{background:#06D6A0;} .ls-live-result-pct{font-size:.72rem;font-weight:700;color:#64748B;float:right;} `; const el = document.createElement('div'); el.id = 'ls-live-overlay'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-labelledby', 'lslq-text'); el.innerHTML = `
`; const styleEl = document.createElement('style'); styleEl.textContent = STYLE; document.addEventListener('DOMContentLoaded', () => { if (window._lsLiveOverriddenByClassroom) return; document.head.appendChild(styleEl); document.body.appendChild(el); }); let currentLiveId = null; let answered = false; /* render text that may contain \(...\) or \[...\] LaTeX using window.katex */ function _mathHtml(text) { if (!text) return ''; const kat = window.katex; if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; } let out = '', i = 0; while (i < text.length) { const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i); let next = -1, close = '', disp = false; if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; } else if (bi >= 0) { next = bi; close = '\\]'; disp = true; } const plain = document.createElement('span'); if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; } plain.textContent = text.slice(i, next); out += plain.innerHTML; const ci = text.indexOf(close, next + 2); if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; } try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); } catch { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; } i = ci + close.length; } return out; } function openOverlay(liveId, question, options) { currentLiveId = liveId; answered = false; document.getElementById('lslq-text').innerHTML = _mathHtml(question.text); const keys = 'АБВГДЕ'; document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => ` `).join(''); document.getElementById('lslq-status').textContent = 'Выберите ответ'; document.getElementById('ls-live-overlay').classList.add('open'); } function showResults(options, stats) { const total = stats?.total || 1; document.getElementById('lslq-status').textContent = `Ответили: ${stats?.total || 0} · Правильно: ${stats?.correct || 0}`; document.querySelectorAll('.ls-live-opt').forEach(el => { el.onclick = null; el.style.cursor = 'default'; }); const keys = 'АБВГДЕ'; document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => { const pct = total ? Math.round((o.chosen_count || 0) * 100 / total) : 0; const cls = o.is_correct ? 'correct' : ''; return `
${keys[i]||i+1} ${_mathHtml(o.text)} ${pct}%
`; }).join(''); } window._lsLiveAnswer = async function(liveId, optionId, el) { if (answered) return; answered = true; document.querySelectorAll('.ls-live-opt').forEach(o => { o.onclick = null; o.onkeydown = null; o.style.cursor = 'default'; o.setAttribute('aria-checked', 'false'); }); el.classList.add('selected'); el.setAttribute('aria-checked', 'true'); document.getElementById('lslq-status').innerHTML = 'Ответ отправлен '; try { const r = await apiFetch(`/api/live/${liveId}/answer`, { method: 'POST', body: JSON.stringify({ option_id: optionId }) }); if (r.is_correct === 1) el.classList.replace('selected','correct'); else if (r.is_correct === 0) el.classList.replace('selected','wrong'); } catch {} }; document.addEventListener('DOMContentLoaded', () => { if (window._lsLiveOverriddenByClassroom) return; connectSSE(d => { if (d.type === 'live_question') { openOverlay(d.liveId, d.question, d.question?.options); } else if (d.type === 'live_results') { showResults(d.options, d.stats); } else if (d.type === 'live_ended') { setTimeout(() => document.getElementById('ls-live-overlay')?.classList.remove('open'), 2000); document.getElementById('lslq-status').textContent = 'Сессия завершена'; } }); }); })(); /* ── Онлайн-урок: липкий верхний баннер + индикатор «В эфире» в сайдбаре ── */ /* Пока идёт урок — заметный баннер сверху на ЛЮБОЙ странице + пульс пункта */ /* «Онлайн-урок» в сайдбаре. Зайти можно одним кликом откуда угодно. */ (function initClassroomNotify() { const token = getToken(); if (!token) return; let payload; try { payload = JSON.parse(atob(token.split('.')[1])); } catch { return; } // Только ученики (учителя сами начинают урок и обычно уже в нём) if (!payload || payload.role === 'teacher' || payload.role === 'admin') return; const onClassroomPage = () => window.location.pathname.replace(/\.html$/, '') === '/classroom'; const STYLE = ` #ls-cr-banner{position:fixed;top:14px;left:50%;z-index:8500;display:none;align-items:center;gap:14px; transform:translateX(-50%) translateY(-130%); padding:9px 10px 9px 18px;border-radius:999px;max-width:calc(100vw - 24px); background:linear-gradient(135deg,#1c1233,#2c1656);border:1.5px solid rgba(155,93,229,.55); box-shadow:0 16px 50px rgba(124,58,205,.45); transition:transform .4s cubic-bezier(.34,1.4,.64,1),opacity .25s;opacity:0;} #ls-cr-banner.open{display:flex;transform:translateX(-50%) translateY(0);opacity:1;} .ls-crb-dot{width:9px;height:9px;border-radius:50%;background:#F15BB5;flex-shrink:0; animation:ls-crb-pulse 1.3s ease infinite;} @keyframes ls-crb-pulse{0%{box-shadow:0 0 0 0 rgba(241,91,181,.55)}70%{box-shadow:0 0 0 9px rgba(241,91,181,0)}100%{box-shadow:0 0 0 0 rgba(241,91,181,0)}} .ls-crb-txt{color:#F3EEFF;font-size:.86rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-family:'Manrope',sans-serif;} .ls-crb-txt b{font-weight:800;color:#fff;} .ls-crb-join{flex-shrink:0;display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:999px; border:none;background:linear-gradient(135deg,#06D6E0,#9B5DE5);color:#fff;font-weight:800;font-size:.82rem; font-family:'Manrope',sans-serif;text-decoration:none;cursor:pointer;transition:opacity .15s,transform .15s;} .ls-crb-join:hover{opacity:.92;transform:translateY(-1px);} .ls-crb-x{flex-shrink:0;background:none;border:none;color:#8b7da8;cursor:pointer;padding:4px 8px; font-size:1.15rem;line-height:1;border-radius:8px;transition:color .15s;} .ls-crb-x:hover{color:#fff;} @media (max-width:768px){#ls-cr-banner{top:64px;left:12px;right:12px;transform:translateY(-130%);} #ls-cr-banner.open{transform:translateY(0);}.ls-crb-txt{white-space:normal;}} `; let bannerEl = null, dismissed = false, current = null; function buildBanner() { if (bannerEl) return bannerEl; const styleEl = document.createElement('style'); styleEl.textContent = STYLE; document.head.appendChild(styleEl); bannerEl = document.createElement('div'); bannerEl.id = 'ls-cr-banner'; bannerEl.setAttribute('role', 'status'); bannerEl.innerHTML = '' + 'Идёт онлайн-урок' + '' + lsIcon('video', 15) + ' Войти' + ''; document.body.appendChild(bannerEl); bannerEl.querySelector('.ls-crb-x').onclick = () => { dismissed = true; bannerEl.classList.remove('open'); }; return bannerEl; } function setSidebarLive(on) { const link = document.getElementById('btn-classroom'); if (link) link.classList.toggle('is-live', on); } function goLive(session) { current = session || current; setSidebarLive(true); // пульс в сайдбаре — всегда if (onClassroomPage() || dismissed) return; // баннер не нужен на самой странице / если закрыли const b = buildBanner(); const t = b.querySelector('#ls-crb-title'); const title = current && current.title; t.textContent = ''; if (title) { t.appendChild(document.createTextNode(': ')); const bold = document.createElement('b'); bold.textContent = title; t.appendChild(bold); } requestAnimationFrame(() => b.classList.add('open')); } function goEnded() { current = null; dismissed = false; setSidebarLive(false); if (bannerEl) bannerEl.classList.remove('open'); } /* ── Мелодия-«вызов на урок» через общий движок LS.sfx ────────────────── */ // sound.js подключён не на всех страницах — подгрузим лениво, затем играем // звук 'lesson_start'. Движок сам уважает мастер-тумблер, громкость, отдельный // тумблер «Вызов на урок» в профиле и разблокировку аудио по первому жесту. function ensureSfx(cb) { if (window.LS && LS.sfx) { if (cb) cb(); return; } let s = document.getElementById('ls-sound-loader'); if (!s) { s = document.createElement('script'); s.id = 'ls-sound-loader'; s.src = '/js/sound.js'; s.defer = true; document.head.appendChild(s); } if (cb) s.addEventListener('load', cb, { once: true }); } function playLessonCall() { ensureSfx(() => { if (window.LS && LS.sfx) LS.sfx.play('lesson_start'); }); } function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); } ready(() => { ensureSfx(); // заранее подгрузить движок звуков // Урок мог начаться ДО загрузки страницы — спросим сервер if (typeof LS !== 'undefined' && LS.api) { LS.api('/api/classroom/my/active') .then(r => { const s = r && r.sessions && r.sessions[0]; if (s) goLive(s); }) .catch(() => {}); } // Реалтайм: старт/конец урока connectSSE(d => { if (d.type === 'classroom_started') { dismissed = false; playLessonCall(); goLive({ title: d.title, sessionId: d.sessionId }); } else if (d.type === 'classroom_ended') goEnded(); }); }); })();