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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) { /* игнорируем */ }
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
59
web/src/main/resources/static/js/ui-state.js
Normal file
59
web/src/main/resources/static/js/ui-state.js
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user