'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 `
${studentsHtml}
`; } 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 && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) 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 (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) 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 && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) 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 && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) 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 && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) 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, }; })();