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 aee1672058
commit 6cb2e558ca
18 changed files with 382 additions and 209 deletions

View File

@@ -21,86 +21,11 @@
<script th:src="@{/js/highlight.min.js(v='10')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='10')}" defer></script>
<script th:src="@{/js/tablekit.js(v='10')}" defer></script>
<script th:src="@{/js/theme-init.js(v='10')}"></script>
<script th:src="@{/js/app.js(v='10')}" defer></script>
<script th:src="@{/js/ui-state.js(v='10')}" defer></script>
<script th:src="@{/js/md-content.js(v='10')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='10')}" defer></script>
<script>
(function(){
var t = localStorage.getItem('lms-theme') || 'light';
document.documentElement.setAttribute('data-theme', 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); });
};
})();
document.addEventListener('DOMContentLoaded', function(){ window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); syncModalScrollLock(); initNavActive(); initSections(); });
/* Активный пункт меню — как в макете: бордовый цвет у текущего раздела. */
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 toggleTheme(){
var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark';
e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); window.__hljsSet(t);
}
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); syncModalScrollLock(); }
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); syncModalScrollLock(); }
function openModalData(btn){ openModal(btn.dataset.modal); }
function closeModalData(btn){ closeModal(btn.dataset.modal); }
/* Пока открыта хоть одна модалка — блокируем прокрутку страницы, сохраняя позицию скролла
(внутри .modal-body прокрутка остаётся). */
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 stored = null;
try { stored = id ? localStorage.getItem(sectionStateKey(id)) : null; } catch (e) { /* приватный режим */ }
var open = stored === '1' ? true
: stored === '0' ? false
: acc.getAttribute('data-default-open') === 'true';
acc.classList.toggle('collapsed', !open);
var btn = acc.querySelector('.sub-toggle');
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function initSections(){
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
function toggleSection(btn){
var acc = btn.closest('.sub-acc');
if (!acc) return;
var collapsed = acc.classList.toggle('collapsed');
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
var id = acc.getAttribute('data-section-id');
if (id) { try { localStorage.setItem(sectionStateKey(id), collapsed ? '0' : '1'); } catch (e) { /* приватный режим */ } }
}
document.addEventListener('DOMContentLoaded', initSections);
</script>
</head>
<body>
<div class="page">
@@ -122,8 +47,8 @@
<a th:href="@{/admin}" th:if="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
<a th:href="@{/student}" th:unless="${currentUser.admin}" class="nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
</th:block>
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary btn-sm">Войти</a>
<button type="button" onclick="toggleTheme()" class="btn btn-secondary btn-sm theme-btn"
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn">Войти</a>
<button type="button" class="btn btn-secondary theme-btn"
aria-label="Сменить тему" title="Сменить тему">
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/>
@@ -141,7 +66,7 @@
</svg>
</button>
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
<button class="btn btn-secondary btn-sm" type="submit">Выйти</button>
<button class="btn" type="submit">Выйти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
@@ -151,39 +76,38 @@
<th:block th:replace="${content}" />
</main>
<footer class="page-footer">
LMS v0.2.0
LMS v1.1.0
</footer>
</div>
<div class="toast-host" id="toast-host" aria-live="polite">
<div th:if="${notice}" class="toast-item toast-success" data-toast>
<span class="toast-text" th:text="${notice}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div th:if="${error}" class="toast-item toast-error" data-toast>
<span class="toast-text" th:text="${error}">…</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div th:if="${resetPassword}" class="toast-item toast-warning" data-toast>
<span class="toast-text">
Новый пароль<span th:if="${resetFor}" th:text="| для ${resetFor}|"> для Иванов И.</span>:
<code class="code font-mono" th:text="${resetPassword}">password</code>
</span>
<button class="toast-close" type="button" aria-label="Закрыть" onclick="this.parentElement.remove()">✕</button>
<button class="toast-close" type="button" aria-label="Закрыть">✕</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
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();
});
}
});
});
</script>
<dialog id="modal-confirm" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="confirm-title">Подтверждение</h2>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<p class="confirm-text" id="confirm-text">—</p>
<div class="flex justify-end gap-2 mt-4">
<button class="btn btn-secondary" type="button" id="confirm-cancel">Отмена</button>
<button class="btn btn-primary" type="button" id="confirm-ok">ОК</button>
</div>
</div>
</dialog>
</body>
</html>
</html>