Files
Maxim Dolgolyov 40df8893cc fix(lab): значок «связанной симуляции» на карточках учебников не скрывался при выключенной лаборатории
В каталоге учебников (textbooks.html) у карточек есть кнопка .tb-lab-btn «открыть
связанную симуляцию» (openLabSim → /lab?sim=…). Это <button onclick>, а не <a href="/lab">,
поэтому kill-switch `[href="/lab"]` её не ловил, и значок-колба оставался при отключённой
«Лаборатории».

Фикс: добавил `.tb-lab-btn` в FEATURE_WIDGETS.lab → api.js скрывает её через инъекцию
при lab=false (работает и без ls.css). Плюс страховка в openLabSim: при lab=false не
открываем (тост «Лаборатория отключена»); админ — всегда (admin-override).

Verified vm-смоук на реальном api.js 4/4 (lab off → .tb-lab-btn скрыта; lab on → нет;
admin → ничего). node --check api.js + инлайн textbooks.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:36:11 +03:00

2032 lines
118 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
/* ── Пожелания по улучшению ── */
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
async function wishCreate(data) { return req('POST', '/wishes', data); }
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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: '<path d="M6 9H4V5h16v4h-2"/><path d="M6 9a6 6 0 006 6 6 6 0 006-6"/><path d="M12 15v3m-4 3h8" stroke-linecap="round"/>',
target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
flame: '<path d="M12 2c.5 3.5-1.5 6-1.5 6 1 1.5 3 2 3 5a4 4 0 01-8 0c0-2 .5-3 1.5-4.5C8.5 6.5 7 4.5 7 4.5S9.5 2 12 2z"/>',
zap: '<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>',
sparkles: '<path d="M12 2l1.5 4.5L18 8l-4.5 1.5L12 14l-1.5-4.5L6 8l4.5-1.5L12 2zm6 10l1 2.5L21.5 16 19 17l-1 2.5-1-2.5L14.5 16 17 15l1-2.5zM4 16l.75 2L7 18.75 4.75 19.5 4 22l-.75-2.5L1 18.75 3.25 18 4 16z" fill="currentColor" stroke="none"/>',
diamond: '<path d="M2.7 9l4-6h10.6l4 6L12 22 2.7 9z"/><path d="M2.7 9h18.6" stroke-linecap="round"/>',
'book-open':'<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2V3zm20 0h-6a4 4 0 00-4 4v14a3 3 0 013-3h7V3z"/>',
running: '<circle cx="14" cy="4" r="2" fill="currentColor" stroke="none"/><path d="M18 9l-4.35 2.17-2.18-2.58L7 12l1.5 1.5 2.5-2 1.73 2.05L10 17l-3 4m7-8l2 4h3"/>',
'check-circle':'<path d="M22 11.08V12a10 10 0 11-5.93-9.14M22 4L12 14.01l-3-3"/>',
hundred: '<text x="12" y="16" text-anchor="middle" font-size="14" font-weight="bold" fill="currentColor" stroke="none" font-family="sans-serif">100</text>',
school: '<path d="M2 10l10-6 10 6-10 6z"/><path d="M6 13v5c0 1.5 2.7 3 6 3s6-1.5 6-3v-5"/><path d="M22 10v6" stroke-linecap="round"/>',
'file-text':'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6M16 13H8m8 4H8m2-8H8"/>',
books: '<rect x="2" y="4" width="6" height="16" rx="1"/><rect x="10" y="2" width="6" height="18" rx="1"/><path d="M18 5l3.5 15.5-1 .2L17 5.5l1-.5z"/>',
star: '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.27 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z"/>',
crown: '<path d="M2 17l3-10 4 4 3-6 3 6 4-4 3 10z" fill="currentColor" stroke="none"/><rect x="2" y="17" width="20" height="3" rx="1" fill="currentColor" stroke="none"/>',
brain: '<path d="M9.5 2a4.5 4.5 0 00-3.8 6.9A4 4 0 004 13a4 4 0 003 3.87V21h2v-4.13A4 4 0 0012 13v-1m2.5-10a4.5 4.5 0 013.8 6.9A4 4 0 0020 13a4 4 0 00-3 3.87V21h-2v-4.13A4 4 0 0012 13v-1"/>',
party: '<path d="M5.8 11.3L2 22l10.7-3.8M5.8 11.3l4.8 4.8m-4.8-4.8L12 2l1.2 5.3m-2.4 8.6L22 12l-5.3-1.2M15 4l2 2m-8 8l2 2"/>',
'thumbs-up':'<path d="M14 9V5a3 3 0 00-6 0v1l-2 6v7a1 1 0 001 1h9.28a2 2 0 001.94-1.57l1.56-7A2 2 0 0017.86 9H14z"/>',
muscle: '<path d="M7 11c0-2 1-4 3-4s3 2 3 2 1-2 3-2 3 2 3 4v4a6 6 0 01-6 6H9a6 6 0 01-6-6v-2c0-2 1-3 2-3s2 1 2 3"/>',
trash: '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14zM10 11v6m4-6v6"/>',
'help-circle':'<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3m.08 4h.01"/>',
clipboard: '<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>',
check: '<path d="M20 6L9 17l-5-5"/>',
x: '<path d="M18 6L6 18M6 6l12 12"/>',
'x-close': '<path d="M18 6L6 18M6 6l12 12"/>',
square: '<rect x="3" y="3" width="18" height="18" rx="2"/>',
'alert-tri':'<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4m0 4h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/>',
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4m0 4h.01"/>',
'check-sq': '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
'bar-chart':'<path d="M12 20V10M18 20V4M6 20v-4"/>',
lightbulb: '<path d="M9 18h6m-5 2h4M12 2a7 7 0 00-4 12.7V17h8v-2.3A7 7 0 0012 2z"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
link: '<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71m4.54 5.07a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>',
pin: '<path d="M12 2a7 7 0 00-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 00-7-7z"/><circle cx="12" cy="9" r="2.5"/>',
video: '<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M22 7l-6 4 6 4V7z"/>',
explosion: '<path d="M12 2l2 6 6-2-4 5 5 3-6 1 1 6-4-4-4 4 1-6-6-1 5-3-4-5 6 2z"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
atom: '<circle cx="12" cy="12" r="2"/><ellipse cx="12" cy="12" rx="10" ry="4" fill="none"/><ellipse cx="12" cy="12" rx="10" ry="4" fill="none" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" fill="none" transform="rotate(120 12 12)"/>',
droplet: '<path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/>',
compass: '<circle cx="12" cy="12" r="10"/><path d="M16.24 7.76l-2.12 6.36-6.36 2.12 2.12-6.36z"/>',
moon: '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>',
'grip-v': '<circle cx="9" cy="5" r="1" fill="currentColor" stroke="none"/><circle cx="15" cy="5" r="1" fill="currentColor" stroke="none"/><circle cx="9" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="9" cy="19" r="1" fill="currentColor" stroke="none"/><circle cx="15" cy="19" r="1" fill="currentColor" stroke="none"/>',
'arrow-up': '<path d="M12 19V5m-7 7l7-7 7 7"/>',
'arrow-down':'<path d="M12 5v14m7-7l-7 7-7-7"/>',
shuffle: '<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>',
coins: '<circle cx="8" cy="14" r="6"/><path d="M18 8a6 6 0 00-6 6"/><circle cx="16" cy="10" r="6"/>',
'shopping-bag':'<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6zM3 6h18M16 10a4 4 0 01-8 0"/>',
gift: '<path d="M20 12v10H4V12M2 7h20v5H2V7z"/><path d="M12 22V7m0 0a4 3 0 10-4-3m4 3a4 3 0 114-3"/>',
dna: '<path d="M2 15c6.667-6 13.333 0 20-6M2 9c6.667 6 13.333 0 20 6M7 21c0-4 4-4 4-8s-4-4-4-8M17 21c0-4-4-4-4-8s4-4 4-8"/>',
};
function lsIcon(name, size = 18, cls = '') {
const d = _ICONS[name];
if (!d) return '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"${cls ? ` class="${cls}"` : ''} style="display:inline-block;vertical-align:middle;flex-shrink:0">${d}</svg>`;
}
/* ── 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 = `<span class="ls-toast-icon">${lsIcon(_tIcons[type] || 'info', 18)}</span><span class="ls-toast-msg"></span><button class="ls-toast-close" aria-label="Закрыть уведомление" onclick="this.closest('.ls-toast').remove()">${lsIcon('x-close', 14)}</button>`;
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 = `<div class="ls-state"><div class="ls-state-spin"></div>${msg ? `<div class="ls-state-msg">${escapeHtml(msg)}</div>` : ''}</div>`;
},
empty(el, msg = 'Пусто', icon = 'inbox') {
if (!el) return;
_ensureStateStyles();
el.innerHTML = `<div class="ls-state">${lsIcon(icon, 36) ? `<div style="margin-bottom:10px;display:flex;justify-content:center;opacity:.55">${lsIcon(icon, 36)}</div>` : ''}<div class="ls-state-msg">${escapeHtml(msg)}</div></div>`;
},
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 = `<div class="ls-state">
<div style="margin-bottom:10px;display:flex;justify-content:center;opacity:.55;color:#F94144">${lsIcon('alert-circle', 36)}</div>
<div class="ls-state-title">Не удалось загрузить</div>
<div class="ls-state-msg">${escapeHtml(msg)}</div>
${retryFn ? `<button class="ls-state-btn" id="${retryId}">Повторить</button>` : ''}
</div>`;
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 }, () =>
`<div class="ls-sk ls-sk-row"></div>`
).join('');
}
return Array.from({ length: count }, () => `
<div class="ls-sk-card">
<div class="ls-sk ls-sk-icon"></div>
<div class="ls-sk-body">
<div class="ls-sk ls-sk-title"></div>
<div class="ls-sk ls-sk-meta"></div>
</div>
<div class="ls-sk-right">
<div class="ls-sk ls-sk-pct"></div>
<div class="ls-sk ls-sk-btn"></div>
</div>
</div>`).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 = `
<div class="ls-box">
<div class="ls-icon">${danger ? lsIcon('trash', 36) : lsIcon('help-circle', 36)}</div>
<div class="ls-title" id="ls-dlg-title"></div>
<div class="ls-msg"></div>
<div class="ls-btns">
<button class="ls-cancel">Отмена</button>
<button class="ls-ok${danger ? ' danger' : ''}"></button>
</div>
</div>`;
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 = `
<div class="ls-mod ${size}" onclick="event.stopPropagation()">
<div class="ls-mod-hdr">
<div class="ls-mod-title"></div>
<button class="ls-mod-x" aria-label="Закрыть">${lsIcon('x-close', 18)}</button>
</div>
<div class="ls-mod-body"></div>
<div class="ls-mod-err"></div>
<div class="ls-mod-act"></div>
</div>`;
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 = `<img src="/avatars/${escapeHtml(url)}?t=${Date.now()}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block">`;
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;
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
return _featuresCache;
}
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; }
/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */
const FEATURE_HREFS = {
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'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
theory: ['/theory', '/theory.html'],
sitemap: ['/sitemap', '/sitemap.html'],
wishes: ['/wishes', '/wishes.html'],
};
/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
Hero-карточки дашборда: у lab JS меняет href на /lab?sim=… → [href="/lab"] не
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
const FEATURE_WIDGETS = {
flashcards: ['#w-flashcard'],
// #hc-lab — hero-карточка дашборда; .tb-lab-btn — кнопка «открыть связанную
// симуляцию» на карточках каталога учебников (openLabSim → /lab?sim=…). Это
// <button onclick>, а не <a href="/lab">, поэтому [href="/lab"] её не ловит.
lab: ['#hc-lab', '.tb-lab-btn'],
pet: ['#hc-pet'],
textbooks: ['#hc-read'],
};
/* Админ видит и имеет доступ ко ВСЕМУ, даже к отключённым модулям (он ими управляет).
Поэтому для админа никакие скрытия/редиректы фич не применяются. getUser() читает
localStorage синхронно (определён в начале файла) — работает и на ранней sync-инъекции. */
function _isAdminUser() {
try { return getUser()?.role === 'admin'; } catch { return false; }
}
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
на ранней загрузке (ДО построения сайдбара/виджетов) — против мигания (FOUC),
затем обновляется по свежему /api/features. */
function _applyFeatureCss(feats) {
// Админ — без скрытий: чистим <style> и снимаем kill-switch геймификации.
if (_isAdminUser()) {
const elA = document.getElementById('ls-feat-hide');
if (elA) elA.textContent = '';
document.documentElement.classList.remove('no-gamification');
return;
}
const sels = [];
if (feats) {
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
hrefs.forEach(h => sels.push(`[href="${h}"]`));
(FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s));
}
}
}
// Скрытые exam-prep треки (подготовка): кэш хрефов с прошлой загрузки — против мигания.
// /api/exam-prep/tracks асинхронен, поэтому держим точный список скрытых ссылок в кэше.
try {
JSON.parse(localStorage.getItem('ls_examhide') || '[]')
.forEach(h => sels.push(`[href="${h}"]`));
} catch { /* пусто */ }
let css = sels.length ? sels.join(',') + '{display:none !important}' : '';
// Геймификация: дублируем kill-switch в инъекцию — для страниц БЕЗ ls.css.
// Учебники (frontend/textbooks/*.html) грузят api.js, но НЕ ls.css, поэтому правила
// .no-gamification из ls.css туда не доходят, и встроенная XP-механика (data-gamified,
// #ach-popup) оставалась видимой. Инъекция работает на любой странице с api.js.
if (feats && feats.gamification === false) {
css += '.no-gamification [data-gamified],.no-gamification #ach-popup{display:none!important}';
}
let el = document.getElementById('ls-feat-hide');
if (!el) {
el = document.createElement('style');
el.id = 'ls-feat-hide';
(document.head || document.documentElement).appendChild(el);
}
el.textContent = css;
// Геймификация: класс на <html> (доступен раньше body) → kill-switch без мигания.
if (feats) document.documentElement.classList.toggle('no-gamification', feats.gamification === false);
}
/* Ранняя синхронная попытка из кэша прошлой загрузки — нет мигания на повторных заходах.
(FEATURE_HREFS — const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */
try {
const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
_applyFeatureCss(_cachedFeats); // применит и кэш фич, и кэш скрытых exam-prep ссылок
} catch { /* нет кэша / приватный режим — просто ждём async */ }
/* Авторитетно подтянуть фичи на страницах БЕЗ сайдбара (учебники, embed): там
sidebar.js/hideDisabledFeatures не вызывают loadFeatures, и кэш мог устареть.
loadFeatures() кэширует in-memory (дубль-вызов = один fetch) и сам зовёт _applyFeatureCss.
Только для залогиненных — иначе на /login apiFetch поймает 401 и зациклит редирект. */
try {
if (isLoggedIn()) { loadFeatures().catch(() => {}); }
} catch { /* defensive */ }
/* Прячет группы сайдбара (.sb-group), у которых не осталось ни одного видимого пункта,
чтобы не висел пустой заголовок-аккордеон (напр. «Практика и игры», когда все
модули отключены). Зовётся после построения сайдбара и после hideDisabledFeatures. */
function hideEmptySidebarGroups() {
document.querySelectorAll('.sb-group').forEach(g => {
const body = g.querySelector('.sb-group-body');
if (!body) return;
let anyVisible = false;
body.querySelectorAll('.sb-link').forEach(it => {
const cs = getComputedStyle(it);
if (cs.display !== 'none' && cs.visibility !== 'hidden') anyVisible = true;
});
g.style.display = anyVisible ? '' : 'none';
});
}
/**
* 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 === 'admin') { el.style.display = ''; return; }
const feats = await loadFeatures();
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю.
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
// иначе она перекрывает скрытие из hideDisabledFeatures().
if (feats.board === false) { el.style.display = 'none'; return; }
if (user.role === 'teacher') { el.style.display = ''; return; }
// Student: check if in a class
if (!feats._no_class) el.style.display = '';
}
async function hideDisabledFeatures() {
const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
// Админ видит и открывает всё — никаких скрытий, редиректов и схлопывания групп.
if (_isAdminUser()) return;
// Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
if (feats[key] === false) {
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'); // дубль на body (html-класс ставит _applyFeatureCss)
// 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();
}
}
// Exam-prep track links (/exam-prep/<key>): показываем только включённые
// (exam_tracks.enabled) и доступные пользователю треки. /api/exam-prep/tracks
// уже отдаёт enabled-треки, отфильтрованные по правам доступа.
const examLinks = document.querySelectorAll('[href^="/exam-prep/"]');
if (examLinks.length) {
try {
const data = await apiFetch('/api/exam-prep/tracks');
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
// Собираем точные хрефы скрытых треков и кэшируем — чтобы на СЛЕДУЮЩЕЙ загрузке
// _applyFeatureCss спрятал их синхронно из кэша ещё до сборки сайдбара (без мигания).
const hide = [];
examLinks.forEach(el => {
const href = el.getAttribute('href') || '';
const m = href.match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) hide.push(href);
});
try { localStorage.setItem('ls_examhide', JSON.stringify(hide)); } catch {}
_applyFeatureCss(_featuresCache); // обновить <style> (скрыть запрещённые, ПОКАЗАТЬ снова разрешённые)
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
}
// 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', '/quantik',
];
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', '/quantik',
];
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> no gamification
}
// В самом конце — после всех скрытий (фичи, exam-prep, no_class) — схлопнуть пустые группы.
hideEmptySidebarGroups();
}
/* ── 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;
const _PREFS_LS_KEY = 'ls_prefs';
// Персистентность настроек — в localStorage (per-device). Раньше sync был отключён,
// и настройки молча терялись при перезагрузке. Грузим синхронно сразу при загрузке api.js.
try { const raw = localStorage.getItem(_PREFS_LS_KEY); if (raw) Object.assign(_prefsCache, JSON.parse(raw)); } catch (e) {}
async function _prefsLoad() { try { const raw = localStorage.getItem(_PREFS_LS_KEY); if (raw) Object.assign(_prefsCache, JSON.parse(raw)); } catch (e) {} }
function _prefsFlush() {
if (!_prefsDirty) return;
_prefsDirty = false;
try { localStorage.setItem(_PREFS_LS_KEY, JSON.stringify(_prefsCache)); } catch (e) {}
}
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, classOutstanding,
wishesList, wishCreate, wishUpdate, wishDelete,
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,
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
gameProgressList, gameProgressSubmit,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass,
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,
hideEmptySidebarGroups,
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 <body> 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 customSimsList() { return req('GET', '/custom-sims'); }
async function customSimGet(id) { return req('GET', `/custom-sims/${id}`); }
async function customSimCreate(data) { return req('POST', '/custom-sims', data); }
async function customSimUpdate(id, d) { return req('PUT', `/custom-sims/${id}`, d); }
async function customSimDelete(id) { return req('DELETE', `/custom-sims/${id}`); }
async function customSimShare(id, d) { return req('POST', `/custom-sims/${id}/share`, d); }
async function customSimClone(id) { return req('POST', `/custom-sims/${id}/clone`); }
async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); }
async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); }
async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); }
async function gameProgressList() { return req('GET', '/game/progress'); }
async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); }
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, mode) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined, mode: mode || undefined }); }
async function assistantFlashcards(text, title, count) { return req('POST', '/assistant/flashcards', { text, title, count }); }
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
async function assistantMemory() { return req('GET', '/assistant/memory'); }
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
async function imageGen(prompt) { return req('POST', '/imggen', { prompt }); }
async function imageGenStatus() { return req('GET', '/imggen/status'); }
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 adminSaveProvider(d) { return req('POST', '/admin/assistant/provider', d); }
async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); }
async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); }
async function adminAssistantModels(params) { const q = new URLSearchParams(params || {}).toString(); return req('GET', '/admin/assistant/models' + (q ? '?' + q : '')); }
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 fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
/* ── prep tracks (мастер-флаг «подготовка к ЦТ» и т.п.) ──────────────────── */
async function prepListTracks() { return req('GET', '/prep/tracks'); }
async function prepMyTracks() { return req('GET', '/prep/me'); }
async function prepStudentTracks(uid) { return req('GET', `/prep/student/${uid}`); }
async function prepSetStudent(uid, track) { return req('POST', `/prep/student/${uid}`, { track }); }
async function prepUnsetStudent(uid, track){ return req('DELETE', `/prep/student/${uid}?track=${encodeURIComponent(track)}`); }
async function prepClassStatus(classId, track) { return req('GET', `/prep/class/${classId}?track=${encodeURIComponent(track)}`); }
async function prepSetClass(classId, track, on) { return req('POST', `/prep/class/${classId}`, { track, on: !!on }); }
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 _sseConnecting = false;
let _sseRetryMs = 2000;
let _sseEverConnected = false; // tracks whether SSE has successfully opened before
const _sseListeners = new Set();
function _sseRetry() {
const delay = Math.min(_sseRetryMs, 30000);
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
setTimeout(_sseConnect, delay);
}
async function _sseConnect() {
if (_sseShared || _sseConnecting) return;
if (!getToken()) return;
// Берём одноразовый тикет authed-запросом (Bearer в заголовке) — токен не попадает в URL.
_sseConnecting = true;
let ticket = null;
try { const r = await req('GET', '/notifications/stream-ticket'); ticket = r && r.ticket; } catch (e) {}
_sseConnecting = false;
if (_sseShared) return; // подключились параллельно
if (!ticket) { _sseRetry(); return; } // не вышло — повтор по backoff
const url = `${API}/notifications/stream?ticket=${encodeURIComponent(ticket)}`;
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;
_sseRetry();
};
}
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();
try { lsPrefs.flush(); } catch (e) {}
});
/* ── 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 = `<div class="ls-live-box">
<div class="ls-live-badge" aria-hidden="true"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Live Quiz</div>
<div class="ls-live-q" id="lslq-text"></div>
<div class="ls-live-opts" id="lslq-opts" role="radiogroup" aria-label="Варианты ответа"></div>
<div class="ls-live-status" id="lslq-status" aria-live="polite"></div>
</div>`;
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) => `
<div class="ls-live-opt" role="radio" aria-checked="false" tabindex="0" data-id="${o.id}"
onclick="window._lsLiveAnswer(${liveId},${o.id},this)"
onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();window._lsLiveAnswer(${liveId},${o.id},this)}">
<span class="ls-live-opt-key" aria-hidden="true">${keys[i] || i+1}</span>
<span>${_mathHtml(o.text)}</span>
</div>`).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 `<div class="ls-live-opt ${cls}" style="flex-direction:column;align-items:flex-start;gap:4px">
<div style="display:flex;align-items:center;gap:10px;width:100%">
<span class="ls-live-opt-key">${keys[i]||i+1}</span>
<span style="flex:1">${_mathHtml(o.text)}</span>
<span class="ls-live-result-pct">${pct}%</span>
</div>
<div class="ls-live-result-bar" style="width:100%">
<div class="ls-live-result-fill ${o.is_correct?'correct-fill':''}" style="width:${pct}%"></div>
</div>
</div>`;
}).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 = 'Ответ отправлен <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
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 =
'<span class="ls-crb-dot"></span>' +
'<span class="ls-crb-txt">Идёт онлайн-урок<span id="ls-crb-title"></span></span>' +
'<a href="/classroom" class="ls-crb-join">' + lsIcon('video', 15) + ' Войти</a>' +
'<button class="ls-crb-x" aria-label="Скрыть">&times;</button>';
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();
});
});
})();
/* ── Глобальный репортер клиентских ошибок ───────────────────────────────
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
и шлёт в /api/client-errors → они появляются в админ-вкладке «Ошибки».
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
(function initClientErrorReporter() {
const seen = new Set();
let sent = 0; const MAX_PER_PAGE = 15;
let inFlight = false;
function send(payload) {
try {
if (!isLoggedIn()) return; // отчёты только от залогиненных
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
if (seen.has(sig)) return;
seen.add(sig); sent++;
if (inFlight) return;
inFlight = true;
const token = getToken();
fetch(API + '/client-errors', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
body: JSON.stringify(payload),
keepalive: true, // долетит даже при закрытии вкладки
}).catch(function () {}).finally(function () { inFlight = false; });
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
}
window.addEventListener('error', function (e) {
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
if (!e || (!e.message && !e.error)) return;
send({
kind: 'error',
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
stack: e.error && e.error.stack ? String(e.error.stack) : null,
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
url: location.pathname + location.search + location.hash,
});
});
window.addEventListener('unhandledrejection', function (e) {
const r = e && e.reason;
let msg = 'Unhandled promise rejection';
let stack = null;
if (r) {
if (typeof r === 'string') msg = r;
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
}
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
});
})();