diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 33f2eae..3cc06c0 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -208,43 +208,7 @@ function attachCommentAutocomplete(input) { }); } -/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */ - -function showToast(text, kind) { - // уведомления должны быть видны поверх открытой модалки: кладём их в сам - // (элемент верхнего слоя), иначе они оказываются под ним - var dlg = document.querySelector('dialog[open]'); - var host = null; - if (dlg) { - host = dlg.querySelector('.toast-host'); - if (!host) { - host = document.createElement('div'); - host.className = 'toast-host'; - dlg.appendChild(host); - } - } else { - host = $id('toast-host'); - } - if (!host) return; - var toast = document.createElement('div'); - toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success'); - toast.setAttribute('data-toast', ''); - - var span = document.createElement('span'); - span.className = 'toast-text'; - span.textContent = text; - toast.appendChild(span); - - var close = document.createElement('button'); - close.type = 'button'; - close.className = 'toast-close'; - close.textContent = '✕'; - close.addEventListener('click', function () { toast.remove(); }); - toast.appendChild(close); - - host.appendChild(toast); - setTimeout(function () { toast.remove(); }, 5000); -} +/* ---------- Удаление подсказки комментария ---------- */ function removeCommentTemplate(text, done) { var form = $id('grade-form'); @@ -345,6 +309,8 @@ function updateEyeCounts() { var DEFENSE_STATE = {}; var DEFENSE_POLL_MS = 10000; var DEFENSE_TICK_MS = 1000; +var DEFENSE_POLL_TIMER = null; +var DEFENSE_TICK_TIMER = null; function defenseBtn(sessionId) { if (!sessionId) return null; @@ -422,6 +388,14 @@ function syncDefenseStatuses(sessions, initial) { } function startDefenseWatch() { + if (DEFENSE_POLL_TIMER) { + clearInterval(DEFENSE_POLL_TIMER); + DEFENSE_POLL_TIMER = null; + } + if (DEFENSE_TICK_TIMER) { + clearInterval(DEFENSE_TICK_TIMER); + DEFENSE_TICK_TIMER = null; + } var meta = $id('defense-meta'); if (!meta) return; var labs = meta.getAttribute('data-labs') || ''; @@ -442,8 +416,8 @@ function startDefenseWatch() { } poll(); - setInterval(poll, DEFENSE_POLL_MS); - setInterval(function () { + DEFENSE_POLL_TIMER = setInterval(poll, DEFENSE_POLL_MS); + DEFENSE_TICK_TIMER = setInterval(function () { Object.keys(DEFENSE_STATE).forEach(function (id) { var s = DEFENSE_STATE[id]; if (s.status === 'started') applyDefenseToCell(s); @@ -706,11 +680,13 @@ document.addEventListener('submit', function (ev) { submitGradeForm(ev.target); }, true); -window.addEventListener('DOMContentLoaded', function () { +function initGroupPage() { focusLab(); startDefenseWatch(); attachPublishToggles(); -}); +} +window.__lmsInitGroupPage = initGroupPage; +window.addEventListener('DOMContentLoaded', initGroupPage); /* ---------- Большая модалка «Выдача вариантов» ---------- */ diff --git a/web/src/main/resources/static/js/app.js b/web/src/main/resources/static/js/app.js index 1ead268..abd376c 100644 --- a/web/src/main/resources/static/js/app.js +++ b/web/src/main/resources/static/js/app.js @@ -75,6 +75,7 @@ function initSections() { document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState); } + window.__lmsInitSections = initSections; window.toggleSection = function (btn) { var acc = btn.closest('.sub-acc'); if (!acc) return; @@ -114,6 +115,39 @@ }); } + /* Программные уведомления: показываем поверх открытой модалки, если она есть. */ + window.showToast = function (text, kind) { + var dialog = document.querySelector('dialog[open]'); + var host = null; + if (dialog) { + host = dialog.querySelector('.toast-host'); + if (!host) { + host = document.createElement('div'); + host.className = 'toast-host'; + dialog.appendChild(host); + } + } else { + host = document.getElementById('toast-host'); + } + if (!host) return; + + var toast = document.createElement('div'); + toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success'); + toast.setAttribute('data-toast', ''); + var textEl = document.createElement('span'); + textEl.className = 'toast-text'; + textEl.textContent = text; + toast.appendChild(textEl); + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'toast-close'; + close.textContent = '✕'; + close.addEventListener('click', function () { toast.remove(); }); + toast.appendChild(close); + host.appendChild(toast); + setTimeout(function () { toast.remove(); }, 5000); + }; + document.addEventListener('DOMContentLoaded', function () { window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); diff --git a/web/src/main/resources/static/js/form-feedback.js b/web/src/main/resources/static/js/form-feedback.js index cc923fa..9051d17 100644 --- a/web/src/main/resources/static/js/form-feedback.js +++ b/web/src/main/resources/static/js/form-feedback.js @@ -256,6 +256,14 @@ } /* Встроенная валидация: вместо браузерных пузырей — сообщение под полем. */ + function symbolWord(count) { + var last = count % 10; + var lastTwo = count % 100; + if (last === 1 && lastTwo !== 11) return 'символ'; + if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'символа'; + return 'символов'; + } + function validationMessage(el) { var v = el.validity || {}; if (v.valueMissing) { @@ -268,8 +276,8 @@ } if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат'; if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат'; - if (v.tooShort) return 'Минимум ' + el.minLength + ' символов'; - if (v.tooLong) return 'Максимум ' + el.maxLength + ' символов'; + if (v.tooShort) return 'Минимум ' + el.minLength + ' ' + symbolWord(el.minLength); + if (v.tooLong) return 'Максимум ' + el.maxLength + ' ' + symbolWord(el.maxLength); if (v.rangeUnderflow) return 'Значение не меньше ' + el.min; if (v.rangeOverflow) return 'Значение не больше ' + el.max; if (v.stepMismatch) return 'Недопустимое значение'; @@ -295,13 +303,49 @@ } } - document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true); - document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true); + /* Проверка совпадения значений (например, повтор пароля). */ + function checkMatch(el) { + var selector = el.getAttribute('data-match'); + if (!selector) return; + var other = null; + try { + other = document.querySelector(selector); + } catch (e) { + return; + } + if (!other) return; + var message = el.getAttribute('data-error-match') || 'Значения не совпадают'; + if (el.value && other.value && el.value !== other.value) { + el.setCustomValidity(message); + } else { + el.setCustomValidity(''); + } + } + + function recheckMatches(source) { + document.querySelectorAll('[data-match]').forEach(function (el) { + try { + if (source.matches(el.getAttribute('data-match'))) checkMatch(el); + } catch (e) { /* некорректный селектор в разметке — пропускаем */ } + }); + } + + document.addEventListener('input', function (event) { + checkMatch(event.target); + recheckMatches(event.target); + clearOnEdit(event.target); + }, true); + document.addEventListener('change', function (event) { + checkMatch(event.target); + recheckMatches(event.target); + clearOnEdit(event.target); + }, true); // Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы. window.lmsApplyFormFeedback = applyFeedback; document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('[data-match]').forEach(checkMatch); // после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку setTimeout(function () { applyFeedback(parseFeedback()); }, 0); }); diff --git a/web/src/main/resources/static/js/md-content.js b/web/src/main/resources/static/js/md-content.js index 59773f0..681cfc0 100644 --- a/web/src/main/resources/static/js/md-content.js +++ b/web/src/main/resources/static/js/md-content.js @@ -70,6 +70,7 @@ function ready() { document.querySelectorAll('.md-body pre').forEach(enhance); } + window.__lmsInitMdContent = ready; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', ready); diff --git a/web/src/main/resources/static/js/modal-ajax.js b/web/src/main/resources/static/js/modal-ajax.js new file mode 100644 index 0000000..c862489 --- /dev/null +++ b/web/src/main/resources/static/js/modal-ajax.js @@ -0,0 +1,227 @@ +/* Формы внутри модалок: отправка через fetch без перезагрузки страницы. + Ошибка показывается в самой форме, успех — закрывает модалку и мягко обновляет + содержимое страницы (позиция прокрутки и фильтры сохраняются). */ +(function () { + 'use strict'; + + function hasEditableControl(form) { + for (var i = 0; i < form.elements.length; i++) { + var el = form.elements[i]; + if (el.disabled) continue; + if (el.tagName === 'BUTTON' || el.type === 'hidden' || el.type === 'submit' || + el.type === 'button' || el.type === 'reset' || el.type === 'image') continue; + return true; + } + return false; + } + + function isModalForm(form) { + if (!form || !form.closest('dialog')) return false; + if (form.classList.contains('js-assign-form')) return false; + if (form.id === 'grade-form') return false; // у оценки своя быстрая AJAX-логика + if (form.hasAttribute('data-no-ajax')) return false; + if ((form.getAttribute('method') || 'post').toLowerCase() !== 'post') return false; + return hasEditableControl(form); + } + + function currentScrollY() { + if (typeof window.__pageScrollY === 'number') return window.__pageScrollY; + return window.scrollY || window.pageYOffset || 0; + } + + function closeFormDialog(form) { + var dialog = form.closest('dialog'); + if (dialog) { + if (window.closeModal && dialog.id) { + window.closeModal(dialog.id); + } else { + dialog.close(); + } + } + // На всякий случай закрываем и остальные диалоги, чтобы не остался scroll-lock. + document.querySelectorAll('dialog[open]').forEach(function (open) { + if (open === dialog) return; + if (window.closeModal && open.id) window.closeModal(open.id); + else open.close(); + }); + } + + function captureUiState() { + var searches = []; + document.querySelectorAll('input.table-search').forEach(function (input, index) { + searches.push({ index: index, value: input.value }); + }); + var filters = []; + document.querySelectorAll('table.tablekit').forEach(function (table, index) { + var values = []; + table.querySelectorAll('.filter-control').forEach(function (control) { + values.push(control.value); + }); + filters.push({ index: index, values: values }); + }); + var labFocus = document.getElementById('lab-focus'); + return { + searches: searches, + filters: filters, + labFocus: labFocus ? labFocus.value : null + }; + } + + function restoreUiState(state) { + if (state.labFocus != null) { + var focus = document.getElementById('lab-focus'); + if (focus) focus.value = state.labFocus; + } + + var searches = document.querySelectorAll('input.table-search'); + state.searches.forEach(function (item) { + var input = searches[item.index]; + if (!input) return; + input.value = item.value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + var tables = document.querySelectorAll('table.tablekit'); + state.filters.forEach(function (item) { + var table = tables[item.index]; + if (!table) return; + var controls = table.querySelectorAll('.filter-control'); + item.values.forEach(function (value, index) { + var control = controls[index]; + if (!control) return; + control.value = value; + control.dispatchEvent(new Event(control.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })); + }); + }); + } + + function initContentModules() { + if (window.__lmsInitTableScroll) window.__lmsInitTableScroll(); + if (window.__lmsInitTablekit) window.__lmsInitTablekit(); + if (window.__lmsInitMdContent) window.__lmsInitMdContent(); + if (window.__lmsInitSections) window.__lmsInitSections(); + if (window.__hljsAll) window.__hljsAll(); + } + + function initPageSpecific() { + if (window.__lmsInitGroupPage) window.__lmsInitGroupPage(); + if (window.startArticleEdit && location.search.indexOf('edit=1') >= 0) { + window.startArticleEdit(); + } + } + + function applyServerError(form, doc, feedbackNode) { + var feedback = null; + if (feedbackNode) { + try { + feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null'); + } catch (e) { + feedback = null; + } + } + if (feedback && typeof window.lmsApplyFormFeedback === 'function') { + window.lmsApplyFormFeedback(feedback); + } + var dialog = form.closest('dialog'); + if (dialog) { + var oldToasts = dialog.querySelector('.toast-host'); + if (oldToasts) oldToasts.remove(); + } + var toast = doc.querySelector('.toast-item.toast-error .toast-text'); + window.showToast( + toast + ? toast.textContent.trim() + : ((feedback && feedback.general) || 'Не удалось сохранить изменения'), + 'error' + ); + } + + function refreshSamePage(form, doc, target, scrollY, successText) { + var main = document.querySelector('main'); + var nextMain = doc.querySelector('main'); + if (!main || !nextMain) { + closeFormDialog(form); + window.location.assign(target.href); + return; + } + + var uiState = captureUiState(); + closeFormDialog(form); + main.innerHTML = nextMain.innerHTML; + document.title = doc.title; + + if (location.pathname === target.pathname && location.search !== target.search) { + history.replaceState(history.state, '', target.href); + } + + initContentModules(); + restoreUiState(uiState); + initPageSpecific(); + window.scrollTo(0, scrollY); + requestAnimationFrame(function () { window.scrollTo(0, scrollY); }); + window.showToast(successText || 'Сохранено', 'success'); + } + + function send(form) { + if (form.dataset.ajaxSaving === '1') return; + form.dataset.ajaxSaving = '1'; + var buttons = form.querySelectorAll('button[type="submit"], input[type="submit"]'); + buttons.forEach(function (button) { button.disabled = true; }); + var scrollY = currentScrollY(); + + fetch(form.action, { + method: (form.getAttribute('method') || 'post').toUpperCase(), + body: new FormData(form), + headers: { 'X-Requested-With': 'fetch' } + }) + .then(function (response) { + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.text().then(function (html) { + return { html: html, url: response.url }; + }); + }) + .then(function (response) { + var doc = new DOMParser().parseFromString(response.html, 'text/html'); + var target = new URL(response.url, location.href); + var feedbackNode = doc.getElementById('form-feedback'); + + if (feedbackNode) { + applyServerError(form, doc, feedbackNode); + return; + } + + // Ошибка без формы (например, истекла сессия) или успех с переходом + // на другую страницу — обычная навигация. + if (target.pathname !== location.pathname) { + closeFormDialog(form); + window.location.assign(target.href); + return; + } + + var errorToast = doc.querySelector('.toast-item.toast-error .toast-text'); + if (errorToast) { + window.showToast(errorToast.textContent.trim(), 'error'); + return; + } + + var notice = doc.querySelector('.toast-item.toast-success .toast-text'); + var successText = notice ? notice.textContent.trim() : (form.getAttribute('data-success') || ''); + refreshSamePage(form, doc, target, scrollY, successText); + }) + .catch(function (error) { + window.showToast('Не удалось сохранить изменения: ' + error.message, 'error'); + }) + .finally(function () { + form.dataset.ajaxSaving = ''; + buttons.forEach(function (button) { button.disabled = false; }); + }); + } + + document.addEventListener('submit', function (event) { + var form = event.target; + if (!isModalForm(form)) return; + if (event.defaultPrevented) return; + event.preventDefault(); + send(form); + }, true); +})(); diff --git a/web/src/main/resources/static/js/table-scroll.js b/web/src/main/resources/static/js/table-scroll.js index 068c2f8..517f7c1 100644 --- a/web/src/main/resources/static/js/table-scroll.js +++ b/web/src/main/resources/static/js/table-scroll.js @@ -14,6 +14,7 @@ function ready() { document.querySelectorAll('table').forEach(wrap); } + window.__lmsInitTableScroll = ready; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', ready); diff --git a/web/src/main/resources/static/js/tablekit.js b/web/src/main/resources/static/js/tablekit.js index fbd5717..5a0f14c 100644 --- a/web/src/main/resources/static/js/tablekit.js +++ b/web/src/main/resources/static/js/tablekit.js @@ -192,8 +192,15 @@ }); } - document.addEventListener('DOMContentLoaded', function () { + function init() { document.querySelectorAll('table.tablekit').forEach(bind); document.querySelectorAll('input.table-search').forEach(bindSearch); - }); + } + window.__lmsInitTablekit = init; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } })(); diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 7906274..8584b2b 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -278,7 +278,8 @@

Если студент уже в другой группе — он будет переведён в эту.

-
+
@@ -346,7 +347,7 @@
- + diff --git a/web/src/main/resources/templates/change_password.html b/web/src/main/resources/templates/change_password.html index 3a096da..f0f9d84 100644 --- a/web/src/main/resources/templates/change_password.html +++ b/web/src/main/resources/templates/change_password.html @@ -14,8 +14,8 @@

-

Минимум 8 символов.

-
+

Минимум 8 символов.

+
Отмена diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 3f7230a..288d188 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -21,14 +21,15 @@ - - + + - + - - + + + diff --git a/web/src/main/resources/templates/setup.html b/web/src/main/resources/templates/setup.html index c4b130c..fcce2a3 100644 --- a/web/src/main/resources/templates/setup.html +++ b/web/src/main/resources/templates/setup.html @@ -7,9 +7,9 @@

Создание администратора

Это разовая настройка. Аккаунт преподавателя создаётся один раз.

-
-

Минимум 8 символов.

-
+
+

Минимум 8 символов.

+