40df8893cc
В каталоге учебников (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>
2032 lines
118 KiB
JavaScript
2032 lines
118 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
const esc = escapeHtml;
|
||
|
||
function parseDate(dateStr) {
|
||
if (!dateStr) return new Date(0);
|
||
return new Date(dateStr.replace(' ', 'T') + (dateStr.includes('Z') || dateStr.includes('+') ? '' : 'Z'));
|
||
}
|
||
|
||
function fmtRelTime(dateStr) {
|
||
const d = parseDate(dateStr);
|
||
const m = Math.floor((Date.now() - d.getTime()) / 60000);
|
||
if (m < 1) return 'только что';
|
||
if (m < 60) return `${m} мин назад`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `${h} ч назад`;
|
||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
function safeHref(link) { return link && /^\/[a-z]/.test(link) ? link : '#'; }
|
||
|
||
function requireAuth() {
|
||
if (!isLoggedIn()) {
|
||
window.location.href = '/login';
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* ── SVG-иконки ──────────────────────────────────────────────────────── */
|
||
const _ICONS = {
|
||
trophy: '<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="Скрыть">×</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 });
|
||
});
|
||
})();
|