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 396221047c
commit 09721747d1
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>