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

@@ -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;

View File

@@ -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,15 +640,22 @@ window.openLabAssign = function (btn) {
window.confirmAssignVariant = function (sel) {
var opt = sel.selectedOptions[0];
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) {
sel.value = sel.dataset.prev;
return;
}
}
var submit = function () {
sel.dataset.prev = sel.value;
sel.closest('form').requestSubmit();
};
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
window.showConfirm({
text: 'Вариант уже занят (' + taken + '). Назначить всё равно?',
okText: 'Назначить'
}).then(function (ok) {
if (ok) submit();
else sel.value = sel.dataset.prev;
});
return;
}
submit();
};
/* Формы внутри модалки сохраняются через fetch — модалка не закрывается и обновляется */
document.addEventListener('submit', function (ev) {

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

View File

@@ -103,7 +103,7 @@
};
window.cancelArticleEdit = function () {
if (dirty && !confirm('Выйти из редактирования без сохранения?')) return;
var proceed = function () {
var view = document.getElementById('article-view');
var form = document.getElementById('article-edit');
if (!view || !form) return;
@@ -111,6 +111,13 @@
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) {
var id = event.target && event.target.id;

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

View File

@@ -33,8 +33,7 @@
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input" name="labLevelId" th:disabled="${#lists.isEmpty(lab.levels)}"
onchange="this.closest('form').requestSubmit()">
<select class="input" name="labLevelId" th:disabled="${#lists.isEmpty(lab.levels)}" data-auto-submit>
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
th:selected="${lv.id == row.levelId}" th:text="${lv.title}">Уровень</option>
</select>
@@ -46,7 +45,7 @@
th:attr="data-refresh=${refreshUrl}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<select class="input" name="variantId" onchange="confirmAssignVariant(this)">
<select class="input js-variant-select" name="variantId">
<option value="">— авто —</option>
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
<option th:each="v : ${lv.variants}" th:value="${v.id}"

View File

@@ -19,14 +19,14 @@
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/submissions.zip(id=${group.id})}"
title="ZIP со всеми последними решениями группы по всем лабораторным">Скачать работы</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input table-search w-260" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input" id="lab-focus" onchange="focusLab()">
<select class="input" id="lab-focus">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
@@ -47,8 +47,7 @@
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<button class="mini-btn" type="button"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|"
onclick="openLabAssign(this)"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<svg class="cell-ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@@ -89,16 +88,14 @@
<div class="cell-main">
<button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}"
onclick="openVariant(this)"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}" data-action="variant"
title="Назначить вариант вручную или выдать случайный">
<span th:if="${cell.variantNumber}" th:text="'В-' + ${cell.variantNumber}">В-1</span>
<span th:if="${cell.variantNumber == null}" class="cell-none">выдать</span>
</button>
<button class="score-link" type="button"
th:classappend="${cell.score == null} ? 'none' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"
onclick="openGradeEditor(this)"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}" data-action="grade"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Оценки нет — выставить балл и комментарии'">
<span th:text="${cell.score} ?: '—'">—</span>
</button>
@@ -113,8 +110,7 @@
<span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
<button class="cell-btn st js-defense-cell" type="button"
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode},data-reason=${cell.defenseReadyReason}"
onclick="openDefense(this)"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode},data-reason=${cell.defenseReadyReason}" data-action="defense"
th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
<span class="cell-dim">защита:</span>
<span class="js-def-label"
@@ -132,15 +128,13 @@
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post"
onsubmit="return confirm('Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.')">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form>
@@ -157,7 +151,7 @@
<dialog id="modal-grade" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-grade')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
@@ -170,7 +164,7 @@
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<div id="cs-rows"></div>
<div class="flex gap-2">
<button class="btn btn-secondary" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-secondary" type="button" data-action="comment-add">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
@@ -182,7 +176,7 @@
<dialog id="modal-variant" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title">Назначить вариант</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-variant')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body form-stack">
<p class="text-sm text-muted" id="variant-who">—</p>
@@ -218,7 +212,7 @@
<dialog id="modal-defense" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-defense')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
@@ -247,8 +241,7 @@
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post"
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
@@ -267,7 +260,7 @@
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-add-student')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="enroll-form" method="post"
@@ -346,7 +339,7 @@
<dialog id="modal-lab-assign" class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="assign-modal-title">Выдача вариантов</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-lab-assign')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body" id="assign-modal-body"></div>
</dialog>

