feat: submit all modal forms without page reload

- add a generic AJAX submitter for forms inside dialogs
- keep modal values and show field errors on server validation failure
- soft-refresh the page content on success, preserving scroll, search and filters
- share toast and re-init hooks for tables, markdown, sections and group page
- require passwords and add minlength/match validation on auth forms
This commit is contained in:
2026-10-23 01:22:05 +03:00
parent 2e43f79256
commit e22443b08e
11 changed files with 351 additions and 59 deletions

View File

@@ -208,43 +208,7 @@ function attachCommentAutocomplete(input) {
}); });
} }
/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */ /* ---------- Удаление подсказки комментария ---------- */
function showToast(text, kind) {
// уведомления должны быть видны поверх открытой модалки: кладём их в сам <dialog>
// (элемент верхнего слоя), иначе они оказываются под ним
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) { function removeCommentTemplate(text, done) {
var form = $id('grade-form'); var form = $id('grade-form');
@@ -345,6 +309,8 @@ function updateEyeCounts() {
var DEFENSE_STATE = {}; var DEFENSE_STATE = {};
var DEFENSE_POLL_MS = 10000; var DEFENSE_POLL_MS = 10000;
var DEFENSE_TICK_MS = 1000; var DEFENSE_TICK_MS = 1000;
var DEFENSE_POLL_TIMER = null;
var DEFENSE_TICK_TIMER = null;
function defenseBtn(sessionId) { function defenseBtn(sessionId) {
if (!sessionId) return null; if (!sessionId) return null;
@@ -422,6 +388,14 @@ function syncDefenseStatuses(sessions, initial) {
} }
function startDefenseWatch() { 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'); var meta = $id('defense-meta');
if (!meta) return; if (!meta) return;
var labs = meta.getAttribute('data-labs') || ''; var labs = meta.getAttribute('data-labs') || '';
@@ -442,8 +416,8 @@ function startDefenseWatch() {
} }
poll(); poll();
setInterval(poll, DEFENSE_POLL_MS); DEFENSE_POLL_TIMER = setInterval(poll, DEFENSE_POLL_MS);
setInterval(function () { DEFENSE_TICK_TIMER = setInterval(function () {
Object.keys(DEFENSE_STATE).forEach(function (id) { Object.keys(DEFENSE_STATE).forEach(function (id) {
var s = DEFENSE_STATE[id]; var s = DEFENSE_STATE[id];
if (s.status === 'started') applyDefenseToCell(s); if (s.status === 'started') applyDefenseToCell(s);
@@ -706,11 +680,13 @@ document.addEventListener('submit', function (ev) {
submitGradeForm(ev.target); submitGradeForm(ev.target);
}, true); }, true);
window.addEventListener('DOMContentLoaded', function () { function initGroupPage() {
focusLab(); focusLab();
startDefenseWatch(); startDefenseWatch();
attachPublishToggles(); attachPublishToggles();
}); }
window.__lmsInitGroupPage = initGroupPage;
window.addEventListener('DOMContentLoaded', initGroupPage);
/* ---------- Большая модалка «Выдача вариантов» ---------- */ /* ---------- Большая модалка «Выдача вариантов» ---------- */

View File

@@ -75,6 +75,7 @@
function initSections() { function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState); document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
} }
window.__lmsInitSections = initSections;
window.toggleSection = function (btn) { window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc'); var acc = btn.closest('.sub-acc');
if (!acc) return; 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 () { document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll(); window.__hljsAll();

View File

@@ -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) { function validationMessage(el) {
var v = el.validity || {}; var v = el.validity || {};
if (v.valueMissing) { if (v.valueMissing) {
@@ -268,8 +276,8 @@
} }
if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат'; if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат';
if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат'; if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат';
if (v.tooShort) return 'Минимум ' + el.minLength + ' символов'; if (v.tooShort) return 'Минимум ' + el.minLength + ' ' + symbolWord(el.minLength);
if (v.tooLong) return 'Максимум ' + el.maxLength + ' символов'; if (v.tooLong) return 'Максимум ' + el.maxLength + ' ' + symbolWord(el.maxLength);
if (v.rangeUnderflow) return 'Значение не меньше ' + el.min; if (v.rangeUnderflow) return 'Значение не меньше ' + el.min;
if (v.rangeOverflow) return 'Значение не больше ' + el.max; if (v.rangeOverflow) return 'Значение не больше ' + el.max;
if (v.stepMismatch) return 'Недопустимое значение'; 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-отправки форм: показать ответ сервера без перезагрузки страницы. // Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
window.lmsApplyFormFeedback = applyFeedback; window.lmsApplyFormFeedback = applyFeedback;
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-match]').forEach(checkMatch);
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку // после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0); setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
}); });

View File

@@ -70,6 +70,7 @@
function ready() { function ready() {
document.querySelectorAll('.md-body pre').forEach(enhance); document.querySelectorAll('.md-body pre').forEach(enhance);
} }
window.__lmsInitMdContent = ready;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready); document.addEventListener('DOMContentLoaded', ready);

View File

@@ -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);
})();

View File

@@ -14,6 +14,7 @@
function ready() { function ready() {
document.querySelectorAll('table').forEach(wrap); document.querySelectorAll('table').forEach(wrap);
} }
window.__lmsInitTableScroll = ready;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready); document.addEventListener('DOMContentLoaded', ready);

View File

@@ -192,8 +192,15 @@
}); });
} }
document.addEventListener('DOMContentLoaded', function () { function init() {
document.querySelectorAll('table.tablekit').forEach(bind); document.querySelectorAll('table.tablekit').forEach(bind);
document.querySelectorAll('input.table-search').forEach(bindSearch); document.querySelectorAll('input.table-search').forEach(bindSearch);
}); }
window.__lmsInitTablekit = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();

