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 03c712fe2f
commit cb4cfee05b
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) { /* игнорируем */ }
});
});