Learn: drag & drop sections and articles on chapter and section pages

This commit is contained in:
2026-09-17 19:15:56 +03:00
parent aa2b1638b2
commit a4ea358d27
10 changed files with 321 additions and 126 deletions

View File

@@ -100,6 +100,11 @@ printf("Ответ: 42\n");
* **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»; * **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»;
у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править». у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править».
Порядок меняется перетаскиванием за значок **⠿** (сохраняется сразу, без перезагрузки страницы):
на главной `/learn` — главы, разделы и статьи (в том числе перенос раздела в другую главу и
статьи в другой раздел), на **странице главы** — разделы этой главы и статьи внутри них,
на **странице раздела** — статьи этого раздела.
## Импорт: что происходит с материалами ## Импорт: что происходит с материалами
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). * Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи).

View File

@@ -73,6 +73,12 @@ data class ReorderSection(val id: String, val articles: List<ReorderArticle> = e
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList()) data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList()) data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList())
private data class ParsedArticle( private data class ParsedArticle(
val path: String, val path: String,
val chapterPos: Int, val chapterPos: Int,
@@ -184,6 +190,38 @@ class ContentService(
} }
} }
/** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */
@Transactional
fun reorderSections(chapterId: UUID, tree: List<ReorderSection>) {
tree.forEachIndexed { sectionIndex, sectionDto ->
val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (section.chapterId != chapterId) return@forEachIndexed
section.position = sectionIndex + 1
sections.save(section)
sectionDto.articles.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
article.sectionId = section.id
article.position = articleIndex + 1
articles.save(article)
}
}
}
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
@Transactional
fun reorderArticles(sectionId: UUID, tree: List<ReorderArticle>) {
tree.forEachIndexed { articleIndex, articleDto ->
val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null)
?: return@forEachIndexed
if (article.sectionId != sectionId) return@forEachIndexed
article.position = articleIndex + 1
articles.save(article)
}
}
private fun uuidOrNull(value: String): UUID? = private fun uuidOrNull(value: String): UUID? =
runCatching { UUID.fromString(value) }.getOrNull() runCatching { UUID.fromString(value) }.getOrNull()

View File

@@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentImportResult
import ru.ysu.lms.academic.content.ContentService import ru.ysu.lms.academic.content.ContentService
import ru.ysu.lms.academic.content.ReorderArticles
import ru.ysu.lms.academic.content.ReorderSections
import ru.ysu.lms.academic.content.ReorderTree import ru.ysu.lms.academic.content.ReorderTree
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@@ -78,6 +80,18 @@ class ContentAdminController(private val contentService: ContentService) {
contentService.reorder(body) contentService.reorder(body)
} }
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/sections/reorder")
fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) {
contentService.reorderSections(id, body.sections)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/articles/reorder")
fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) {
contentService.reorderArticles(id, body.articles)
}
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,

View File

