- 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.
249 lines
18 KiB
HTML
249 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||
<head><title>Материалы — LMS</title></head>
|
||
<body>
|
||
<div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='full'">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
|
||
<span id="sort-status-text">Изменения не сохранены…</span>
|
||
</div>
|
||
|
||
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
|
||
Материалы ещё не добавлены.
|
||
</div>
|
||
|
||
<div th:each="chapter, chStat : ${tree?.chapters}" class="card card-body chapter-card mb-4"
|
||
th:attr="data-chapter-id=${chapter.id}">
|
||
<div class="sort-row">
|
||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||
th:attr="data-drag-kind='chapter',data-drag-id=${chapter.id}"
|
||
title="Перетащите мышью: изменить порядок глав">⠿</span>
|
||
<h2 class="section-title sort-title m-0" th:text="${chapter.title}">Глава</h2>
|
||
<span th:if="${!chapter.published}" class="badge badge-warn">скрыто</span>
|
||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||
<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}">
|
||
<button class="btn" type="submit"
|
||
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
|
||
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
|
||
</form>
|
||
<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" type="submit">Удалить главу</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div data-section-list>
|
||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted mt-2 mb-0">Разделов пока нет</p>
|
||
|
||
<div th:each="section : ${chapter.sections}" class="sub-acc collapsed"
|
||
th:attr="data-section-id=${section.id},data-default-open=${chStat.last}">
|
||
<div class="sub-head sort-row">
|
||
<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="Перетащите мышью: изменить порядок или перенести раздел">⠿</span>
|
||
<button type="button" class="sub-toggle" aria-expanded="false" onclick="toggleSection(this)"
|
||
title="Свернуть или развернуть раздел">
|
||
<span class="sub-arrow" aria-hidden="true"></span>
|
||
</button>
|
||
<a class="group-title sort-title m-0"
|
||
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>
|
||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||
<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}">
|
||
<button class="btn" type="submit"
|
||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
|
||
</form>
|
||
<button class="btn btn-secondary" type="button"
|
||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</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" type="submit">Удалить раздел</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-body">
|
||
<ul class="sort-list text-sm" data-article-list>
|
||
<li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
|
||
title="Перетащите мышью: изменить порядок или перенести статью">⠿</span>
|
||
<a class="sort-title"
|
||
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">скрыто</span>
|
||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||
<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}">
|
||
<button class="btn" type="submit"
|
||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||
</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('Удалить статью?')">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<button class="btn btn-danger" type="submit">✕</button>
|
||
</form>
|
||
</div>
|
||
</li>
|
||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<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" aria-label="Закрыть"
|
||
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}">
|
||
<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" aria-label="Закрыть"
|
||
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}">
|
||
<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>
|
||
</div>
|
||
|
||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||
<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" aria-label="Закрыть"
|
||
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</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}">
|
||
<div><label class="label">Название</label>
|
||
<input class="input" name="title" th:value="${chapter.title}" required></div>
|
||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
</th:block>
|
||
</div>
|
||
|
||
<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>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<p class="text-sm text-muted">
|
||
Структура: <code class="code">chapters/01-глава/01-раздел/01-статья.md</code>, картинки в <code class="code">assets/</code>.
|
||
</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>
|
||
</dialog>
|
||
|
||
<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>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<div><label class="label" for="chTitle">Название</label>
|
||
<input class="input" id="chTitle" name="title" required></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" 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">
|
||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||
<div><label class="label" for="naChapter">Существующая глава и раздел</label>
|
||
<select class="input" id="naChapter" name="sectionId">
|
||
<option th:each="pair : ${sectionChoices}"
|
||
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
||
</select></div>
|
||
<div><label class="label" for="naNewSection">…или новый раздел в выбранной главе</label>
|
||
<input class="input" id="naNewSection" name="newSectionTitle" placeholder="Новый раздел"></div>
|
||
<div><label class="label" for="naTitle">Заголовок статьи</label>
|
||
<input class="input" id="naTitle" name="title" required></div>
|
||
<div><label class="label" for="naBody">Текст (markdown)</label>
|
||
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
|
||
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
||
<button class="btn btn-primary" type="submit">Создать</button>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<script th:src="@{/js/mdeditor.js(v='9')}" defer></script>
|
||
<script th:src="@{/js/content-sort.js(v='9')}" defer></script>
|
||
</div>
|
||
</body>
|
||
</html>
|