View File

@@ -8,8 +8,8 @@
<div class="flex gap-2">
<input class="input table-search" data-target="groups-table"
placeholder="Поиск по названию…" aria-label="Поиск по группам">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-group')">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-group')">+ Новая группа</button>
<button class="btn btn-secondary" type="button" data-modal="modal-import-group">Импорт</button>
<button class="btn btn-primary" type="button" data-modal="modal-create-group">+ Новая группа</button>
</div>
</div>
@@ -30,7 +30,7 @@
<dialog id="modal-create-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая группа</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-create-group')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups}" method="post">
@@ -45,7 +45,7 @@
<dialog id="modal-import-group" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт студентов</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import-group')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">

View File

@@ -46,8 +46,7 @@
</td>
<td class="text-right">
<button class="btn btn-danger" type="submit" name="action"
th:value="|delete-level-${level.id}|"
onclick="return confirm('Удалить уровень? Несохранённые правки остальных полей формы будут потеряны. Варианты на уровне должны быть пустыми.')">
th:value="|delete-level-${level.id}|" data-confirm="Удалить уровень? Несохранённые правки остальных полей формы будут потеряны. Варианты на уровне должны быть пустыми.">
удалить</button>
</td>
</tr>
@@ -82,8 +81,7 @@
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="variantStatements" rows="6" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-danger" type="submit" name="action"
th:value="|delete-variant-${v.id}|"
onclick="return confirm('Удалить вариант? Несохранённые правки остальных полей формы будут потеряны.')">Удалить вариант</button>
th:value="|delete-variant-${v.id}|" data-confirm="Удалить вариант? Несохранённые правки остальных полей формы будут потеряны.">Удалить вариант</button>
</div>
</div>

View File

@@ -11,8 +11,7 @@
Статус: <span class="badge"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">draft</span>
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post"
onsubmit="return confirm('Сменить статус лабораторной? Опубликованную увидят студенты, черновик — перестанут.')">
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post" data-confirm="Сменить статус лабораторной? Опубликованную увидят студенты, черновик — перестанут.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary" type="submit"
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">Опубликовать</button>

View File

@@ -8,8 +8,8 @@
<div class="flex gap-2">
<input class="input table-search" data-target="labs-table"
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
<button class="btn btn-secondary" type="button" data-modal="modal-import-lab">Импорт</button>
<button class="btn btn-primary" type="button" data-modal="modal-create-lab">+ Создать</button>
</div>
</div>
@@ -27,8 +27,7 @@
th:text="${lab.status == 'published'} ? 'Опубликована' : (${lab.status == 'archived'} ? 'В архиве' : 'Черновик')">—</span>
</td>
<td class="text-center">
<form th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post"
onsubmit="return confirm('Сменить статус лабораторной? Опубликованную увидят студенты, черновик — перестанут.')">
<form th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post" data-confirm="Сменить статус лабораторной? Опубликованную увидят студенты, черновик — перестанут.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="@{/admin/labs}">
<button class="btn"
@@ -37,8 +36,7 @@
th:text="${lab.status == 'published'} ? 'Скрыть' : 'Опубликовать'">—</button>
</form>
</td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"
onsubmit="return confirm('Удалить лабораторную вместе с вариантами и назначениями? Действие необратимо.')"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger" type="submit">Удалить</button></form></td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post" data-confirm="Удалить лабораторную вместе с вариантами и назначениями? Действие необратимо."><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger" type="submit">Удалить</button></form></td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="6" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
@@ -49,7 +47,7 @@
<dialog id="modal-import-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт лабораторной</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import-lab')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
@@ -72,7 +70,7 @@
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-create-lab')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">

View File

@@ -30,8 +30,7 @@
<td class="font-mono" th:text="${u.studentNo}">—</td>
<td><a th:if="${u.groupId}" th:href="@{/admin/groups/{id}(id=${u.groupId})}" th:text="${u.groupName}">—</a><span th:unless="${u.groupId}" class="text-muted">без группы</span></td>
<td class="text-right">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post"
onsubmit="return confirm('Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.');">
<form class="inline-form" th:action="@{/admin/users/{id}/delete(id=${u.id})}" method="post" data-confirm="Удалить пользователя безвозвратно? Будут удалены аккаунт, группа, оценки с комментариями, сдачи с файлами и задания защит.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="q" th:value="${query}">
<button class="btn btn-danger" type="submit">Удалить</button>

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>

