diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 48c8b06..3605a3c 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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; diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index e61e656..e613098 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -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 — модалка не закрывается и обновляется */ diff --git a/web/src/main/resources/static/js/app.js b/web/src/main/resources/static/js/app.js new file mode 100644 index 0000000..ba9ccd7 --- /dev/null +++ b/web/src/main/resources/static/js/app.js @@ -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(); + } + }); +})(); diff --git a/web/src/main/resources/static/js/mdeditor.js b/web/src/main/resources/static/js/mdeditor.js index 31065f1..970a28a 100644 --- a/web/src/main/resources/static/js/mdeditor.js +++ b/web/src/main/resources/static/js/mdeditor.js @@ -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) { diff --git a/web/src/main/resources/static/js/theme-init.js b/web/src/main/resources/static/js/theme-init.js new file mode 100644 index 0000000..12a590a --- /dev/null +++ b/web/src/main/resources/static/js/theme-init.js @@ -0,0 +1,6 @@ +/* Тема применяется до отрисовки, чтобы не было вспышки светлой темы. */ +(function () { + var t; + try { t = localStorage.getItem('lms-theme') || 'light'; } catch (e) { t = 'light'; } + document.documentElement.setAttribute('data-theme', t); +})(); diff --git a/web/src/main/resources/templates/admin/assign-fragment.html b/web/src/main/resources/templates/admin/assign-fragment.html index 1c693fc..6665ace 100644 --- a/web/src/main/resources/templates/admin/assign-fragment.html +++ b/web/src/main/resources/templates/admin/assign-fragment.html @@ -33,8 +33,7 @@ th:attr="data-refresh=${refreshUrl}"> - + Уровень @@ -46,7 +45,7 @@ th:attr="data-refresh=${refreshUrl}"> - + — авто — Скачать работы Скачать пароли - + Студент + + Студент - + все лабораторные — @@ -47,8 +47,7 @@ — @@ -89,16 +88,14 @@ В-1 выдать — @@ -113,8 +110,7 @@ не сдано защита: - + Сбросить + method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы."> Отчислить @@ -157,7 +151,7 @@ Оценка и комментарии - ✕ + ✕ @@ -170,7 +164,7 @@ - + комментарий + + комментарий Сохранить @@ -182,7 +176,7 @@ Назначить вариант - ✕ + ✕ — @@ -218,7 +212,7 @@ Индивидуальное задание - ✕ + ✕ @@ -247,8 +241,7 @@ Выдать код - + @@ -267,7 +260,7 @@ Добавить студента - ✕ + ✕ Выдача вариантов - ✕ + ✕ diff --git a/web/src/main/resources/templates/admin/groups.html b/web/src/main/resources/templates/admin/groups.html index 2963649..fa89eba 100644 --- a/web/src/main/resources/templates/admin/groups.html +++ b/web/src/main/resources/templates/admin/groups.html @@ -8,8 +8,8 @@ - Импорт - + Новая группа + Импорт + + Новая группа @@ -30,7 +30,7 @@ Новая группа - ✕ + ✕ @@ -45,7 +45,7 @@ Импорт студентов - ✕ + ✕ diff --git a/web/src/main/resources/templates/admin/lab-edit.html b/web/src/main/resources/templates/admin/lab-edit.html index fba3f98..c95e29a 100644 --- a/web/src/main/resources/templates/admin/lab-edit.html +++ b/web/src/main/resources/templates/admin/lab-edit.html @@ -46,8 +46,7 @@ + th:value="|delete-level-${level.id}|" data-confirm="Удалить уровень? Несохранённые правки остальных полей формы будут потеряны. Варианты на уровне должны быть пустыми."> удалить @@ -82,8 +81,7 @@ Условие (markdown) Удалить вариант + th:value="|delete-variant-${v.id}|" data-confirm="Удалить вариант? Несохранённые правки остальных полей формы будут потеряны.">Удалить вариант diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 059ea09..cc0c110 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -11,8 +11,7 @@ Статус: draft - + Опубликовать diff --git a/web/src/main/resources/templates/admin/labs.html b/web/src/main/resources/templates/admin/labs.html index 9d76d04..654bd7c 100644 --- a/web/src/main/resources/templates/admin/labs.html +++ b/web/src/main/resources/templates/admin/labs.html @@ -8,8 +8,8 @@ - Импорт - + Создать + Импорт + + Создать @@ -27,8 +27,7 @@ th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">— - + — - Удалить + Удалить Лабораторных пока нет. @@ -49,7 +47,7 @@ Импорт лабораторной - ✕ + ✕ @@ -72,7 +70,7 @@ Новая лабораторная - ✕ + ✕ diff --git a/web/src/main/resources/templates/admin/users.html b/web/src/main/resources/templates/admin/users.html index e8ce7f3..f84d046 100644 --- a/web/src/main/resources/templates/admin/users.html +++ b/web/src/main/resources/templates/admin/users.html @@ -30,8 +30,7 @@ — —без группы - + Удалить diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 038d295..e911410 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -21,86 +21,11 @@ + + - @@ -122,8 +47,8 @@ — — - Войти - Войти + @@ -141,7 +66,7 @@ - Выйти + Выйти @@ -151,39 +76,38 @@ … - ✕ + ✕ … - ✕ + ✕ Новый пароль для Иванов И.: password - ✕ + ✕ - + + + Подтверждение + ✕ + + + — + + Отмена + ОК + + + -
—
password