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:
156
web/src/main/resources/static/js/content-sort.js
Normal file
156
web/src/main/resources/static/js/content-sort.js
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user