'use strict';
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
* правило ученика важнее правила класса.
*
* Два режима:
* • «По контенту» — выбрать учебник/экзамен → раздать классам (+ ученики).
* • «По классу» — выбрать класс → отметить доступный ему контент. */
(function () {
'use strict';
let inited = false;
let _catalog = null; // { textbooks:[], exams:[] }
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
let _summary = { totalClasses: 0, textbooks: {}, exams: {} };
let _mode = 'content'; // 'content' | 'class'
// content-mode state
let _selContent = null; // { type, ref, title }
let _rules = { classRules: {}, studentRules: {} };
const _open = new Set(); // развёрнутые классы (показ учеников)
// class-mode state
let _selClass = null; // { id, name }
let _classOpen = { textbooks: new Set(), exams: new Set() };
// matrix-mode state
let _matrix = null; // { classes:[{id,name}], open:{ [cid]:{textbook:[],exam:[]} } }
let _mSearch = '';
// content-mode left search
let _leftSearch = '';
const SUBJ_LABEL = { math: 'Математика', physics: 'Физика', phys: 'Физика', chemistry: 'Химия',
chem: 'Химия', biology: 'Биология', bio: 'Биология', informatics: 'Информатика',
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' };
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
const CONTENT_TYPES = ['textbook', 'exam', 'sim', 'course'];
const bucket = (type) => BUCKET[type] || (type + 's');
const keyName = (type) => KEYNAME[type] || 'id';
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
const subjOf = (it) => it.subject || it.subject_slug || ''; // нормализация поля предмета
const subjLabel = (s) => SUBJ_LABEL[s] || s || 'Прочее';
function contentTitle(type, ref) {
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
return it ? it.title : ref;
}
async function load() {
const root = document.getElementById('acc-root');
try {
[_catalog, _targets, _summary] = await Promise.all([
LS.accessCatalog(), LS.accessTargets(), LS.accessSummary(),
]);
renderRoot();
} catch (e) {
root.innerHTML = `
Ошибка загрузки: ${esc(e.message)}
`;
}
}
/* ── каркас: переключатель режимов + две колонки / матрица ── */
function renderRoot() {
const root = document.getElementById('acc-root');
const seg = (m, label, pos) => {
const radius = pos === 'first' ? 'border-radius:8px 0 0 8px'
: pos === 'last' ? 'border-radius:0 8px 8px 0;border-left:none' : 'border-left:none';
return ``;
};
const tabs = `
${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')}
`;
if (_mode === 'matrix') {
root.innerHTML = tabs + ``;
renderMatrix();
return;
}
root.innerHTML = tabs + `
`;
renderLeft();
renderRight();
}
/* ── badge «N/M» ── */
function badge(open, total) {
const has = open > 0;
return `${open}/${total}`;
}
/* ── список контента в левой колонке (с поиском + подзаголовками по предмету) ── */
function contentItemBtn(type, it, total) {
const ref = it[keyName(type)];
const active = _selContent && _selContent.type === type && _selContent.ref === ref;
const open = (_summary[bucket(type)] || {})[ref] || 0;
return ``;
}
function contentLeftList() {
const total = _summary.totalClasses || 0;
const term = _leftSearch.trim().toLowerCase();
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
let html = '';
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type).filter(match);
if (!items.length) return;
html += `${TYPE_LABEL[type]}
`;
if (type === 'textbook') {
let lastSubj = null;
items.forEach(it => {
const sj = it.subject || '';
if (sj !== lastSubj) {
lastSubj = sj;
html += `${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}
`;
}
html += contentItemBtn('textbook', it, total);
});
} else {
items.forEach(it => { html += contentItemBtn(type, it, total); });
}
});
return html || empty('Ничего не найдено');
}
function leftSearch(v) {
_leftSearch = v;
const el = document.getElementById('acc-left-list');
if (el) el.innerHTML = contentLeftList();
}
/* ── ЛЕВАЯ колонка ── */
function renderLeft() {
const left = document.getElementById('acc-left');
if (_mode === 'content') {
left.innerHTML = `
${contentLeftList()}
`;
} else {
const classes = _targets.classes || [];
left.innerHTML = `
Классы
${classes.length ? classes.map(c => {
const active = _selClass && _selClass.id === c.id;
return ``;
}).join('') : empty('Нет классов')}`;
}
}
const empty = (t) => `${t}
`;
/* ── ПРАВАЯ колонка ── */
function renderRight() {
const right = document.getElementById('acc-right');
if (_mode === 'content') {
if (!_selContent) { right.innerHTML = hint('Выберите учебник или экзамен слева, чтобы настроить доступ.'); return; }
renderContentDetail(right);
} else {
if (!_selClass) { right.innerHTML = hint('Выберите класс слева, чтобы открыть ему учебники и экзамены.'); return; }
renderClassDetail(right);
}
}
const hint = (t) => `${t}
`;
/* ════════ режим «По контенту» ════════ */
async function selContent(type, ref) {
_selContent = { type, ref, title: contentTitle(type, ref) };
renderLeft();
const right = document.getElementById('acc-right');
right.innerHTML = 'Загрузка…
';
try {
_rules = await LS.accessRules(type, ref);
renderRight();
} catch (e) { right.innerHTML = `Ошибка: ${esc(e.message)}
`; }
}
function studentTri(uid) {
const v = _rules.studentRules[uid];
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
const btn = (val, label, on) =>
``;
return `
${btn('null', 'Наследовать', state === 'inherit')}${btn(1, 'Открыт', state === 'open')}${btn(0, 'Закрыт', state === 'closed')}`;
}
/* эффективный доступ ученика: что он реально видит и почему */
function effBadge(uid, classOpen) {
const v = _rules.studentRules[uid];
let open, why;
if (v === 1) { open = true; why = 'лично'; }
else if (v === 0) { open = false; why = 'лично'; }
else { open = !!classOpen; why = classOpen ? 'по классу' : 'по умолч.'; }
return `${open ? 'видит' : 'не видит'} · ${why}`;
}
function classRowContent(c) {
const openToClass = _rules.classRules[c.id] === 1;
const expanded = _open.has(c.id);
const students = c.students || [];
const studentsHtml = expanded ? `
${students.length ? students.map(s => `
${esc(s.name || s.email)}
${effBadge(s.id, openToClass)}${studentTri(s.id)}
`).join('') : '
В классе нет учеников
'}
` : '';
return `
`;
}
function looseRow(s) {
const open = _rules.studentRules[s.id] === 1;
return `
${esc(s.name || s.email)} ${esc(s.email)}
`;
}
function renderContentDetail(right) {
const classes = _targets.classes || [];
const loose = _targets.looseStudents || [];
right.innerHTML = `
${esc(_selContent.title)}
${TYPE_BADGE[_selContent.type] || 'Контент'}
${classes.length ? `
` : ''}
${classes.length ? classes.map(classRowContent).join('') : 'Нет классов.
'}
${loose.length ? `
Отдельные ученики (без класса)
${loose.map(looseRow).join('')}
` : ''}
`;
}
async function showLog() {
const box = document.getElementById('acc-log');
if (!box || !_selContent) return;
box.innerHTML = 'Загрузка…
';
try {
const log = await LS.accessLog(_selContent.type, _selContent.ref);
if (!log.length) { box.innerHTML = 'Изменений пока нет.
'; return; }
const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' };
box.innerHTML = log.map(e => `
${esc(e.actor)} ${A[e.action] || esc(e.action)} · ${esc(e.targetName)}
· ${esc((e.at || '').replace('T', ' ').slice(0, 16))}
`).join('');
} catch (e) {
box.innerHTML = `${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}
`;
}
}
/* пересчёт бейджа для текущего контента по отображаемым классам */
function recountContent() {
if (!_selContent) return;
const open = (_targets.classes || []).filter(c => _rules.classRules[c.id] === 1).length;
_summary[bucket(_selContent.type)][_selContent.ref] = open;
}
async function setClass(classId, checked) {
const allow = checked ? 1 : null;
try {
await LS.accessSetRule(_selContent.type, _selContent.ref, 'class', classId, allow);
if (allow === 1) _rules.classRules[classId] = 1; else delete _rules.classRules[classId];
recountContent(); renderLeft(); renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
}
async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
const classes = _targets.classes || [];
try {
await Promise.all(classes.map(c =>
LS.accessSetRule(_selContent.type, _selContent.ref, 'class', c.id, allow ? 1 : null)));
for (const c of classes) { if (allow) _rules.classRules[c.id] = 1; else delete _rules.classRules[c.id]; }
recountContent(); renderLeft(); renderRight();
LS.toast(allow ? 'Открыто всем классам' : 'Закрыто у всех классов', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selContent(_selContent.type, _selContent.ref); }
}
async function setStudent(uid, allow) {
if (allow === 'null') allow = null;
try {
await LS.accessSetRule(_selContent.type, _selContent.ref, 'student', uid, allow);
if (allow === 1) _rules.studentRules[uid] = 1;
else if (allow === 0) _rules.studentRules[uid] = 0;
else delete _rules.studentRules[uid];
renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
}
function toggleExpand(classId) {
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
renderRight();
}
/* ════════ режим «По классу» ════════ */
async function selClass(id) {
const c = (_targets.classes || []).find(x => x.id === id);
_selClass = { id, name: c ? c.name : ('#' + id) };
renderLeft();
const right = document.getElementById('acc-right');
right.innerHTML = 'Загрузка…
';
try {
const open = await LS.accessClassOpen(id);
_classOpen = {};
CONTENT_TYPES.forEach(t => { _classOpen[bucket(t)] = new Set(open[bucket(t)] || []); });
renderRight();
} catch (e) { right.innerHTML = `Ошибка: ${esc(e.message)}
`; }
}
function classContentRow(type, it) {
const ref = it[keyName(type)];
const open = _classOpen[bucket(type)].has(ref);
return `
${esc(it.title)}
${TYPE_BADGE[type] || 'Контент'}
`;
}
function renderClassDetail(right) {
let html = `
Класс «${esc(_selClass.name)}»
`;
const subjects = [...new Set(CONTENT_TYPES.flatMap(t => itemsOf(t).map(subjOf)).filter(Boolean))].sort();
if (subjects.length) {
html += `
Открыть по предмету:
${subjects.map(s => ``).join('')}
`;
}
const others = (_targets.classes || []).filter(c => c.id !== _selClass.id);
if (others.length) {
html += `
Скопировать доступ из класса:
`;
}
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type);
html += `${TYPE_LABEL[type]}
`;
html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет');
});
right.innerHTML = html;
}
function bumpSummary(type, ref, delta) {
const b = _summary[bucket(type)];
const cur = b[ref] || 0;
b[ref] = Math.max(0, Math.min(_summary.totalClasses || 0, cur + delta));
}
async function classToggle(type, ref, checked) {
try {
await LS.accessSetRule(type, ref, 'class', _selClass.id, checked ? 1 : null);
const set = _classOpen[bucket(type)];
const was = set.has(ref);
if (checked) set.add(ref); else set.delete(ref);
if (checked && !was) bumpSummary(type, ref, +1);
if (!checked && was) bumpSummary(type, ref, -1);
renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
/* открыть классу весь контент одного предмета (любого типа) */
async function classSubjectBulk(subj) {
const items = CONTENT_TYPES.flatMap(t => itemsOf(t).filter(it => subjOf(it) === subj).map(it => [t, it[keyName(t)]]));
if (!items.length) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
renderRight();
LS.toast(`Открыт весь контент по предмету «${subjLabel(subj)}»`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
/* пресет: скопировать открытый доступ из другого класса в текущий (дополняет) */
async function copyFrom() {
const sel = document.getElementById('acc-copy-src');
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
try {
const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
if (!items.length) { LS.toast('В классе-источнике нет открытого контента', 'error'); return; }
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
renderRight();
LS.toast(`Скопировано из «${srcName}» (${items.length})`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try {
await Promise.all(all.map(([type, ref]) =>
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
for (const [type, ref] of all) {
const set = _classOpen[bucket(type)];
const was = set.has(ref);
if (allow) { set.add(ref); if (!was) bumpSummary(type, ref, +1); }
else { set.delete(ref); if (was) bumpSummary(type, ref, -1); }
}
renderRight();
LS.toast(allow ? 'Открыт весь контент классу' : 'Закрыт весь контент', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
/* ════════ режим «Матрица» (класс × контент одним экраном) ════════ */
function matrixHeadCells(classes) {
return classes.map(c =>
`
| `).join('');
}
function matrixBody() {
const classes = _matrix.classes || [];
const term = _mSearch.trim().toLowerCase();
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
const section = (type, items) => {
const rows = (items || []).filter(match).map(it => {
const ref = it[keyName(type)];
const cells = classes.map(c => {
const open = ((_matrix.open[c.id] || {})[type] || []).includes(ref);
return `
| `;
}).join('');
return `|
| ${cells}
`;
}).join('');
if (!rows) return '';
return `| ${TYPE_LABEL[type] || type} |
${rows}`;
};
const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join('');
return body || `| ${empty('Ничего не найдено')} |
`;
}
async function renderMatrix() {
const root = document.getElementById('acc-matrix');
if (!root) return;
if (!_matrix) {
root.innerHTML = 'Загрузка…
';
try { _matrix = await LS.accessMatrix(); }
catch (e) { root.innerHTML = `Ошибка: ${esc(e.message)}
`; return; }
}
const classes = _matrix.classes || [];
if (!classes.length) { root.innerHTML = empty('Нет классов'); return; }
root.innerHTML = `
отметьте, какой класс видит контент
| ${matrixHeadCells(classes)}
${matrixBody()}
`;
}
async function mxToggle(type, ref, classId, checked) {
try {
await LS.accessSetRule(type, ref, 'class', classId, checked ? 1 : null);
const o = _matrix.open[classId] || (_matrix.open[classId] = { textbook: [], exam: [] });
const arr = o[type] || (o[type] = []);
const i = arr.indexOf(ref);
if (checked && i < 0) arr.push(ref);
if (!checked && i >= 0) arr.splice(i, 1);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
function mxSearch(v) { _mSearch = v; const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
function mxRepaint() { const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
function mxApply(o, type, ref, open) {
const arr = o[type] || (o[type] = []);
const i = arr.indexOf(ref);
if (open && i < 0) arr.push(ref);
if (!open && i >= 0) arr.splice(i, 1);
}
/* строка матрицы: открыть/закрыть один контент всем классам */
async function mxRowBulk(type, ref) {
const classes = _matrix.classes || [];
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
const open = !allOpen;
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
try {
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
mxRepaint();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
/* столбец матрицы: открыть/закрыть весь контент одному классу */
async function mxColBulk(classId) {
const items = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
const o = _matrix.open[classId] || (_matrix.open[classId] = {});
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
const open = !allOpen;
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
mxRepaint();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
/* ── режим ── */
function setMode(m) {
if (m === _mode) return;
_mode = m;
if (m === 'matrix') _matrix = null; // всегда свежая матрица
renderRoot();
}
window.accMode = setMode;
window.accSelContent = selContent;
window.accSetClass = setClass;
window.accBulk = bulk;
window.accSetStudent = setStudent;
window.accToggleExpand = toggleExpand;
window.accSelClass = selClass;
window.accClassToggle = classToggle;
window.accClassBulk = classBulk;
window.accClassSubj = classSubjectBulk;
window.accCopyFrom = copyFrom;
window.accShowLog = showLog;
window.accMx = mxToggle;
window.accMxSearch = mxSearch;
window.accMxRowBulk = mxRowBulk;
window.accMxColBulk = mxColBulk;
window.accLeftSearch = leftSearch;
window.AdminSections = window.AdminSections || {};
window.AdminSections.access = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();