Redesign B3 bordeaux + finish lab-page rework

- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
This commit is contained in:
2026-10-02 15:41:16 +03:00
parent 7a781fea33
commit 556fdf3b5e
67 changed files with 3019 additions and 653 deletions

View File

@@ -46,34 +46,34 @@
</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="startArticleEdit()">Править</button>
<button class="btn btn-primary" type="button" onclick="startArticleEdit()">Править</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"
<button class="btn" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть от студентов' : 'Опубликовать'">Скрыть</button>
</form>
<a class="btn btn-secondary btn-sm"
<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('Удалить статью?')">
<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>
<button class="btn btn-danger" 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"
<a th:if="${prevArticle != null}" class="btn btn-secondary"
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"
<a th:if="${nextArticle != null}" class="btn btn-secondary 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> →
@@ -90,8 +90,8 @@
<div class="md-editor-head">
<span class="md-editor-title">Редактирование статьи</span>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="cancelArticleEdit()">Отмена</button>
<button class="btn btn-primary btn-sm" type="submit">Сохранить</button>
<button class="btn btn-secondary" type="button" onclick="cancelArticleEdit()">Отмена</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</div>
<div class="card-body form-stack">
@@ -126,7 +126,7 @@
</div>
<script src="/js/mdeditor.js" defer></script>
<script th:src="@{/js/mdeditor.js(v='9')}" defer></script>
</div>
</body>
</html>

View File

@@ -15,14 +15,14 @@
</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>
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-section')">+ Раздел</button>
<button class="btn btn-secondary" 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"
<button class="btn" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть</button>
</form>
@@ -30,7 +30,7 @@
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>
<button class="btn btn-danger" type="submit">Удалить главу</button>
</form>
</div>
@@ -62,24 +62,24 @@
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="btn btn-secondary btn-sm" type="button"
<button class="btn btn-secondary" 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"
<button class="btn" 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"
<button class="btn btn-secondary" 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>
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</div>
</div>
@@ -95,14 +95,14 @@
th:text="${article.title}">Статья</a>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
<a class="btn btn-secondary btn-sm"
<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/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"
<button class="btn" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
@@ -116,7 +116,7 @@
<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"
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
@@ -132,7 +132,7 @@
<dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
@@ -140,6 +140,9 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
<p th:if="${#lists.size(sectionChoices) < 2}" class="text-sm text-muted">
<b>Других разделов нет</b> — при подтверждении статьи будут удалены вместе с разделом.
</p>
<div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}"
@@ -157,7 +160,7 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" 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">
@@ -173,7 +176,7 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">
@@ -187,7 +190,7 @@
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/content-sort.js(v='9')}" defer></script>
</div>
</body>
</html>

View File

@@ -11,11 +11,6 @@
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
</div>
</div>
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
и видны только вам. Перетаскивайте за значок ⠿ слева, чтобы менять порядок глав, разделов
и статей, а также переносить разделы в другую главу и статьи — в другой раздел.
</div>
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
<span id="sort-status-text">Изменения не сохранены…</span>
</div>
@@ -37,16 +32,16 @@
<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}">
<button class="btn btn-sm" type="submit"
<button class="btn" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
<button class="btn btn-secondary" type="button"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</button>
<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}">
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button>
<button class="btn btn-danger" type="submit">Удалить главу</button>
</form>
</div>
</div>
@@ -73,18 +68,18 @@
<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}">
<button class="btn btn-sm" type="submit"
<button class="btn" 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"
<button class="btn btn-secondary" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<button class="btn btn-secondary btn-sm" type="button"
<button class="btn btn-secondary" 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}">
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button>
<button class="btn btn-danger" type="submit">Удалить раздел</button>
</form>
</div>
</div>
@@ -104,16 +99,16 @@
<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}">
<button class="btn btn-sm" type="submit"
<button class="btn" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
<a class="btn btn-secondary btn-sm"
<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('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">✕</button>
<button class="btn btn-danger" type="submit">✕</button>
</form>
</div>
</li>
@@ -125,7 +120,7 @@
<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"
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
@@ -140,7 +135,7 @@
<dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
@@ -150,6 +145,7 @@
<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>
@@ -164,7 +160,7 @@
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button"
<button class="modal-close" type="button" aria-label="Закрыть"
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
@@ -182,7 +178,7 @@
<dialog id="modal-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт материалов (zip)</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
@@ -208,7 +204,7 @@
<dialog id="modal-new-chapter" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая глава</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-chapter')">✕</button>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-chapter')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
@@ -223,7 +219,7 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -245,8 +241,8 @@
</div>
</dialog>
<script src="/js/mdeditor.js" defer></script>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/mdeditor.js(v='9')}" defer></script>
<script th:src="@{/js/content-sort.js(v='9')}" defer></script>
</div>
</body>
</html>

View File

@@ -17,23 +17,23 @@
</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>
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary" 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"
<button class="btn" 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>
<button class="btn btn-secondary" 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>
<button class="btn btn-danger" type="submit">Удалить раздел</button>
</form>
</div>
@@ -58,14 +58,14 @@
<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"
<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/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"
<button class="btn" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
@@ -73,7 +73,7 @@
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>
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</td>
</tr>
@@ -85,7 +85,7 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" 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">
@@ -101,13 +101,16 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" 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>
<p th:if="${#lists.size(sectionChoices) < 2}" class="text-sm text-muted">
<b>Других разделов нет</b> — при подтверждении статьи будут удалены вместе с разделом.
</p>
<div><label class="label" for="moveTo">Перенести в раздел</label>
<select class="input" id="moveTo" name="moveTo">
<option th:each="pair : ${sectionChoices}"
@@ -122,7 +125,7 @@
<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>
<button class="modal-close" type="button" aria-label="Закрыть" onclick="closeModal('modal-new-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
@@ -144,7 +147,7 @@
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
<script th:src="@{/js/content-sort.js(v='9')}" defer></script>
</div>
</body>
</html>