No inline JS/styles in templates: app.js delegation (data-action/data-modal/data-confirm/data-auto-submit), B3 confirm/alert dialogs, theme-init file

This commit is contained in:
2026-10-02 18:41:12 +03:00
parent 5a1985b142
commit cdf8586904
18 changed files with 382 additions and 209 deletions

View File

@@ -287,6 +287,8 @@ tr:last-child td { border-bottom: none; }
to { opacity: 1; transform: none; }
}
.confirm-text { margin: 0; font-size: 0.875rem; white-space: pre-line; overflow-wrap: anywhere; }
/* Modals */
dialog.modal {
border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 0;

View File

@@ -520,7 +520,10 @@ function openVariant(btn) {
fetch('/admin/labs/' + encodeURIComponent(lab) + '/schema')
.then(function (r) { return r.ok && !r.redirected ? r.json() : { levels: [] }; })
.catch(function () {
alert('Не удалось загрузить уровни и варианты (возможно, сессия истекла). Обновите страницу и откройте модалку заново.');
window.showAlert({
title: 'Не удалось открыть',
text: 'Не удалось загрузить уровни и варианты (возможно, сессия истекла). Обновите страницу и откройте модалку заново.'
});
return { levels: [] };
})
.then(function (data) {
@@ -637,14 +640,21 @@ window.openLabAssign = function (btn) {
window.confirmAssignVariant = function (sel) {
var opt = sel.selectedOptions[0];
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
var submit = function () {
sel.dataset.prev = sel.value;
sel.closest('form').requestSubmit();
};
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) {
sel.value = sel.dataset.prev;
return;
}
window.showConfirm({
text: 'Вариант уже занят (' + taken + '). Назначить всё равно?',
okText: 'Назначить'
}).then(function (ok) {
if (ok) submit();
else sel.value = sel.dataset.prev;
});
return;
}
sel.dataset.prev = sel.value;
sel.closest('form').requestSubmit();
submit();
};
/* Формы внутри модалки сохраняются через fetch — модалка не закрывается и обновляется */

View File

@@ -0,0 +1,247 @@
/* Общая логика интерфейса: тема, подсветка кода, модалки, подтверждения,
уведомления, скролл-лок при открытых диалогах, активный пункт меню.
Никаких инлайновых обработчиков в шаблонах — только классы и data-атрибуты. */
(function () {
'use strict';
/* Тема */
window.toggleTheme = function () {
var root = document.documentElement;
var t = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', t);
try { localStorage.setItem('lms-theme', t); } catch (e) { /* приватный режим */ }
window.__hljsSet(t);
};
/* Подсветка кода */
window.__hljsSet = function (theme) {
var l = document.getElementById('hljs-light'), d = document.getElementById('hljs-dark');
if (l && d) { l.disabled = theme !== 'light'; d.disabled = theme !== 'dark'; }
};
window.__hljsAll = function () {
if (!window.hljs) return;
document.querySelectorAll('pre code').forEach(function (el) { window.hljs.highlightElement(el); });
};
/* Модалки */
window.openModal = function (id) {
var d = document.getElementById(id);
if (d) d.showModal();
syncModalScrollLock();
};
window.closeModal = function (id) {
var d = document.getElementById(id);
if (d) d.close();
syncModalScrollLock();
};
/* Скролл-лок: пока открыт диалог, страница остаётся на месте */
var modalScrollY = 0;
function syncModalScrollLock() {
var root = document.documentElement;
if (document.querySelector('dialog[open]')) {
if (root.classList.contains('modal-open')) return;
modalScrollY = window.scrollY || window.pageYOffset || 0;
var sw = window.innerWidth - root.clientWidth;
root.style.setProperty('--modal-scrollbar', (sw > 0 ? sw : 0) + 'px');
root.style.setProperty('--modal-top', (-modalScrollY) + 'px');
root.classList.add('modal-open');
} else if (root.classList.contains('modal-open')) {
root.classList.remove('modal-open');
root.style.removeProperty('--modal-scrollbar');
root.style.removeProperty('--modal-top');
window.scrollTo(0, modalScrollY);
}
}
document.addEventListener('toggle', function (e) {
if (e.target && e.target.tagName === 'DIALOG') syncModalScrollLock();
}, true);
/* Секции материалов: раскрытие и запоминание состояния */
function sectionStateKey(id) { return 'lms-section-open:' + id; }
function applySectionState(acc) {
var id = acc.getAttribute('data-section-id');
var open = sessionStorage.getItem(sectionStateKey(id)) === '1';
var def = acc.getAttribute('data-default-open') === 'true';
acc.classList.toggle('collapsed', !open && !def);
var btn = acc.querySelector('.sub-toggle');
if (btn) btn.setAttribute('aria-expanded', String(!acc.classList.contains('collapsed')));
}
function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc');
if (!acc) return;
acc.classList.toggle('collapsed');
var collapsed = acc.classList.contains('collapsed');
btn.setAttribute('aria-expanded', String(!collapsed));
try {
sessionStorage.setItem(sectionStateKey(acc.getAttribute('data-section-id')), collapsed ? '0' : '1');
} catch (e) { /* приватный режим */ }
};
/* Активный пункт меню */
function initNavActive() {
var path = window.location.pathname;
document.querySelectorAll('#top-nav .nav-link').forEach(function (a) {
var href = a.getAttribute('href') || '';
if (path === href || (href !== '/' && path.indexOf(href + '/') === 0)) a.classList.add('active');
});
}
/* Серверные уведомления: автоскрытие и кнопка закрытия */
function initDataToasts() {
document.querySelectorAll('[data-toast]').forEach(function (toast) {
var timer = setTimeout(function () { toast.remove(); }, 30000);
var close = toast.querySelector('.toast-close');
if (close) close.addEventListener('click', function () { clearTimeout(timer); toast.remove(); });
});
}
document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll();
initNavActive();
initSections();
initDataToasts();
syncModalScrollLock();
});
/* ---- Делегирование: вместо onclick/onsubmit/onchange в разметке ---- */
/* Диалог подтверждения/уведомления — замена нативным alert и confirm */
var confirmResolve = null;
function confirmFinish(result) {
var r = confirmResolve;
confirmResolve = null;
if (r) r(result);
}
(function () {
var d = document.getElementById('modal-confirm');
if (!d) return;
var ok = d.querySelector('#confirm-ok');
var cancel = d.querySelector('#confirm-cancel');
ok.addEventListener('click', function () { confirmFinish(true); d.close(); });
cancel.addEventListener('click', function () { confirmFinish(false); d.close(); });
d.addEventListener('close', function () { confirmFinish(false); });
})();
window.showConfirm = function (opts) {
opts = opts || {};
var d = document.getElementById('modal-confirm');
if (!d) return Promise.resolve(window.confirm(opts.text || ''));
return new Promise(function (resolve) {
document.getElementById('confirm-title').textContent = opts.title || 'Подтверждение';
document.getElementById('confirm-text').textContent = opts.text || '';
var ok = document.getElementById('confirm-ok');
var cancel = document.getElementById('confirm-cancel');
ok.textContent = opts.okText || 'ОК';
ok.classList.toggle('btn-danger', !!opts.danger);
ok.classList.toggle('btn-primary', !opts.danger);
cancel.textContent = opts.cancelText || 'Отмена';
cancel.hidden = !!opts.hideCancel;
confirmResolve = resolve;
window.openModal('modal-confirm');
});
};
window.showAlert = function (opts) {
opts = opts || {};
return window.showConfirm({
title: opts.title || 'Внимание',
text: opts.text || '',
okText: 'Понятно',
hideCancel: true
});
};
/* Подтверждения: data-confirm на кнопке — спрашиваем при клике,
на форме — при сабмите (Enter или кнопка без data-confirm). */
document.addEventListener('click', function (e) {
var el = e.target.closest('[data-confirm]');
if (!el || el.tagName === 'FORM') return;
e.preventDefault();
e.stopImmediatePropagation();
var danger = el.classList.contains('btn-danger') || el.classList.contains('danger');
window.showConfirm({
text: el.getAttribute('data-confirm'),
danger: danger,
okText: danger ? 'Удалить' : 'Продолжить'
}).then(function (ok) {
if (!ok) return;
if (el.tagName === 'BUTTON' && el.form) el.form.requestSubmit(el);
});
}, true);
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f.matches('form[data-confirm]') || f.dataset.confirmed) return;
e.preventDefault();
var danger = f.classList.contains('btn-danger');
window.showConfirm({
text: f.getAttribute('data-confirm'),
danger: danger,
okText: danger ? 'Удалить' : 'Продолжить'
}).then(function (ok) {
if (!ok) return;
f.dataset.confirmed = '1';
f.requestSubmit();
});
}, true);
/* Клик-действия */
document.addEventListener('click', function (e) {
var el = e.target;
var close = el.closest('.modal-close');
if (close) {
var dlg = close.closest('dialog');
if (dlg) dlg.close();
syncModalScrollLock();
return;
}
var toast = el.closest('.toast-close');
if (toast) {
var item = toast.closest('.toast-item');
if (item) item.remove();
return;
}
var themeBtn = el.closest('.theme-btn');
if (themeBtn) { window.toggleTheme(); return; }
var toggle = el.closest('.sub-toggle');
if (toggle) { window.toggleSection(toggle); return; }
var opener = el.closest('[data-modal]');
if (opener) { window.openModal(opener.getAttribute('data-modal')); return; }
var action = el.closest('[data-action]');
if (!action) return;
var kind = action.getAttribute('data-action');
var fn = {
grade: window.openGradeEditor,
variant: window.openVariant,
defense: window.openDefense,
assign: window.openLabAssign,
'article-edit': window.startArticleEdit,
'article-edit-cancel': window.cancelArticleEdit
}[kind];
if (typeof fn === 'function') fn(action);
else if (kind === 'comment-add' && typeof window.csRow === 'function') window.csRow('', 'neutral');
});
/* Изменение полей: автоотправка форм (data-auto-submit),
подтверждение занятия занятого варианта (select.js-variant-select) */
document.addEventListener('change', function (e) {
var t = e.target;
if (t.matches('.js-variant-select') && typeof window.confirmAssignVariant === 'function') {
window.confirmAssignVariant(t);
return;
}
if (t.matches('[data-auto-submit]')) {
var f = t.closest('form');
if (f) f.requestSubmit();
}
});
})();

View File

@@ -103,13 +103,20 @@
};
window.cancelArticleEdit = function () {
if (dirty && !confirm('Выйти из редактирования без сохранения?')) return;
var view = document.getElementById('article-view');
var form = document.getElementById('article-edit');
if (!view || !form) return;
form.hidden = true;
view.hidden = false;
dirty = false;
var proceed = function () {
var view = document.getElementById('article-view');
var form = document.getElementById('article-edit');
if (!view || !form) return;
form.hidden = true;
view.hidden = false;
dirty = false;
};
if (!dirty) { proceed(); return; }
window.showConfirm({
title: 'Несохранённые изменения',
text: 'Выйти из редактирования без сохранения?',
okText: 'Выйти'
}).then(function (ok) { if (ok) proceed(); });
};
document.addEventListener('input', function (event) {

View File

@@ -0,0 +1,6 @@
/* Тема применяется до отрисовки, чтобы не было вспышки светлой темы. */
(function () {
var t;
try { t = localStorage.getItem('lms-theme') || 'light'; } catch (e) { t = 'light'; }
document.documentElement.setAttribute('data-theme', t);
})();