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:
@@ -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 — модалка не закрывается и обновляется */
|
||||
|
||||
247
web/src/main/resources/static/js/app.js
Normal file
247
web/src/main/resources/static/js/app.js
Normal 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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
6
web/src/main/resources/static/js/theme-init.js
Normal file
6
web/src/main/resources/static/js/theme-init.js
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user