Content sort: autosave without reload; keep page state across reloads

- drag & drop now saves in the background (700 ms debounce) without reloading:
  sections stay open, scroll position is kept; on failure it retries every 3 s,
  and navigator.sendBeacon flushes unsaved changes on page leave
- removed the explicit save button (autosave only), status indicator shows
  «Изменения не сохранены… / Сохранение… / Порядок сохранён»
- new global ui-state.js: restores open <details> and scroll position after any
  reload/redirect within the same page (applies to all pages, not just /learn)
- auto-expand a collapsed chapter/section while hovering during drag
This commit is contained in:
2026-09-16 19:00:03 +03:00
parent 5ffeaa0217
commit da464f882a
5 changed files with 157 additions and 21 deletions

View File

@@ -357,3 +357,13 @@ th.th-filterable { padding-bottom: 0.35rem; }
.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; }
/* индикатор сохранения порядка материалов */
.sort-status {
position: fixed; right: 1rem; bottom: 1rem; z-index: 40;
display: flex; align-items: center; gap: 0.6rem;
max-width: min(24rem, calc(100vw - 2rem));
background: var(--c-surface); border: 1px solid var(--c-border);
border-radius: 0.5rem; padding: 0.5rem 0.75rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem;
}

View File

@@ -1,9 +1,14 @@
/* Drag & drop для материалов: меняем порядок и переносим между родителями.
Ручки .drag-handle (data-drag-kind/id) — draggable, контейнеры — зоны сброса.
После перетаскивания собираем дерево из DOM и сохраняем одним запросом. */
/* Drag & drop для материалов: порядок и перенос между главами/разделами.
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором
состояния и кнопкой «Сохранить порядок» для повторной попытки. */
(function () {
var state = null;
var marker = null;
var pending = false;
var saving = false;
var timer = null;
var hoverTimer = null;
var hoverTarget = null;
function itemSelector(kind) {
if (kind === 'chapter') return '[data-chapter-id]';
@@ -11,6 +16,22 @@
return '[data-article-id]';
}
function statusBox() { return document.getElementById('sort-status'); }
function statusText() { return document.getElementById('sort-status-text'); }
function showStatus(message) {
var box = statusBox();
var text = statusText();
if (!box || !text) return;
box.hidden = false;
text.textContent = message;
}
function hideStatus() {
var box = statusBox();
if (box) box.hidden = true;
}
function clearMarker() {
if (marker && marker.element) {
marker.element.classList.remove('drop-before', 'drop-after', 'drop-into');
@@ -38,10 +59,13 @@
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 isDraggedOrInside(element) {
return element && (element === state.element || state.element.contains(element));
}
function onDragOver(event) {
if (!state) return;
var article = event.target.closest('[data-article-id]');
@@ -51,29 +75,23 @@
var cls = null;
if (state.kind === 'article') {
if (article && !state.element.contains(article)) {
if (article && !isDraggedOrInside(article)) {
target = article;
cls = placement(event, article);
} else if (section && !state.element.contains(section)) {
} else if (section && !isDraggedOrInside(section)) {
var list = section.querySelector(':scope > [data-article-list]');
if (list) {
target = list;
cls = 'drop-into';
}
if (list) { target = list; cls = 'drop-into'; }
}
} else if (state.kind === 'section') {
if (section && section !== state.element && !state.element.contains(section)) {
if (section && section !== state.element && !isDraggedOrInside(section)) {
target = section;
cls = placement(event, section);
} else if (chapter && !state.element.contains(chapter)) {
} else if (chapter && !isDraggedOrInside(chapter)) {
var body = chapter.querySelector(':scope > .acc-body');
if (body) {
target = body;
cls = 'drop-into';
}
if (body) { target = body; cls = 'drop-into'; }
}
} else if (state.kind === 'chapter') {
if (chapter && chapter !== state.element && !state.element.contains(chapter)) {
if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) {
target = chapter;
cls = placement(event, chapter);
}
@@ -86,6 +104,20 @@
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setMarker(target, cls);
autoExpand(hoverHost(target));
}
/* Пока тянем, раскрываем закрытую главу/раздел под курсором, чтобы внутрь можно было бросить. */
function hoverHost(target) {
return target.closest('details[data-chapter-id], details[data-section-id]');
}
function autoExpand(host) {
if (!host || host.open || host === state.element) return;
if (hoverTarget === host) return;
hoverTarget = host;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(function () { host.open = true; }, 600);
}
function onDrop(event) {
@@ -104,12 +136,14 @@
target.parentNode.insertBefore(dragged, target);
}
clearMarker();
save();
markDirty();
}
function onDragEnd() {
if (state && state.element) state.element.classList.remove('dragging');
state = null;
hoverTarget = null;
clearTimeout(hoverTimer);
clearMarker();
}
@@ -133,19 +167,35 @@
return { chapters: chapters };
}
function markDirty() {
pending = true;
showStatus('Изменения не сохранены…');
clearTimeout(timer);
timer = setTimeout(save, 700);
}
function save() {
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), {
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();
pending = false;
saving = false;
showStatus('Порядок сохранён');
setTimeout(hideStatus, 1500);
}).catch(function (error) {
alert('Не удалось сохранить порядок: ' + error.message);
window.location.reload();
saving = false;
showStatus('Не удалось сохранить (' + error.message + '), повторю через 3 с…');
clearTimeout(timer);
timer = setTimeout(save, 3000); // pending остаётся true — пробуем снова
});
}
@@ -153,4 +203,16 @@
document.addEventListener('dragover', onDragOver);
document.addEventListener('drop', onDrop);
document.addEventListener('dragend', onDragEnd);
document.addEventListener('DOMContentLoaded', function () {
// уход со страницы при несохранённых правках — тихо досылаем запрос
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);
} catch (e) { /* игнорируем */ }
});
});
})();

View File

@@ -0,0 +1,59 @@
/* Состояние страницы при перезагрузке: открытые <details> и позиция прокрутки.
Нужно, чтобы после сохранения формы или перерисовки страница возвращалась
туда же, а не «схлопывала» разделы и не сбрасывала прокрутку в начало. */
(function () {
var KEY = 'lms-ui-state';
function keyOf(element, index) {
return element.getAttribute('data-chapter-id')
|| element.getAttribute('data-section-id')
|| element.id
|| ('details-' + index);
}
function currentUrl() {
return location.pathname + location.search;
}
function save() {
var open = [];
document.querySelectorAll('details').forEach(function (element, index) {
if (element.open) open.push(keyOf(element, index));
});
try {
sessionStorage.setItem(KEY, JSON.stringify({
url: currentUrl(),
y: window.scrollY || window.pageYOffset || 0,
open: open
}));
} catch (e) { /* приватный режим — просто пропускаем */ }
}
function restore() {
var raw;
try {
raw = sessionStorage.getItem(KEY);
if (raw) sessionStorage.removeItem(KEY);
} catch (e) { return; }
if (!raw) return;
var state;
try { state = JSON.parse(raw); } catch (e) { return; }
// состояние применяем только при возврате на ту же страницу (перезагрузка/redirect)
if (!state || state.url !== currentUrl()) return;
var open = state.open || [];
document.querySelectorAll('details').forEach(function (element, index) {
if (open.indexOf(keyOf(element, index)) >= 0) element.open = true;
});
if (typeof state.y === 'number' && state.y > 0) {
var y = state.y;
requestAnimationFrame(function () { window.scrollTo(0, y); });
setTimeout(function () { window.scrollTo(0, y); }, 80);
}
}
window.addEventListener('beforeunload', save);
document.addEventListener('DOMContentLoaded', restore);
})();