View File

@@ -46,7 +46,7 @@
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary" type="button" onclick="startArticleEdit()">Править</button>
<button class="btn btn-primary" type="button" data-action="article-edit">Править</button>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
@@ -57,8 +57,7 @@
</form>
<a class="btn btn-secondary"
th:href="@{/learn/{c}(c=${article.chapterSlug})}">К главе</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="mini-btn danger" type="submit" title="Удалить статью" aria-label="Удалить статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
@@ -89,7 +88,7 @@
<div class="md-editor-head">
<span class="md-editor-title">Редактирование статьи</span>
<div class="flex gap-2">
<button class="btn btn-secondary" type="button" onclick="cancelArticleEdit()">Отмена</button>
<button class="btn btn-secondary" type="button" data-action="article-edit-cancel">Отмена</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</div>

View File

@@ -15,8 +15,8 @@
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-section')">+ Раздел</button>
<button class="mini-btn" type="button" onclick="openModal('modal-rename-chapter')" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<button class="btn btn-primary" type="button" data-modal="modal-new-section">+ Раздел</button>
<button class="mini-btn" type="button" data-modal="modal-rename-chapter" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${chapter.id}">
@@ -25,8 +25,7 @@
<button class="mini-btn" type="submit" th:if="${chapter.published}" title="Скрыть главу" aria-label="Скрыть главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${chapter.published}" title="Опубликовать главу" aria-label="Опубликовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="mini-btn danger" type="submit" title="Удалить главу со всеми разделами и статьями" aria-label="Удалить главу со всеми разделами и статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
@@ -52,7 +51,7 @@
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок разделов"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)"
<button type="button" class="sub-toggle" aria-expanded="false"
title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span>
</button>
@@ -61,7 +60,7 @@
th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<button class="mini-btn" type="button" th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<button class="mini-btn" type="button" th:data-modal="|modal-rename-section-${section.id}|" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}">
@@ -70,9 +69,8 @@
<button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<button class="mini-btn" type="button" th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<button class="mini-btn" type="button" th:data-modal="|modal-move-${section.id}|" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел вместе со статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="mini-btn danger" type="submit" title="Удалить раздел вместе со статьями" aria-label="Удалить раздел вместе со статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
@@ -112,7 +110,7 @@
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
th:data-modal="|modal-rename-section-${section.id}|">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -128,7 +126,7 @@
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
th:data-modal="|modal-move-${section.id}|">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
@@ -155,7 +153,7 @@
<dialog id="modal-rename-chapter" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-rename-chapter')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
@@ -171,7 +169,7 @@
<dialog id="modal-new-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый раздел в главе</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-section')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">

View File