View File

@@ -278,7 +278,8 @@
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p> <p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p>
<button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button> <button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
</form> </form>
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post"> <form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post"
data-success="Студент создан. Пароль показан ниже.">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label> <div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label>
@@ -346,7 +347,7 @@
<div class="dlg-body" id="assign-modal-body"></div> <div class="dlg-body" id="assign-modal-body"></div>
</dialog> </dialog>
<script th:src="@{/js/admin-group.js(v='38')}" defer></script> <script th:src="@{/js/admin-group.js(v='39')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -14,8 +14,8 @@
</p> </p>
<form class="card form-card form-stack" th:action="@{/change-password}" method="post"> <form class="card form-card form-stack" th:action="@{/change-password}" method="post">
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" autocomplete="current-password" required></div> <div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" autocomplete="current-password" required></div>
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" autocomplete="new-password" minlength="8" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" autocomplete="new-password" required></div> <div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" autocomplete="new-password" minlength="8" data-match="#new" data-error-match="Пароли не совпадают" required></div>
<button class="btn btn-primary" type="submit">Сменить пароль</button> <button class="btn btn-primary" type="submit">Сменить пароль</button>
<a th:if="${currentUser != null and !currentUser.mustChangePassword}" <a th:if="${currentUser != null and !currentUser.mustChangePassword}"
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a> class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>

View File

@@ -21,14 +21,15 @@
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}"> <link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled> <link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script> <script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='36')}" defer></script> <script th:src="@{/js/table-scroll.js(v='38')}" defer></script>
<script th:src="@{/js/tablekit.js(v='36')}" defer></script> <script th:src="@{/js/tablekit.js(v='38')}" defer></script>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script> <script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/theme-init.js(v='36')}"></script> <script th:src="@{/js/theme-init.js(v='36')}"></script>
<script th:src="@{/js/app.js(v='36')}" defer></script> <script th:src="@{/js/app.js(v='38')}" defer></script>
<script th:src="@{/js/ui-state.js(v='36')}" defer></script> <script th:src="@{/js/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='38')}" defer></script> <script th:src="@{/js/form-feedback.js(v='39')}" defer></script>
<script th:src="@{/js/md-content.js(v='36')}" defer></script> <script th:src="@{/js/modal-ajax.js(v='1')}" defer></script>
<script th:src="@{/js/md-content.js(v='38')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script> <script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
</head> </head>
<body> <body>

View File

@@ -7,9 +7,9 @@
<h1 class="page-title">Создание администратора</h1> <h1 class="page-title">Создание администратора</h1>
<p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p> <p class="text-sm text-muted mb-4">Это разовая настройка. Аккаунт преподавателя создаётся один раз.</p>
<form class="card form-card form-stack" th:action="@{/setup}" method="post"> <form class="card form-card form-stack" th:action="@{/setup}" method="post">
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" autofocus required></div> <div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" autocomplete="username" th:value="${login}" minlength="3" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="new-password" required><p class="hint">Минимум 8 символов.</p></div> <div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" autocomplete="new-password" minlength="8" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" autocomplete="new-password" required></div> <div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" autocomplete="new-password" minlength="8" data-match="#password" data-error-match="Пароли не совпадают" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button> <button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> </form>