const API = '/api';
/* ── токен ────────────────────────────────────────────────────────────── */
function getToken() { return localStorage.getItem('ls_token'); }
function setToken(t) { localStorage.setItem('ls_token', t); }
function removeToken() { localStorage.removeItem('ls_token'); }
function getUser() { return JSON.parse(localStorage.getItem('ls_user') || 'null'); }
function setUser(u) { localStorage.setItem('ls_user', JSON.stringify(u)); }
function removeUser() { localStorage.removeItem('ls_user'); }
function isLoggedIn() { return !!getToken(); }
function logout() {
removeToken();
removeUser();
window.location.href = '/login';
}
/* ── базовый fetch ────────────────────────────────────────────────────── */
async function req(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + path, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('ls_token'); localStorage.removeItem('ls_user');
if (window.location.pathname !== '/login') { window.location.href = '/login'; return; }
throw Object.assign(new Error(data.error || 'Unauthorized'), { status: 401, data });
}
if (res.status === 403) {
if (typeof LS !== 'undefined' && LS.toast) LS.toast('Нет доступа', 'error');
throw Object.assign(new Error(data.error || 'Forbidden'), { status: 403, data });
}
throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data });
}
return data;
}
/* ── auth ─────────────────────────────────────────────────────────────── */
async function register(email, password, name) {
const data = await req('POST', '/auth/register', { email, password, name });
setToken(data.token);
setUser(data.user);
return data;
}
async function login(email, password) {
const data = await req('POST', '/auth/login', { email, password });
setToken(data.token);
setUser(data.user);
return data;
}
async function fetchMe() {
return req('GET', '/auth/me');
}
async function updateProfile(data) {
const result = await req('PATCH', '/auth/profile', data);
if (result.token) setToken(result.token);
if (result.user) setUser(result.user);
return result;
}
/* ── subjects ─────────────────────────────────────────────────────────── */
async function getSubjects() { return req('GET', '/subjects'); }
async function updateSubject(slug, data) { return req('PATCH', `/subjects/${slug}`, data); }
async function getTopics(slug) { return req('GET', `/subjects/${slug}/topics`); }
/* ── sessions ─────────────────────────────────────────────────────────── */
async function startSession(subject_slug, mode = 'exam', count = 25, topic_id = null, test_id = null) {
return req('POST', '/sessions', { subject_slug, mode, count, topic_id, test_id });
}
async function sendAnswer(session_id, question_id, option_id, time_spent_sec, answer_text, chosen_options) {
// Retry up to 3x on network failures (loss of answer is unacceptable mid-test).
// Server is idempotent: same (session, question) overwrites; safe to retry.
// Don't retry on 4xx (validation/auth errors).
const body = { question_id, option_id, time_spent_sec, answer_text, chosen_options };
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await req('POST', `/sessions/${session_id}/answer`, body);
} catch (e) {
lastErr = e;
const code = e?.status || 0;
if (code >= 400 && code < 500) throw e; // permanent error — abort
await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); // 300, 1200, 2700ms
}
}
throw lastErr;
}
async function finishSession(session_id) {
return req('POST', `/sessions/${session_id}/finish`);
}
async function getResult(session_id) {
return req('GET', `/sessions/${session_id}/result`);
}
async function getHistory(page = 1, limit = 20) {
return req('GET', `/sessions/history?page=${page}&limit=${limit}`);
}
async function getSessionQuestions(id) {
return req('GET', `/sessions/${id}/questions`);
}
async function getWeakTopics() {
return req('GET', '/sessions/weak-topics');
}
async function getStudentStats() {
return req('GET', '/sessions/stats');
}
/* ── questions ────────────────────────────────────────────────────────── */
async function getQuestions(subject, topic_id, sort, page, limit) {
const p = new URLSearchParams();
if (subject) p.set('subject', subject);
if (topic_id) p.set('topic_id', topic_id);
if (sort) p.set('sort', sort);
if (page) p.set('page', page);
if (limit) p.set('limit', limit);
const data = await req('GET', `/questions?${p}`);
// API returns { rows, total, page, limit } — extract rows for compat
return Array.isArray(data) ? data : data.rows;
}
async function createQuestion(data) { return req('POST', '/questions', data); }
async function duplicateQuestion(id) { return req('POST', `/questions/${id}/copy`, {}); }
async function updateQuestion(id, data) { return req('PUT', `/questions/${id}`, data); }
async function deleteQuestion(id) { return req('DELETE', `/questions/${id}`); }
async function importQuestions(formData) {
const token = getToken();
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + '/questions/import', { method: 'POST', headers, body: formData });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data });
return data;
}
/* ── admin ────────────────────────────────────────────────────────────── */
async function adminGetStats() { return req('GET', '/admin/stats'); }
async function adminGetOverview() { return req('GET', '/admin/overview'); }
async function adminGlobalSearch(q) {
// Limits are hardcoded server-side (top 5 users / 3 tests / 3 classes).
return req('GET', `/admin/search?q=${encodeURIComponent(q)}`);
}
async function adminGetUsers(params = {}) {
const p = new URLSearchParams();
if (params.page) p.set('page', params.page);
if (params.limit) p.set('limit', params.limit);
if (params.role) p.set('role', params.role);
if (params.q) p.set('q', params.q);
// Returns { users, total, page, limit } (or { users, nextCursor, limit } if cursor used)
return req('GET', `/admin/users?${p}`);
}
async function adminUpdateRole(id, role) { return req('PATCH', `/admin/users/${id}/role`, { role }); }
async function adminGetUserSessions(id) { return req('GET', `/admin/users/${id}/sessions`); }
async function adminGetSessions(params = {}) {
const p = new URLSearchParams();
if (params.subject) p.set('subject', params.subject);
if (params.user_id) p.set('user_id', params.user_id);
if (params.limit) p.set('limit', params.limit);
if (params.offset) p.set('offset', params.offset);
return req('GET', `/admin/sessions?${p}`);
}
async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); }
async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); }
async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); }
async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); }
async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); }
async function adminDeleteUser(id) { return req('DELETE', `/admin/users/${id}`); }
/* ── classes (teacher/admin) ──────────────────────────────────────────── */
async function getClasses() { return req('GET', '/classes'); }
async function createClass(data) { return req('POST', '/classes', data); }
async function getClassDetail(id) { return req('GET', `/classes/${id}`); }
async function updateClass(id, data) { return req('PATCH', `/classes/${id}`, data); }
async function deleteClass(id) { return req('DELETE', `/classes/${id}`); }
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
async function deleteAssignment(id) { return req('DELETE', `/assignments/${id}`); }
/* ── classes (student) ────────────────────────────────────────────────── */
async function joinClass(invite_code) { return req('POST', '/classes/join', { invite_code }); }
async function myClasses() { return req('GET', '/classes/student/my'); }
async function getStudents() { return req('GET', '/classes/students'); }
async function classFeed(classId) { return req('GET', `/classes/${classId}/feed`); }
/* ── assignments (student) ────────────────────────────────────────────── */
async function myAssignments() { return req('GET', '/assignments/my'); }
async function teacherAssignments() { return req('GET', '/assignments/teacher'); }
async function startAssignment(id) { return req('POST', `/assignments/${id}/start`); }
async function assignmentResults(id) { return req('GET', `/assignments/${id}/results`); }
async function assignmentSessionReview(assignment_id, session_id) {
return req('GET', `/assignments/${assignment_id}/sessions/${session_id}/review`);
}
async function assignmentQuestionStats(id) { return req('GET', `/assignments/${id}/question-stats`); }
/* ── tests ────────────────────────────────────────────────────────────── */
async function getTests(subject) {
const p = subject ? `?subject=${subject}` : '';
return req('GET', `/tests${p}`);
}
async function createTest(data) { return req('POST', '/tests', data); }
async function getTest(id) { return req('GET', `/tests/${id}`); }
async function updateTest(id, data) { return req('PUT', `/tests/${id}`, data); }
async function deleteTest(id) { return req('DELETE', `/tests/${id}`); }
async function addQuestionsToTest(id, question_ids){ return req('POST', `/tests/${id}/questions`, { question_ids }); }
async function removeQFromTest(tid, qid) { return req('DELETE', `/tests/${tid}/questions/${qid}`); }
/* ── gamification ────────────────────────────────────────────────────── */
async function getGamificationMe() { return req('GET', '/gamification/me'); }
async function getGamAchievements() { return req('GET', '/gamification/achievements'); }
async function getLeaderboard(params = {}) {
const p = new URLSearchParams();
if (params.class_id) p.set('class_id', params.class_id);
if (params.period) p.set('period', params.period);
return req('GET', `/gamification/leaderboard?${p}`);
}
async function getXPHistory(limit) { return req('GET', `/gamification/xp-history?limit=${limit || 20}`); }
async function getChallenges() { return req('GET', '/gamification/challenges'); }
async function claimChallenge(id) { return req('POST', `/gamification/challenges/${id}/claim`); }
async function setGoalTier(tier) { return req('POST', '/gamification/goal-tier', { tier }); }
async function getFrames() { return req('GET', '/gamification/frames'); }
async function setFrame(frame) { return req('POST', '/gamification/frame', { frame }); }
async function reportLabActivity(reactionsDiscovered) { return req('POST', '/gamification/lab-activity', { reactionsDiscovered: reactionsDiscovered || 0 }); }
/* ── утилиты ──────────────────────────────────────────────────────────── */
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
const esc = escapeHtml;
function parseDate(dateStr) {
if (!dateStr) return new Date(0);
return new Date(dateStr.replace(' ', 'T') + (dateStr.includes('Z') || dateStr.includes('+') ? '' : 'Z'));
}
function fmtRelTime(dateStr) {
const d = parseDate(dateStr);
const m = Math.floor((Date.now() - d.getTime()) / 60000);
if (m < 1) return 'только что';
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч назад`;
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
function safeHref(link) { return link && /^\/[a-z]/.test(link) ? link : '#'; }
function requireAuth() {
if (!isLoggedIn()) {
window.location.href = '/login';
return false;
}
return true;
}
/* ── SVG-иконки ──────────────────────────────────────────────────────── */
const _ICONS = {
trophy: '',
target: '',
flame: '',
zap: '',
sparkles: '',
diamond: '',
'book-open':'',
running: '',
'check-circle':'',
hundred: '100',
school: '',
'file-text':'',
books: '',
star: '',
crown: '',
brain: '',
party: '',
'thumbs-up':'',
muscle: '',
trash: '',
'help-circle':'',
clipboard: '',
lock: '',
check: '',
x: '',
'x-close': '',
square: '',
'alert-tri':'',
info: '',
warning: '',
'check-sq': '',
'bar-chart':'',
lightbulb: '',
image: '',
link: '',
pin: '',
video: '',
explosion: '',
clock: '',
atom: '',
droplet: '',
compass: '',
moon: '',
'grip-v': '',
'arrow-up': '',
'arrow-down':'',
shuffle: '',
coins: '',
'shopping-bag':'',
gift: '',
dna: '',
};
function lsIcon(name, size = 18, cls = '') {
const d = _ICONS[name];
if (!d) return '';
return ``;
}
/* ── Toast-уведомления ────────────────────────────────────────────────── */
function lsToast(message, type = 'info', duration = 3500) {
// нормализация типа: иначе неизвестный класс (напр. 'warning' вместо 'warn')
// остаётся без фонового градиента → белый текст сливается со страницей
const _tAlias = { warning: 'warn', danger: 'error', err: 'error', fail: 'error', ok: 'success' };
type = _tAlias[type] || type;
if (!['success', 'error', 'info', 'warn'].includes(type)) type = 'info';
if (!document.getElementById('ls-toast-style')) {
const s = document.createElement('style');
s.id = 'ls-toast-style';
s.textContent = `
#ls-toast-wrap{position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;}
.ls-toast{display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:14px;min-width:220px;max-width:360px;
font-family:'Manrope',sans-serif;font-size:0.875rem;font-weight:600;color:#fff;pointer-events:auto;
box-shadow:0 8px 32px rgba(15,23,42,0.22);transform:translateX(120%);opacity:0;
transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .22s ease;}
.ls-toast.show{transform:translateX(0);opacity:1;}
.ls-toast.hide{transform:translateX(120%);opacity:0;}
.ls-toast.success{background:linear-gradient(135deg,#00C87A,#06B96E);}
.ls-toast.error {background:linear-gradient(135deg,#F15BB5,#E0335E);}
.ls-toast.info {background:linear-gradient(135deg,#06D6E0,#9B5DE5);}
.ls-toast.warn {background:linear-gradient(135deg,#FF9F1C,#E07A00);}
.ls-toast-icon{font-size:1.1rem;flex-shrink:0;}
.ls-toast-msg{flex:1;line-height:1.4;}
.ls-toast-close{background:none;border:none;color:rgba(255,255,255,0.7);font-size:1rem;cursor:pointer;padding:0 2px;flex-shrink:0;line-height:1;}
.ls-toast-close:hover{color:#fff;}
`;
document.head.appendChild(s);
}
let wrap = document.getElementById('ls-toast-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = 'ls-toast-wrap';
wrap.setAttribute('aria-live', 'polite');
wrap.setAttribute('aria-atomic', 'false');
document.body.appendChild(wrap);
}
const _tIcons = { success: 'check-circle', error: 'x-close', info: 'info', warn: 'warning' };
const el = document.createElement('div');
el.className = `ls-toast ${type}`;
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.innerHTML = `${lsIcon(_tIcons[type] || 'info', 18)}`;
el.querySelector('.ls-toast-msg').textContent = message;
// Progress bar — thin line at bottom that drains over the toast duration
const bar = document.createElement('span');
bar.className = 'ls-toast-bar';
bar.style.setProperty('--toast-dur', (duration / 1000).toFixed(2) + 's');
el.appendChild(bar);
wrap.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
const hide = () => {
el.classList.add('hide');
setTimeout(() => el.remove(), 320);
};
const timer = setTimeout(hide, duration);
el.querySelector('.ls-toast-close').addEventListener('click', () => { clearTimeout(timer); hide(); });
}
/* ── State helpers: единый паттерн loading/empty/error ───────────────────
Usage:
LS.state.loading(el) // spinner
LS.state.empty(el, msg, icon?) // "пусто" с иконкой
LS.state.error(el, err, retry?) // ошибка + "Повторить"
──────────────────────────────────────────────────────────────────────── */
function _ensureStateStyles() {
if (document.getElementById('ls-state-style')) return;
const s = document.createElement('style');
s.id = 'ls-state-style';
s.textContent = `
.ls-state { padding: 40px 20px; text-align: center; color: #56687A; font-size: .9rem; }
.ls-state-icon { width: 36px; height: 36px; margin: 0 auto 10px; opacity: .55; display: block; stroke: currentColor; fill: none; stroke-width: 1.6; }
.ls-state-spin { width: 32px; height: 32px; border: 3px solid rgba(155,93,229,.15); border-top-color: #9B5DE5; border-radius: 50%; animation: ls-spin .8s linear infinite; margin: 8px auto; display: block; }
@keyframes ls-spin { to { transform: rotate(360deg); } }
.ls-state-title { font-family: 'Unbounded', sans-serif; font-weight: 700; color: #1F2937; margin-bottom: 4px; font-size: .92rem; }
.ls-state-msg { line-height: 1.5; }
.ls-state-btn {
margin-top: 14px; padding: 8px 18px; border-radius: 999px;
border: 1.5px solid #9B5DE5; background: transparent; color: #9B5DE5;
font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.ls-state-btn:hover { background: #9B5DE5; color: #fff; }
`;
document.head.appendChild(s);
}
const lsState = {
loading(el, msg) {
if (!el) return;
_ensureStateStyles();
el.innerHTML = `
${msg ? `
${escapeHtml(msg)}
` : ''}
`;
},
empty(el, msg = 'Пусто', icon = 'inbox') {
if (!el) return;
_ensureStateStyles();
el.innerHTML = `${lsIcon(icon, 36) ? `
${lsIcon(icon, 36)}
` : ''}
${escapeHtml(msg)}
`;
},
error(el, err, retryFn) {
if (!el) return;
_ensureStateStyles();
const msg = (err && err.message) || String(err || 'Ошибка');
const retryId = 'lse-retry-' + Math.random().toString(36).slice(2, 8);
el.innerHTML = `
${lsIcon('alert-circle', 36)}
Не удалось загрузить
${escapeHtml(msg)}
${retryFn ? `
` : ''}
`;
if (retryFn) {
const btn = el.querySelector('#' + retryId);
if (btn) btn.addEventListener('click', () => { lsState.loading(el); retryFn(); });
}
},
};
/* ── Skeleton-заглушки ────────────────────────────────────────────────── */
function lsSkeleton(count = 3, variant = 'card') {
if (!document.getElementById('ls-skeleton-style')) {
const s = document.createElement('style');
s.id = 'ls-skeleton-style';
s.textContent = `
@keyframes ls-shimmer{from{background-position:-600px 0}to{background-position:600px 0}}
.ls-sk{
border-radius:12px;
background:linear-gradient(90deg,rgba(155,93,229,0.06) 20%,rgba(155,93,229,0.15) 40%,rgba(255,255,255,0.65) 50%,rgba(155,93,229,0.15) 60%,rgba(155,93,229,0.06) 80%);
background-size:1200px 100%;
animation:ls-shimmer 1.8s infinite ease-in-out;
}
.ls-sk-card{display:flex;align-items:center;gap:18px;padding:18px 20px;border:1.5px solid rgba(155,93,229,0.08);border-radius:18px;margin-bottom:10px;background:rgba(255,255,255,0.7);}
.ls-sk-icon{width:48px;height:48px;border-radius:12px;flex-shrink:0;}
.ls-sk-body{flex:1;display:flex;flex-direction:column;gap:8px;}
.ls-sk-title{height:14px;border-radius:6px;width:55%;}
.ls-sk-meta{height:10px;border-radius:6px;width:80%;}
.ls-sk-right{width:56px;display:flex;flex-direction:column;gap:8px;align-items:flex-end;}
.ls-sk-pct{width:42px;height:22px;border-radius:8px;}
.ls-sk-btn{width:80px;height:32px;border-radius:999px;}
.ls-sk-row{height:48px;border-radius:12px;margin-bottom:10px;}
`;
document.head.appendChild(s);
}
if (variant === 'row') {
return Array.from({ length: count }, () =>
``
).join('');
}
return Array.from({ length: count }, () => `
`).join('');
}
/* ── Красивый диалог подтверждения ───────────────────────────────────── */
function lsConfirm(message, { title = 'Подтверждение', confirmText = 'Подтвердить', danger = true } = {}) {
return new Promise(resolve => {
if (!document.getElementById('ls-confirm-style')) {
const s = document.createElement('style');
s.id = 'ls-confirm-style';
s.textContent = `
.ls-ov{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center;padding:20px;
background:rgba(15,23,42,0.45);backdrop-filter:blur(12px);opacity:0;transition:opacity .2s ease;}
.ls-ov.open{opacity:1;}
.ls-box{background:#fff;border-radius:24px;padding:36px 40px;width:100%;max-width:420px;text-align:center;
box-shadow:0 40px 100px rgba(15,23,42,0.24);transform:scale(.9) translateY(12px);transition:transform .22s ease;}
.ls-ov.open .ls-box{transform:scale(1) translateY(0);}
.ls-icon{font-size:2.4rem;margin-bottom:12px;}
.ls-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px;}
.ls-msg{font-size:0.88rem;color:#3D4F6B;line-height:1.65;white-space:pre-line;margin-bottom:28px;}
.ls-btns{display:flex;gap:10px;justify-content:center;}
.ls-cancel{padding:10px 26px;min-height:44px;border:1.5px solid rgba(15,23,42,0.2);border-radius:999px;background:transparent;
font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:600;color:#56687A;cursor:pointer;transition:all .2s;}
.ls-cancel:hover{border-color:#9B5DE5;color:#9B5DE5;}
.ls-ok{padding:10px 28px;min-height:44px;border:none;border-radius:999px;color:#fff;
font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer;transition:opacity .2s;
background:linear-gradient(135deg,#06D6E0,#9B5DE5);}
.ls-ok.danger{background:linear-gradient(135deg,#F15BB5,#9B5DE5);}
.ls-ok:hover{opacity:.88;}
`;
document.head.appendChild(s);
}
const prevFocus = document.activeElement;
const el = document.createElement('div');
el.className = 'ls-ov';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-labelledby', 'ls-dlg-title');
el.innerHTML = `
${danger ? lsIcon('trash', 36) : lsIcon('help-circle', 36)}
`;
el.querySelector('.ls-title').textContent = title;
el.querySelector('.ls-msg').textContent = message;
el.querySelector('.ls-ok').textContent = confirmText;
document.body.appendChild(el);
requestAnimationFrame(() => el.classList.add('open'));
const done = result => {
el.classList.remove('open');
setTimeout(() => { el.remove(); prevFocus?.focus(); }, 230);
resolve(result);
};
el.querySelector('.ls-cancel').onclick = () => done(false);
el.querySelector('.ls-ok').onclick = () => done(true);
el.addEventListener('click', e => { if (e.target === el) done(false); });
el.addEventListener('keydown', e => {
if (e.key === 'Tab') {
const btns = [...el.querySelectorAll('button')];
if (e.shiftKey && document.activeElement === btns[0]) {
e.preventDefault(); btns[btns.length - 1].focus();
} else if (!e.shiftKey && document.activeElement === btns[btns.length - 1]) {
e.preventDefault(); btns[0].focus();
}
}
if (e.key === 'Enter') { e.preventDefault(); done(true); }
if (e.key === 'Escape') { e.preventDefault(); done(false); }
});
setTimeout(() => el.querySelector('.ls-cancel').focus(), 10);
});
}
/* ────────────────────────────────────────────────────────────────────────
LS.modal — universal form/content modal
Companion to LS.confirm. Use for forms, pickers, editors — anything
that's not a simple yes/no confirmation.
Usage:
const m = LS.modal({
title: 'Назначить чтение',
content: htmlString | DOMElement,
size: 'sm' | 'md' | 'lg', // 420 / 560 / 720 px
actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Назначить', primary: true, onClick: async () => { ... } },
],
onClose: () => { ... },
});
// Returns { close, root, setBody, setActions, setError }
──────────────────────────────────────────────────────────────────────── */
function lsModal({ title = '', content = '', size = 'md', actions = [], onClose, dismissible = true } = {}) {
if (!document.getElementById('ls-modal-style')) {
const s = document.createElement('style');
s.id = 'ls-modal-style';
s.textContent = `
.ls-mov{position:fixed;inset:0;z-index:9000;display:flex;align-items:flex-start;justify-content:center;
padding:60px 20px 20px;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);
opacity:0;transition:opacity .18s ease;overflow-y:auto;}
.ls-mov.open{opacity:1;}
.ls-mod{background:#fff;border-radius:18px;width:100%;
box-shadow:0 24px 80px rgba(15,23,42,0.28);
transform:scale(.96) translateY(-12px);transition:transform .22s ease;
display:flex;flex-direction:column;max-height:calc(100vh - 80px);}
.ls-mod.sm{max-width:420px;} .ls-mod.md{max-width:560px;} .ls-mod.lg{max-width:720px;}
.ls-mov.open .ls-mod{transform:scale(1) translateY(0);}
.ls-mod-hdr{display:flex;align-items:center;justify-content:space-between;
padding:18px 22px 14px;border-bottom:1px solid rgba(15,23,42,.08);flex-shrink:0;}
.ls-mod-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;}
.ls-mod-x{width:32px;height:32px;border:none;background:transparent;color:#56687A;
cursor:pointer;border-radius:8px;display:flex;align-items:center;justify-content:center;
transition:background .12s;flex-shrink:0;margin-left:12px;}
.ls-mod-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}
.ls-mod-x svg{width:18px;height:18px;}
.ls-mod-body{padding:18px 22px;overflow-y:auto;flex:1;}
.ls-mod-err{margin:0 22px 14px;padding:9px 12px;border-radius:8px;font-size:.84rem;
background:rgba(241,91,68,.1);border:1px solid rgba(241,91,68,.3);color:#F94144;display:none;}
.ls-mod-err.visible{display:block;}
.ls-mod-act{display:flex;gap:10px;justify-content:flex-end;
padding:14px 22px 18px;border-top:1px solid rgba(15,23,42,.08);flex-shrink:0;}
.ls-mod-btn{padding:9px 18px;border-radius:10px;border:1.5px solid rgba(15,23,42,0.18);
background:transparent;color:#0F172A;font-family:'Manrope',sans-serif;
font-size:.88rem;font-weight:700;cursor:pointer;transition:all .15s;}
.ls-mod-btn:hover{border-color:#9B5DE5;color:#9B5DE5;}
.ls-mod-btn.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}
.ls-mod-btn.primary:hover{background:#7e3eca;border-color:#7e3eca;color:#fff;}
.ls-mod-btn.danger{background:#F94144;border-color:#F94144;color:#fff;}
.ls-mod-btn.danger:hover{background:#d62a2d;border-color:#d62a2d;}
.ls-mod-btn:disabled{opacity:.55;cursor:not-allowed;}
@media (max-width:540px){.ls-mov{padding:20px 12px;}.ls-mod-hdr,.ls-mod-body,.ls-mod-act{padding-left:16px;padding-right:16px;}}
`;
document.head.appendChild(s);
}
const prevFocus = document.activeElement;
const ov = document.createElement('div');
ov.className = 'ls-mov';
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.innerHTML = `
`;
ov.querySelector('.ls-mod-title').textContent = title;
const bodyEl = ov.querySelector('.ls-mod-body');
const errEl = ov.querySelector('.ls-mod-err');
const actEl = ov.querySelector('.ls-mod-act');
function setBody(c) {
bodyEl.innerHTML = '';
if (typeof c === 'string') bodyEl.innerHTML = c;
else if (c instanceof Node) bodyEl.appendChild(c);
}
function setError(msg) {
if (!msg) { errEl.classList.remove('visible'); return; }
errEl.textContent = msg;
errEl.classList.add('visible');
}
function setActions(arr) {
actEl.innerHTML = '';
(arr || []).forEach((a, i) => {
const b = document.createElement('button');
b.className = 'ls-mod-btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
b.textContent = a.label || (a.primary ? 'OK' : 'Отмена');
if (a.id) b.id = a.id;
b.addEventListener('click', e => {
e.preventDefault();
if (typeof a.onClick === 'function') a.onClick();
else if (a.close !== false) close();
});
actEl.appendChild(b);
});
actEl.style.display = (arr && arr.length) ? '' : 'none';
}
setBody(content);
setActions(actions);
document.body.appendChild(ov);
requestAnimationFrame(() => ov.classList.add('open'));
function close() {
ov.classList.remove('open');
setTimeout(() => {
ov.remove();
prevFocus?.focus?.();
if (typeof onClose === 'function') onClose();
}, 230);
}
ov.querySelector('.ls-mod-x').onclick = () => { if (dismissible) close(); };
if (dismissible) {
ov.addEventListener('click', e => { if (e.target === ov) close(); });
}
const onKey = e => {
if (e.key === 'Escape' && dismissible) { e.preventDefault(); close(); }
};
document.addEventListener('keydown', onKey);
const _close = close;
close = () => { document.removeEventListener('keydown', onKey); _close(); };
// Focus first focusable element inside body, or close button
setTimeout(() => {
const focusable = bodyEl.querySelector('input,select,textarea,button') || ov.querySelector('.ls-mod-x');
focusable?.focus?.();
}, 50);
return { close, root: ov, body: bodyEl, setBody, setActions, setError };
}
/* ── renderNavAvatar — paint the sidebar avatar (image or initials) ──
Exported via LS.refreshNavAvatar so pages that update avatar_url
(profile preset picker, upload flow) can re-paint without reload.
Stale-cache recovery: if the cached user has no `avatar_url` field
(e.g. logged in before login was returning it), fetch /auth/me once
in the background and re-paint when it returns. */
let _navAvatarFetchInflight = false;
function renderNavAvatar(el, user) {
if (!el) return;
const u = user || getUser();
const url = u?.avatar_url;
if (url) {
el.innerHTML = `
`;
el.style.background = 'transparent';
return;
}
// No url yet — paint initials, then opportunistically refresh from /auth/me
// if the field is absent (not just empty). `null` means "verified absent",
// `undefined` means "we never fetched and might be stale".
const initials = (u?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
el.textContent = initials;
el.style.background = '';
if (u && u.avatar_url === undefined && !_navAvatarFetchInflight && isLoggedIn()) {
_navAvatarFetchInflight = true;
fetchMe().then(fresh => {
if (!fresh) return;
const merged = { ...getUser(), ...fresh, avatar_url: fresh.avatar_url ?? null };
setUser(merged);
const newEl = document.getElementById('nav-avatar');
if (newEl) renderNavAvatar(newEl, merged);
}).catch(() => {}).finally(() => { _navAvatarFetchInflight = false; });
}
}
function refreshNavAvatar() {
renderNavAvatar(document.getElementById('nav-avatar'));
}
/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */
function applyRoleSidebar(user) {
if (!user) return;
if (['teacher', 'admin'].includes(user.role)) {
document.querySelectorAll('.sb-teacher-only').forEach(el => el.style.display = '');
}
}
/* ── initPage — consolidates page init boilerplate ─────────────────── */
function initPage({ requireLogin = true } = {}) {
if (requireLogin && !requireAuth()) return null;
const user = getUser();
const isTeacher = user && ['teacher', 'admin'].includes(user.role);
const isAdmin = user?.role === 'admin';
// Nav avatar — render uploaded avatar if available, otherwise initials.
const navUser = document.getElementById('nav-user');
const navAvatar = document.getElementById('nav-avatar');
if (navUser) navUser.textContent = user?.name || user?.email || '';
if (navAvatar) renderNavAvatar(navAvatar, user);
// Sidebar collapsed state
if (localStorage.getItem('ls_sb_collapsed') === '1') {
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
}
// Sidebar toggle wiring (skip if sidebar.js already wired it via data-sb-wired)
const togBtn = document.querySelector('.sb-toggle');
if (togBtn && !togBtn.dataset.sbWired) {
togBtn.addEventListener('click', () => {
const layout = document.querySelector('.app-layout');
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '0');
});
}
// Sidebar active link (fallback for pages without sidebar.js)
const currentPath = location.pathname.replace(/\.html$/, '').replace(/^\//, '') || 'dashboard';
document.querySelectorAll('.sidebar .sb-link').forEach(a => {
const href = a.getAttribute('href')?.replace(/^\//, '').replace(/\.html$/, '') || '';
if (href && href === currentPath) a.classList.add('active');
});
// Cosmetics
applyCosmetics();
// Hide disabled game features in sidebar
hideDisabledFeatures();
// Board link — показываем всем залогиненным пользователям
const boardEl = document.getElementById('btn-board') || document.getElementById('sbl-board');
if (boardEl) boardEl.style.display = '';
// Teacher-only sidebar items
applyRoleSidebar(user);
return { user, isTeacher, isAdmin };
}
/* ── Feature flags (cached per page load, bust on demand) ───────────── */
let _featuresCache = null;
/* Synchronous mirror of features.gamification, populated on first
loadFeatures() resolve. Used by xp.js to bail out without network. */
let _gamificationEnabled = null;
function isGamificationEnabled() {
// Default to true when the cache hasn't resolved yet — page-load order
// means some code runs before /api/features. The CSS kill-switch
// (body.no-gamification) catches the visual side regardless.
return _gamificationEnabled !== false;
}
async function loadFeatures() {
if (_featuresCache) return _featuresCache;
try {
_featuresCache = await apiFetch('/api/features');
} catch { _featuresCache = {}; }
_gamificationEnabled = _featuresCache.gamification !== false;
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'],
};
/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
Hero-карточки дашборда: у lab JS меняет href на /lab?sim=… → [href="/lab"] не
матчит, поэтому прячем по СТАБИЛЬНОМУ id #hc-lab (аналогично pet/чтение). */
const FEATURE_WIDGETS = {
flashcards: ['#w-flashcard'],
lab: ['#hc-lab'],
pet: ['#hc-pet'],
textbooks: ['#hc-read'],
};
/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша
на ранней загрузке (ДО построения сайдбара/виджетов) — против мигания (FOUC),
затем обновляется по свежему /api/features. */
function _applyFeatureCss(feats) {
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 { /* пусто */ }
const css = sels.length ? sels.join(',') + '{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;
// Геймификация: класс на (доступен раньше 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 */ }
/* Прячет группы сайдбара (.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;
const feats = await loadFeatures();
// Фича выключена (глобально или для класса) → доску не показываем, даже учителю/админу.
// Эта функция зовётся напрямую на многих страницах, поэтому проверка ОБЯЗАТЕЛЬНА,
// иначе она перекрывает скрытие из hideDisabledFeatures().
if (feats.board === false) { el.style.display = 'none'; return; }
if (user.role === 'teacher' || user.role === 'admin') { 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 (визуальное скрытие)
// Редирект со страницы отключённой фичи (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/): показываем только включённые
// (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); // обновить