Content: drag & drop reordering and moving of chapters, sections and articles

- academic: POST /api/v1/content/reorder applies a full tree arrangement in one
  request — chapter order, section order and section->chapter moves, article
  order and article->section moves (positions and parent ids updated)
- web: POST /admin/content/reorder accepts the tree from JS (CSRF via query
  param), calls the gateway with the admin token
- /learn admin view: draggable handles (⠿) for chapters, sections and articles;
  drop before/after to reorder, drop into a chapter/section to move; visual
  indicators; structure is collected from DOM and saved, then page reloads
- docs: class diagram method + new sequence diagram for drag & drop
This commit is contained in:
2026-09-16 18:51:21 +03:00
parent 6ffc6304e3
commit 5ffeaa0217
10 changed files with 304 additions and 6 deletions

View File

@@ -345,3 +345,15 @@ th.th-sortable.is-desc::after { content: " ▼"; color: var(--c-text-muted); fon
background: var(--c-surface); color: var(--c-text); font-family: inherit;
}
th.th-filterable { padding-bottom: 0.35rem; }
/* drag & drop материалов */
.drag-handle {
cursor: grab; user-select: none; color: var(--c-text-muted);
font-size: 1rem; line-height: 1; padding: 0.15rem 0.3rem; border-radius: 0.35rem;
}
.drag-handle:hover { background: var(--c-bg); color: var(--c-text); }
.drag-handle:active { cursor: grabbing; }
.dragging { opacity: 0.45; }
.drop-before { box-shadow: 0 -3px 0 0 var(--c-text); }
.drop-after { box-shadow: 0 3px 0 0 var(--c-text); }
.drop-into { outline: 2px dashed var(--c-border); outline-offset: 3px; border-radius: 0.35rem; }

View File

@@ -0,0 +1,156 @@
/* Drag & drop для материалов: меняем порядок и переносим между родителями.
Ручки .drag-handle (data-drag-kind/id) — draggable, контейнеры — зоны сброса.
После перетаскивания собираем дерево из DOM и сохраняем одним запросом. */
(function () {
var state = null;
var marker = null;
function itemSelector(kind) {
if (kind === 'chapter') return '[data-chapter-id]';
if (kind === 'section') return '[data-section-id]';
return '[data-article-id]';
}
function clearMarker() {
if (marker && marker.element) {
marker.element.classList.remove('drop-before', 'drop-after', 'drop-into');
}
marker = null;
}
function setMarker(element, cls) {
if (marker && marker.element === element && marker.cls === cls) return;
clearMarker();
element.classList.add(cls);
marker = { element: element, cls: cls };
}
function placement(event, element) {
var rect = element.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before';
}
function onDragStart(event) {
var handle = event.target.closest('.drag-handle');
if (!handle) return;
var element = handle.closest(itemSelector(handle.dataset.dragKind));
if (!element) return;
state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element };
element.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
// Firefox не начинает перетаскивание без данных
event.dataTransfer.setData('text/plain', handle.dataset.dragId || '');
}
function onDragOver(event) {
if (!state) return;
var article = event.target.closest('[data-article-id]');
var section = event.target.closest('[data-section-id]');
var chapter = event.target.closest('[data-chapter-id]');
var target = null;
var cls = null;
if (state.kind === 'article') {
if (article && !state.element.contains(article)) {
target = article;
cls = placement(event, article);
} else if (section && !state.element.contains(section)) {
var list = section.querySelector(':scope > [data-article-list]');
if (list) {
target = list;
cls = 'drop-into';
}
}
} else if (state.kind === 'section') {
if (section && section !== state.element && !state.element.contains(section)) {
target = section;
cls = placement(event, section);
} else if (chapter && !state.element.contains(chapter)) {
var body = chapter.querySelector(':scope > .acc-body');
if (body) {
target = body;
cls = 'drop-into';
}
}
} else if (state.kind === 'chapter') {
if (chapter && chapter !== state.element && !state.element.contains(chapter)) {
target = chapter;
cls = placement(event, chapter);
}
}
if (!target) {
clearMarker();
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setMarker(target, cls);
}
function onDrop(event) {
if (!state || !marker) return;
event.preventDefault();
event.stopPropagation();
var target = marker.element;
var cls = marker.cls;
var dragged = state.element;
if (cls === 'drop-into') {
target.appendChild(dragged);
} else if (cls === 'drop-after') {
target.parentNode.insertBefore(dragged, target.nextSibling);
} else {
target.parentNode.insertBefore(dragged, target);
}
clearMarker();
save();
}
function onDragEnd() {
if (state && state.element) state.element.classList.remove('dragging');
state = null;
clearMarker();
}
function collectTree() {
var content = document.getElementById('content');
var chapters = [];
content.querySelectorAll(':scope > details[data-chapter-id]').forEach(function (chapter) {
var sections = [];
chapter.querySelectorAll(':scope > .acc-body > details[data-section-id]').forEach(function (section) {
var articles = [];
var list = section.querySelector(':scope > [data-article-list]');
if (list) {
list.querySelectorAll(':scope > [data-article-id]').forEach(function (item) {
articles.push({ id: item.getAttribute('data-article-id') });
});
}
sections.push({ id: section.getAttribute('data-section-id'), articles: articles });
});
chapters.push({ id: chapter.getAttribute('data-chapter-id'), sections: sections });
});
return { chapters: chapters };
}
function save() {
var content = document.getElementById('content');
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectTree())
}).then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
window.location.reload();
}).catch(function (error) {
alert('Не удалось сохранить порядок: ' + error.message);
window.location.reload();
});
}
document.addEventListener('dragstart', onDragStart);
document.addEventListener('dragover', onDragOver);
document.addEventListener('drop', onDrop);
document.addEventListener('dragend', onDragEnd);
})();