@@ -6,9 +6,9 @@
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import')">Импорт zip</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
<button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</button>
<button class="btn btn-secondary" type="button" data-modal="modal-import">Импорт zip</button>
<button class="btn btn-secondary" type="button" data-modal="modal-new-chapter">+ Глава</button>
</div>
</div>
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
@@ -35,9 +35,8 @@
<button class="mini-btn" type="submit" th:if="${chapter.published}" title="Скрыть главу" aria-label="Скрыть главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${chapter.published}" title="Опубликовать главу" aria-label="Опубликовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<button class="mini-btn" type="button" th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<button class="mini-btn" type="button" th:data-modal="|modal-rename-chapter-${chapter.id}|" title="Переименовать главу" aria-label="Переименовать главу"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post" data-confirm="Удалить главу со всеми разделами и статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="mini-btn danger" type="submit" title="Удалить главу со всеми разделами и статьями" aria-label="Удалить главу со всеми разделами и статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form>
@@ -53,7 +52,7 @@
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок или перенести раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></span>
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)"
<button type="button" class="sub-toggle" aria-expanded="false"
title="Свернуть или развернуть раздел">
<span class="sub-arrow" aria-hidden="true"></span>
</button>
@@ -69,10 +68,9 @@
<button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<button class="mini-btn" type="button" th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<button class="mini-btn" type="button" th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)" title="Перенести статьи в другой раздел" aria-label="Перенести статьи в другой раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
<button class="mini-btn" type="button" th:data-modal="|modal-rename-section-${section.id}|" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<button class="mini-btn" type="button" th:data-modal="|modal-move-${section.id}|" title="Перенести статьи в другой раздел" aria-label="Перенести статьи в другой раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел со всеми статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="mini-btn danger" type="submit" title="Удалить раздел со всеми статьями" aria-label="Удалить раздел со всеми статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form>
@@ -99,8 +97,7 @@
</form>
<a class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="mini-btn danger" type="submit" title="Удалить статью" aria-label="Удалить статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
</form>
@@ -115,7 +112,7 @@
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
th:data-modal="|modal-rename-section-${section.id}|">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -130,7 +127,7 @@
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
th:data-modal="|modal-move-${section.id}|">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
@@ -155,7 +152,7 @@
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</button>
th:data-modal="|modal-rename-chapter-${chapter.id}|">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
@@ -172,7 +169,7 @@
<dialog id="modal-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт материалов (zip)</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
@@ -198,7 +195,7 @@
<dialog id="modal-new-chapter" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая глава</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-chapter')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
@@ -213,7 +210,7 @@
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая статья</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-article')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">

View File

@@ -17,8 +17,8 @@
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="mini-btn" type="button" onclick="openModal('modal-rename-section')" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<button class="btn btn-primary" type="button" data-modal="modal-new-article">+ Статья</button>
<button class="mini-btn" type="button" data-modal="modal-rename-section" title="Переименовать раздел" aria-label="Переименовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${section.id}">
@@ -27,9 +27,8 @@
<button class="mini-btn" type="submit" th:if="${section.published}" title="Скрыть раздел" aria-label="Скрыть раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${section.published}" title="Опубликовать раздел" aria-label="Опубликовать раздел"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<button class="mini-btn" type="button" onclick="openModal('modal-move-section')" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<button class="mini-btn" type="button" data-modal="modal-move-section" title="Перенести статьи и удалить" aria-label="Перенести статьи и удалить"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg></button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post" data-confirm="Удалить раздел вместе со статьями?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="mini-btn danger" type="submit" title="Удалить раздел вместе со статьями" aria-label="Удалить раздел вместе со статьями"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
@@ -67,8 +66,7 @@
<button class="mini-btn" type="submit" th:if="${article.published}" title="Скрыть статью" aria-label="Скрыть статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="mini-btn" type="submit" th:unless="${article.published}" title="Опубликовать статью" aria-label="Опубликовать статью"><svg class="cell-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>
</form>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post" data-confirm="Удалить статью?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger" type="submit">Удалить</button>
@@ -83,7 +81,7 @@
<dialog id="modal-rename-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-rename-section')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
@@ -99,7 +97,7 @@
<dialog id="modal-move-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи и удалить раздел</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-move-section')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
@@ -123,7 +121,7 @@
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая статья в разделе</h2>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-article')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">

View File

@@ -8,10 +8,10 @@
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
<p class="text-sm text-muted mb-6">
<span th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</span>
<p class="text-muted mb-6">
<span th:if="${deadlineMsk}"><b>Срок сдачи кода:</b> <span th:text="${deadlineMsk}"></span> МСК</span>
<span th:if="${deadlineMsk != null and defenseMsk != null}" class="nav-sep">·</span>
<span th:if="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span>
<span th:if="${defenseMsk}"><b>Защита:</b> <span th:text="${deadlineMsk}"></span> МСК</span>
</p>
<h2 class="section-title">Условие лабораторной</h2>
@@ -98,8 +98,7 @@
<p class="text-sm defense-timer" th:attr="data-seconds=${defense.secondsLeft},data-lab=${lab.id}"></p>
<h3 class="group-title">Ваше индивидуальное задание</h3>
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post"
onsubmit="return confirm('Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.')">
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post" data-confirm="Завершить защиту? Продолжить её будет нельзя — дождитесь преподавателя.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
</form>