Learn UI: prev/next navigation, sidebar outline, editing on every page; merge import; defense date

- article page: ← / → navigation across the whole material tree (including
  between sections), sticky sidebar outline with the current article highlighted
- editing on chapter, section and article pages: rename, publish/hide, delete,
  create section/article, move articles while deleting a section; forms return
  to the page they were opened from (validated internal `back` parameter)
- article page opens the edit dialog directly via ?edit=1 (used by "Править"
  links on section/chapter pages)
- import: optional sync flag — with it stale sections/articles of archived
  chapters are deleted (previous behaviour), without it import only adds and
  updates, so material edited in the UI is not overwritten
- student area: lab page and lab cards now show the defense date next to the
  submission deadline
- fix SpEL crash on the student lab page (`and` with nullable strings)
This commit is contained in:
2026-09-17 17:14:16 +03:00
parent 6246d4c8d3
commit df5020fea6
15 changed files with 533 additions and 61 deletions

View File

@@ -3,21 +3,127 @@
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
<body>
<div id="content">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<h1 class="page-title">
<span th:text="${article.title}">Статья</span>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
<div class="learn-layout">
<div class="learn-main">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<h1 class="page-title">
<span th:text="${article.title}">Статья</span>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-edit-article')">Править</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}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть от студентов' : 'Опубликовать'">Скрыть</button>
</form>
<a class="btn btn-secondary btn-sm"
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('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить статью</button>
</form>
</div>
<nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary btn-sm"
th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}"
th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-secondary btn-sm ml-auto"
th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}"
th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
<nav class="article-nav mt-6" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-primary ml-auto"
th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
</div>
<aside class="learn-side">
<div class="card card-body">
<p class="text-sm text-muted mb-2">Содержание</p>
<div th:each="c : ${tree.chapters}">
<div class="side-chapter">
<a th:href="@{/learn/{c}(c=${c.slug})}" th:text="${c.title}">Глава</a>
<span th:if="${!c.published}" class="badge badge-warn">скрыто</span>
</div>
<div th:each="s : ${c.sections}" class="side-section">
<a th:href="@{/learn/{c}/{s}(c=${c.slug}, s=${s.slug})}" th:text="${s.title}">Раздел</a>
<span th:if="${!s.published}" class="badge badge-warn">скрыто</span>
<ul class="side-articles">
<li th:each="a : ${s.articles}">
<a th:href="@{/learn/{c}/{s}/{a2}(c=${c.slug}, s=${s.slug}, a2=${a.slug})}"
th:text="${a.title}"
th:classappend="${a.slug == article.slug and s.slug == article.sectionSlug and c.slug == article.chapterSlug} ? 'side-current'">Статья</a>
<span th:if="${!a.published}" class="badge badge-warn">скрыто</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</div>
<dialog id="modal-edit-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка статьи</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-edit-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${article.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="artTitle">Заголовок</label>
<input class="input" id="artTitle" name="title" th:value="${article.title}" required></div>
<div><label class="label" for="artBody">Текст (markdown)</label>
<textarea class="input" id="artBody" name="bodyMd" rows="18" th:text="${article.bodyMd}"></textarea></div>
<p class="hint">
Картинка с подписью: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>.
Видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>.
Решение под спойлером: <code class="code">:::spoiler Решение … :::</code>.
</p>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<script th:if="${currentUser != null and currentUser.admin}">
if (window.location.search.indexOf('edit=1') >= 0) {
document.addEventListener('DOMContentLoaded', function () { openModal('modal-edit-article'); });
}
</script>
</div>
</body>
</html>
</html>

View File

@@ -8,29 +8,160 @@
<span class="nav-sep">/</span>
<span th:text="${chapter.title}">Глава</span>
</nav>
<h1 class="page-title">
<span th:text="${chapter.title}">Глава</span>
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-section')">+ Раздел</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-chapter')">Переименовать главу</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}">
<input type="hidden" name="published" th:value="${!chapter.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть</button>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button>
</form>
</div>
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
В этой главе пока нет разделов.
</div>
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
<h2 class="group-title">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
</h2>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="group-title m-0">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</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}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</div>
</div>
<ul class="list-pad text-sm">
<li th:each="article : ${section.articles}">
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<th:block th:if="${currentUser != null and currentUser.admin}">
<a class="btn btn-secondary btn-sm"
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/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
<th:block th:if="${currentUser != null and currentUser.admin}">
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${section.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
<div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
</form>
</div>
</dialog>
</th:block>
</div>
<dialog id="modal-rename-chapter" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-chapter')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="chTitle">Название</label>
<input class="input" id="chTitle" name="title" th:value="${chapter.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog id="modal-new-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый раздел в главе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="chapterId" th:value="${chapter.id}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="secNewTitle">Название раздела</label>
<input class="input" id="secNewTitle" name="title" required></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
</div>
</body>
</html>

View File

@@ -198,6 +198,14 @@
</p>
<div><label class="label" for="file">Архив</label>
<input class="input" id="file" name="file" type="file" accept=".zip" required></div>
<label class="tone-label">
<input type="checkbox" name="sync" value="true">
Удалять разделы и статьи, которых нет в архиве (синхронизация)
</label>
<p class="hint">
Без этой галочки импорт <b>только добавляет и обновляет</b> материалы из архива —
статьи, отредактированные прямо в приложении, не затрагиваются.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>

View File

@@ -10,13 +10,36 @@
<span class="nav-sep">/</span>
<span th:text="${section.title}">Раздел</span>
</nav>
<h1 class="page-title">
<span th:text="${section.title}">Раздел</span>
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-section')">Переименовать раздел</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}">
<input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть</button>
</form>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-move-section')">Перенести статьи и удалить</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button>
</form>
</div>
<div class="card card-flush">
<table>
<thead><tr><th>Статья</th></tr></thead>
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<td>
@@ -24,11 +47,91 @@
th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td>
<td class="text-right actions-cell">
<a class="btn btn-secondary btn-sm"
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/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td colspan="2" class="empty">Статей пока нет.</td></tr>
</tbody>
</table>
</div>
<dialog id="modal-rename-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="secTitle">Название</label>
<input class="input" id="secTitle" name="title" th:value="${section.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog id="modal-move-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи и удалить раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-move-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<p class="text-sm text-muted">Выберите раздел-приёмник: его статьи перенесутся туда, а текущий раздел удалится.</p>
<div><label class="label" for="moveTo">Перенести в раздел</label>
<select class="input" id="moveTo" name="moveTo">
<option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
</form>
</div>
</dialog>
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая статья в разделе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="sectionId" th:value="${section.id}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="naTitle">Заголовок</label>
<input class="input" id="naTitle" name="title" required></div>
<div><label class="label" for="naBody">Текст (markdown)</label>
<textarea class="input" id="naBody" name="bodyMd" rows="14"></textarea></div>
<p class="hint">
Картинка: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>;
видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>;
спойлер: <code class="code">:::spoiler Решение … :::</code>.
</p>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
</div>
</body>
</html>
</html>