View File

@@ -2,7 +2,7 @@
<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">
<div id="content" th:attr="data-csrf=${csrfToken}">
<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">
@@ -14,6 +14,8 @@
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его.
Перетаскивайте за значок ⠿, чтобы менять порядок глав, разделов и статей, а также
переносить разделы в другую главу и статьи — в другой раздел.
</div>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
@@ -22,7 +24,8 @@
Материалы ещё не добавлены.
</div>
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion" th:attr="open=${chStat.last}">
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion"
th:attr="open=${chStat.last},data-chapter-id=${chapter.id}">
<summary class="card-body acc-head">
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
<span class="acc-arrow" aria-hidden="true"></span>
@@ -34,6 +37,9 @@
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
<span class="drag-handle" draggable="true"
th:attr="data-drag-kind='chapter',data-drag-id=${chapter.id}"
title="Перетащите мышью: изменить порядок или перенести главу">⠿</span>
<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}">
@@ -54,7 +60,8 @@
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc">
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc"
th:attr="data-section-id=${section.id}">
<summary class="sub-head">
<h3 class="group-title m-0">
<span class="sub-arrow" aria-hidden="true"></span>
@@ -64,7 +71,10 @@
</summary>
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
<div class="spacer"></div>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
<span class="drag-handle" draggable="true"
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
title="Перетащите мышью: изменить порядок или перенести раздел">⠿</span>
<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}">
@@ -84,12 +94,16 @@
</form>
</div>
</div>
<ul class="list-pad text-sm">
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
<ul class="list-pad text-sm" data-article-list>
<li th:each="article : ${section.articles}" class="flex items-center gap-2"
th:attr="data-article-id=${article.id}">
<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}">
<span class="drag-handle" draggable="true"
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
title="Перетащите мышью: изменить порядок или перенести статью">⠿</span>
<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}">
@@ -243,6 +257,7 @@
</div>
</dialog>
</th:block>
<script src="/js/content-sort.js" defer></script>
</div>
</body>
</html>