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

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

View File

@@ -2,7 +2,7 @@
<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>
<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">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
@@ -34,100 +34,118 @@
</form>
</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>
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="group-title m-0">
<a 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>
</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<button class="btn btn-secondary btn-sm" 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"
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">
<div data-section-list>
<div th:each="section : ${chapter.sections}" class="card card-body mb-4"
th:attr="data-section-id=${section.id}">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="group-title m-0">
<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>
<a 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>
</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<button class="btn btn-secondary btn-sm" 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="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<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"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
<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>
<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"
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}">
<input type="hidden" name="back" th:value="${back}">
<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"
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>
<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}">
<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})}"
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="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>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
<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"
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}">
<input type="hidden" name="back" th:value="${back}">
<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"
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>
<dialog id="modal-rename-chapter" class="modal">
@@ -162,6 +180,8 @@
</form>
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
</div>
</body>
</html>

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" 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">
<h1 class="page-title">Учебные материалы</h1>
<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>
<span class="acc-arrow" aria-hidden="true"></span>
</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">
<h2 class="section-title m-0">
<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})}">
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
<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">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
@@ -37,12 +37,22 @@
</form>
</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">
<table>
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<tbody data-article-list>
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
<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})}"
th:text="${article.title}">—</a>
<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>
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
<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">:::spoiler Решение … :::</code>.
</p>
@@ -133,6 +143,8 @@
</form>
</div>
</dialog>
<script src="/js/content-sort.js" defer></script>
</div>
</body>
</html>