@@ -14,7 +14,9 @@ import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.ArticlesReorderDto
import ru.ysu.lms.web.api.ContentReorderDto import ru.ysu.lms.web.api.ContentReorderDto
import ru.ysu.lms.web.api.SectionsReorderDto
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
@Controller @Controller
@@ -61,6 +63,30 @@ class ContentAdminController(
return mapOf("ok" to true) return mapOf("ok" to true)
} }
/** Порядок разделов внутри главы (drag & drop на странице главы). */
@PostMapping("/chapters/{id}/reorder-sections")
@ResponseBody
fun reorderSections(
@PathVariable id: String,
@RequestBody body: SectionsReorderDto,
session: HttpSession,
): Map<String, Any> {
api.reorderSections(token(session), id, body)
return mapOf("ok" to true)
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
@PostMapping("/sections/{id}/reorder-articles")
@ResponseBody
fun reorderArticles(
@PathVariable id: String,
@RequestBody body: ArticlesReorderDto,
session: HttpSession,
): Map<String, Any> {
api.reorderArticles(token(session), id, body)
return mapOf("ok" to true)
}
@PostMapping("/{kind}/toggle-published") @PostMapping("/{kind}/toggle-published")
fun togglePublished( fun togglePublished(
@PathVariable kind: String, @PathVariable kind: String,

View File

@@ -502,6 +502,30 @@ class ApiClient(
} }
} }
/** Порядок разделов внутри главы (drag & drop на странице главы). */
fun reorderSections(token: String, chapterId: String, body: SectionsReorderDto) {
call {
client.post()
.uri("/api/v1/content/chapters/{id}/sections/reorder", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve().toBodilessEntity()
}
}
/** Порядок статей внутри раздела (drag & drop на странице раздела). */
fun reorderArticles(token: String, sectionId: String, body: ArticlesReorderDto) {
call {
client.post()
.uri("/api/v1/content/sections/{id}/articles/reorder", sectionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve().toBodilessEntity()
}
}
fun importContent( fun importContent(
token: String, token: String,
filename: String, filename: String,

View File

@@ -360,6 +360,16 @@ data class ContentReorderArticleDto(
val id: String = "", val id: String = "",
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class SectionsReorderDto(
val sections: List<ContentReorderSectionDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ArticlesReorderDto(
val articles: List<ContentReorderArticleDto> = emptyList(),
)
data class ContentImportResultDto( data class ContentImportResultDto(
val chapters: Int = 0, val chapters: Int = 0,
val sections: Int = 0, val sections: Int = 0,

View File

@@ -1,6 +1,9 @@
/* Drag & drop для материалов: порядок и перенос между главами/разделами. /* Drag & drop для материалов: порядок и перенос между главами/разделами.
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором Работает в трёх режимах (атрибут data-sort-scope у #content):
состояния и кнопкой «Сохранить порядок» для повторной попытки. */ - full — страница «Материалы»: главы, разделы, статьи;
- chapter — страница главы: разделы внутри главы и статьи внутри них;
- section — страница раздела: статьи внутри раздела.
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором состояния. */
(function () { (function () {
var state = null; var state = null;
var marker = null; var marker = null;
@@ -10,12 +13,20 @@
var hoverTimer = null; var hoverTimer = null;
var hoverTarget = null; var hoverTarget = null;
function contentEl() { return document.getElementById('content'); }
function scope() { return contentEl() ? (contentEl().getAttribute('data-sort-scope') || 'full') : 'full'; }
function csrf() { return contentEl() ? (contentEl().getAttribute('data-csrf') || '') : ''; }
function itemSelector(kind) { function itemSelector(kind) {
if (kind === 'chapter') return '[data-chapter-id]'; if (kind === 'chapter') return '[data-chapter-id]';
if (kind === 'section') return '[data-section-id]'; if (kind === 'section') return '[data-section-id]';
return '[data-article-id]'; return '[data-article-id]';
} }
/* #content на страницах главы/раздела тоже помечен data-section-id / data-chapter-id,
поэтому его нельзя считать перетаскиваемым элементом. */
function isRealItem(element) { return element && element !== contentEl(); }
function statusBox() { return document.getElementById('sort-status'); } function statusBox() { return document.getElementById('sort-status'); }
function statusText() { return document.getElementById('sort-status-text'); } function statusText() { return document.getElementById('sort-status-text'); }
@@ -51,11 +62,16 @@
return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before'; return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before';
} }
function listFor(kind, container) {
if (!container) return null;
return container.querySelector(kind === 'article' ? '[data-article-list]' : '[data-section-list]');
}
function onDragStart(event) { function onDragStart(event) {
var handle = event.target.closest('.drag-handle'); var handle = event.target.closest('.drag-handle');
if (!handle) return; if (!handle) return;
var element = handle.closest(itemSelector(handle.dataset.dragKind)); var element = handle.closest(itemSelector(handle.dataset.dragKind));
if (!element) return; if (!isRealItem(element)) return;
state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element }; state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element };
element.classList.add('dragging'); element.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
@@ -75,23 +91,23 @@
var cls = null; var cls = null;
if (state.kind === 'article') { if (state.kind === 'article') {
if (article && !isDraggedOrInside(article)) { if (isRealItem(article) && !isDraggedOrInside(article)) {
target = article; target = article;
cls = placement(event, article); cls = placement(event, article);
} else if (section && !isDraggedOrInside(section)) { } else if (isRealItem(section) && !isDraggedOrInside(section)) {
var list = section.querySelector(':scope > [data-article-list]'); var list = listFor('article', section);
if (list) { target = list; cls = 'drop-into'; } if (list) { target = list; cls = 'drop-into'; }
} }
} else if (state.kind === 'section') { } else if (state.kind === 'section') {
if (section && section !== state.element && !isDraggedOrInside(section)) { if (isRealItem(section) && section !== state.element && !isDraggedOrInside(section)) {
target = section; target = section;
cls = placement(event, section); cls = placement(event, section);
} else if (chapter && !isDraggedOrInside(chapter)) { } else if (isRealItem(chapter) && !isDraggedOrInside(chapter)) {
var body = chapter.querySelector(':scope > .acc-body'); var sectionList = listFor('section', chapter);
if (body) { target = body; cls = 'drop-into'; } if (sectionList) { target = sectionList; cls = 'drop-into'; }
} }
} else if (state.kind === 'chapter') { } else if (state.kind === 'chapter') {
if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) { if (isRealItem(chapter) && chapter !== state.element && !isDraggedOrInside(chapter)) {
target = chapter; target = chapter;
cls = placement(event, chapter); cls = placement(event, chapter);
} }
@@ -147,26 +163,60 @@
clearMarker(); clearMarker();
} }
function collectTree() { function collectArticles(container) {
var content = document.getElementById('content'); var out = [];
var chapters = []; var list = container ? container.querySelector('[data-article-list]') : null;
content.querySelectorAll(':scope > details[data-chapter-id]').forEach(function (chapter) { if (list) {
var sections = []; list.querySelectorAll('[data-article-id]').forEach(function (item) {
chapter.querySelectorAll(':scope > .acc-body > details[data-section-id]').forEach(function (section) { out.push({ id: item.getAttribute('data-article-id') });
var articles = []; });
var list = section.querySelector(':scope > [data-article-list]'); }
if (list) { return out;
list.querySelectorAll(':scope > [data-article-id]').forEach(function (item) { }
articles.push({ id: item.getAttribute('data-article-id') });
}); function collectSections(container) {
} var out = [];
sections.push({ id: section.getAttribute('data-section-id'), articles: articles }); if (!container) return out;
container.querySelectorAll('[data-section-id]').forEach(function (section) {
if (!isRealItem(section)) return;
out.push({ id: section.getAttribute('data-section-id'), articles: collectArticles(section) });
});
return out;
}
function collectPayload() {
var content = contentEl();
if (!content) return { chapters: [] };
if (scope() === 'chapter') {
return { sections: collectSections(content.querySelector('[data-section-list]')) };
}
if (scope() === 'section') {
return { articles: collectArticles(content) };
}
var chapters = [];
content.querySelectorAll('[data-chapter-id]').forEach(function (chapter) {
if (!isRealItem(chapter)) return;
chapters.push({
id: chapter.getAttribute('data-chapter-id'),
sections: collectSections(chapter.querySelector('[data-section-list]'))
}); });
chapters.push({ id: chapter.getAttribute('data-chapter-id'), sections: sections });
}); });
return { chapters: chapters }; return { chapters: chapters };
} }
function saveUrl() {
var content = contentEl();
if (scope() === 'chapter' && content) {
return '/admin/content/chapters/' + encodeURIComponent(content.getAttribute('data-chapter-id')) + '/reorder-sections';
}
if (scope() === 'section' && content) {
return '/admin/content/sections/' + encodeURIComponent(content.getAttribute('data-section-id')) + '/reorder-articles';
}
return '/admin/content/reorder';
}
function markDirty() { function markDirty() {
pending = true; pending = true;
showStatus('Изменения не сохранены…'); showStatus('Изменения не сохранены…');
@@ -178,13 +228,11 @@
if (!pending || saving) return; if (!pending || saving) return;
saving = true; saving = true;
clearTimeout(timer); clearTimeout(timer);
var content = document.getElementById('content');
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
showStatus('Сохранение…'); showStatus('Сохранение…');
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), { fetch(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectTree()) body: JSON.stringify(collectPayload())
}).then(function (response) { }).then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status); if (!response.ok) throw new Error('HTTP ' + response.status);
pending = false; pending = false;
@@ -208,10 +256,8 @@
window.addEventListener('beforeunload', function () { window.addEventListener('beforeunload', function () {
if (!pending || saving) return; if (!pending || saving) return;
try { try {
var content = document.getElementById('content'); var blob = new Blob([JSON.stringify(collectPayload())], { type: 'application/json' });
var csrf = content ? (content.getAttribute('data-csrf') || '') : ''; navigator.sendBeacon(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), blob);
var blob = new Blob([JSON.stringify(collectTree())], { type: 'application/json' });
navigator.sendBeacon('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), blob);
} catch (e) { /* игнорируем */ } } catch (e) { /* игнорируем */ }
}); });
}); });

View File

@@ -2,7 +2,7 @@
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}"> <html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head> <head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head>
<body> <body>
<div id="content"> <div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='chapter',data-chapter-id=${chapter.id}">
<nav class="text-sm mb-4 breadcrumb"> <nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a> <a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span> <span class="nav-sep">/</span>
@@ -34,100 +34,118 @@
</form> </form>
</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>
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted"> <div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
В этой главе пока нет разделов. В этой главе пока нет разделов.
</div> </div>
<div th:each="section : ${chapter.sections}" class="card card-body mb-4"> <div data-section-list>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2"> <div th:each="section : ${chapter.sections}" class="card card-body mb-4"
<h2 class="group-title m-0"> th:attr="data-section-id=${section.id}">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a> <div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span> <h2 class="group-title m-0">
</h2> <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2"> th:attr="data-drag-kind='section',data-drag-id=${section.id}"
<button class="btn btn-secondary btn-sm" type="button" title="Перетащите, чтобы изменить порядок разделов">⠿</span>
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button> <a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post"> <span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> </h2>
<input type="hidden" name="id" th:value="${section.id}"> <div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<input type="hidden" name="published" th:value="${!section.published}"> <button class="btn btn-secondary btn-sm" type="button"
<input type="hidden" name="back" th:value="${back}"> th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<button class="btn btn-sm" type="submit" <form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
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}" 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">скрыто</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="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}"> <input type="hidden" name="id" th:value="${section.id}">
<input type="hidden" name="published" th:value="${!article.published}"> <input type="hidden" name="published" th:value="${!section.published}">
<input type="hidden" name="back" th:value="${back}"> <input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit" <button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button> th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form> </form>
</th:block> <button class="btn btn-secondary btn-sm" type="button"
</li> th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li> <form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
</ul> 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>
<th:block th:if="${currentUser != null and currentUser.admin}"> <ul class="list-pad text-sm" data-article-list>
<dialog th:id="|modal-rename-section-${section.id}|" class="modal"> <li th:each="article : ${section.articles}" class="flex items-center gap-2"
<div class="modal-header"> th:attr="data-article-id=${article.id}">
<h2 class="modal-title">Переименовать раздел</h2> <span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
<button class="modal-close" type="button" th:attr="data-drag-kind='article',data-drag-id=${article.id}"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button> title="Перетащите, чтобы изменить порядок или перенести в другой раздел">⠿</span>
</div> <a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
<div class="modal-body"> th:text="${article.title}">Статья</a>
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post"> <span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <th:block th:if="${currentUser != null and currentUser.admin}">
<input type="hidden" name="back" th:value="${back}"> <a class="btn btn-secondary btn-sm"
<div><label class="label">Название</label> th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<input class="input" name="title" th:value="${section.title}" required></div> <form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<button class="btn btn-primary" type="submit">Сохранить</button> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</form> <input type="hidden" name="id" th:value="${article.id}">
</div> <input type="hidden" name="published" th:value="${!article.published}">
</dialog> <input type="hidden" name="back" th:value="${back}">
<dialog th:id="|modal-move-${section.id}|" class="modal"> <button class="btn btn-sm" type="submit"
<div class="modal-header"> th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
<h2 class="modal-title">Перенести статьи из раздела</h2> th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
<button class="modal-close" type="button" </form>
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button> </th:block>
</div> </li>
<div class="modal-body"> <li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"> </ul>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}"> <th:block th:if="${currentUser != null and currentUser.admin}">
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p> <dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div><label class="label">Перенести в раздел</label> <div class="modal-header">
<select class="input" name="moveTo"> <h2 class="modal-title">Переименовать раздел</h2>
<option th:each="pair : ${sectionChoices}" <button class="modal-close" type="button"
th:if="${pair['id'] != section.id}" th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
th:value="${pair['id']}" th:text="${pair['label']}">—</option> </div>
</select></div> <div class="modal-body">
<button class="btn btn-primary" type="submit">Перенести и удалить</button> <form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
</form> <input type="hidden" name="_csrf" th:value="${csrfToken}">
</div> <input type="hidden" name="back" th:value="${back}">
</dialog> <div><label class="label">Название</label>
</th:block> <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>
</div> </div>
<dialog id="modal-rename-chapter" class="modal"> <dialog id="modal-rename-chapter" class="modal">
@@ -162,6 +180,8 @@
</form> </form>
</div> </div>
</dialog> </dialog>
<script src="/js/content-sort.js" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,7 @@
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}"> <html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Материалы — LMS</title></head> <head><title>Материалы — LMS</title></head>
<body> <body>
<div id="content" th:attr="data-csrf=${csrfToken}"> <div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='full'">
<div class="flex items-center justify-between gap-4 mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1> <h1 class="page-title">Учебные материалы</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2"> <div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
@@ -34,7 +34,7 @@
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2> <h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
<span class="acc-arrow" aria-hidden="true"></span> <span class="acc-arrow" aria-hidden="true"></span>
</summary> </summary>
<div class="card-body acc-body"> <div class="card-body acc-body" data-section-list>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4"> <div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<h2 class="section-title m-0"> <h2 class="section-title m-0">
<span th:text="${chapter.title}">Глава</span> <span th:text="${chapter.title}">Глава</span>

View File

@@ -2,7 +2,7 @@
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}"> <html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head> <head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
<body> <body>
<div id="content"> <div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='section',data-section-id=${section.id}">
<nav class="text-sm mb-4 breadcrumb"> <nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a> <a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span> <span class="nav-sep">/</span>
@@ -37,12 +37,22 @@
</form> </form>
</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>
<div class="card card-flush"> <div class="card card-flush">
<table> <table>
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead> <thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
<tbody> <tbody data-article-list>
<tr th:each="article : ${section.articles}"> <tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<td> <td>
<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 th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}" <a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a> 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 ml-1">скрыто</span>
@@ -125,7 +135,7 @@
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div> <div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div> <textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
<p class="hint"> <p class="hint">
Картинка: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>; Картинка: <code class="code">:::figure assets/имя.png | Рис. 1. Подпись</code>;
видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>; видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>;
спойлер: <code class="code">:::spoiler Решение … :::</code>. спойлер: <code class="code">:::spoiler Решение … :::</code>.
</p> </p>
@@ -133,6 +143,8 @@
</form> </form>
</div> </div>
</dialog> </dialog>
<script src="/js/content-sort.js" defer></script>
</div> </div>
</body> </body>
</html> </html>