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-before { box-shadow: 0 -3px 0 0 var(--c-text); }
|
||||||
.drop-after { 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; }
|
.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 & drop для материалов: порядок и перенос между главами/разделами.
|
||||||
Ручки .drag-handle (data-drag-kind/id) — draggable, контейнеры — зоны сброса.
|
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором
|
||||||
После перетаскивания собираем дерево из DOM и сохраняем одним запросом. */
|
состояния и кнопкой «Сохранить порядок» для повторной попытки. */
|
||||||
(function () {
|
(function () {
|
||||||
var state = null;
|
var state = null;
|
||||||
var marker = null;
|
var marker = null;
|
||||||
|
var pending = false;
|
||||||
|
var saving = false;
|
||||||
|
var timer = null;
|
||||||
|
var hoverTimer = null;
|
||||||
|
var hoverTarget = null;
|
||||||
|
|
||||||
function itemSelector(kind) {
|
function itemSelector(kind) {
|
||||||
if (kind === 'chapter') return '[data-chapter-id]';
|
if (kind === 'chapter') return '[data-chapter-id]';
|
||||||
@@ -11,6 +16,22 @@
|
|||||||
return '[data-article-id]';
|
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() {
|
function clearMarker() {
|
||||||
if (marker && marker.element) {
|
if (marker && marker.element) {
|
||||||
marker.element.classList.remove('drop-before', 'drop-after', 'drop-into');
|
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 };
|
state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element };
|
||||||
element.classList.add('dragging');
|
element.classList.add('dragging');
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
// Firefox не начинает перетаскивание без данных
|
|
||||||
event.dataTransfer.setData('text/plain', handle.dataset.dragId || '');
|
event.dataTransfer.setData('text/plain', handle.dataset.dragId || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDraggedOrInside(element) {
|
||||||
|
return element && (element === state.element || state.element.contains(element));
|
||||||
|
}
|
||||||
|
|
||||||
function onDragOver(event) {
|
function onDragOver(event) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
var article = event.target.closest('[data-article-id]');
|
var article = event.target.closest('[data-article-id]');
|
||||||
@@ -51,29 +75,23 @@
|
|||||||
var cls = null;
|
var cls = null;
|
||||||
|
|
||||||
if (state.kind === 'article') {
|
if (state.kind === 'article') {
|
||||||
if (article && !state.element.contains(article)) {
|
if (article && !isDraggedOrInside(article)) {
|
||||||
target = article;
|
target = article;
|
||||||
cls = placement(event, 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]');
|
var list = section.querySelector(':scope > [data-article-list]');
|
||||||
if (list) {
|
if (list) { target = list; cls = 'drop-into'; }
|
||||||
target = list;
|
|
||||||
cls = 'drop-into';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (state.kind === 'section') {
|
} else if (state.kind === 'section') {
|
||||||
if (section && section !== state.element && !state.element.contains(section)) {
|
if (section && section !== state.element && !isDraggedOrInside(section)) {
|
||||||
target = section;
|
target = section;
|
||||||
cls = placement(event, section);
|
cls = placement(event, section);
|
||||||
} else if (chapter && !state.element.contains(chapter)) {
|
} else if (chapter && !isDraggedOrInside(chapter)) {
|
||||||
var body = chapter.querySelector(':scope > .acc-body');
|
var body = chapter.querySelector(':scope > .acc-body');
|
||||||
if (body) {
|
if (body) { target = body; cls = 'drop-into'; }
|
||||||
target = body;
|
|
||||||
cls = 'drop-into';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (state.kind === 'chapter') {
|
} else if (state.kind === 'chapter') {
|
||||||
if (chapter && chapter !== state.element && !state.element.contains(chapter)) {
|
if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) {
|
||||||
target = chapter;
|
target = chapter;
|
||||||
cls = placement(event, chapter);
|
cls = placement(event, chapter);
|
||||||
}
|
}
|
||||||
@@ -86,6 +104,20 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'move';
|
event.dataTransfer.dropEffect = 'move';
|
||||||
setMarker(target, cls);
|
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) {
|
function onDrop(event) {
|
||||||
@@ -104,12 +136,14 @@
|
|||||||
target.parentNode.insertBefore(dragged, target);
|
target.parentNode.insertBefore(dragged, target);
|
||||||
}
|
}
|
||||||
clearMarker();
|
clearMarker();
|
||||||
save();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd() {
|
function onDragEnd() {
|
||||||
if (state && state.element) state.element.classList.remove('dragging');
|
if (state && state.element) state.element.classList.remove('dragging');
|
||||||
state = null;
|
state = null;
|
||||||
|
hoverTarget = null;
|
||||||
|
clearTimeout(hoverTimer);
|
||||||
clearMarker();
|
clearMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,19 +167,35 @@
|
|||||||
return { chapters: chapters };
|
return { chapters: chapters };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
pending = true;
|
||||||
|
showStatus('Изменения не сохранены…');
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(save, 700);
|
||||||
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
|
if (!pending || saving) return;
|
||||||
|
saving = true;
|
||||||
|
clearTimeout(timer);
|
||||||
var content = document.getElementById('content');
|
var content = document.getElementById('content');
|
||||||
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
||||||
|
showStatus('Сохранение…');
|
||||||
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), {
|
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(collectTree())
|
body: JSON.stringify(collectTree())
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
window.location.reload();
|
pending = false;
|
||||||
|
saving = false;
|
||||||
|
showStatus('Порядок сохранён');
|
||||||
|
setTimeout(hideStatus, 1500);
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
alert('Не удалось сохранить порядок: ' + error.message);
|
saving = false;
|
||||||
window.location.reload();
|
showStatus('Не удалось сохранить (' + error.message + '), повторю через 3 с…');
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(save, 3000); // pending остаётся true — пробуем снова
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,4 +203,16 @@
|
|||||||
document.addEventListener('dragover', onDragOver);
|
document.addEventListener('dragover', onDragOver);
|
||||||
document.addEventListener('drop', onDrop);
|
document.addEventListener('drop', onDrop);
|
||||||
document.addEventListener('dragend', onDragEnd);
|
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);
|
||||||
|
})();
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<script th:src="@{/js/htmx.min.js}" defer></script>
|
<script th:src="@{/js/htmx.min.js}" defer></script>
|
||||||
<script th:src="@{/js/highlight.min.js}" defer></script>
|
<script th:src="@{/js/highlight.min.js}" defer></script>
|
||||||
<script th:src="@{/js/tablekit.js}" defer></script>
|
<script th:src="@{/js/tablekit.js}" defer></script>
|
||||||
|
<script th:src="@{/js/ui-state.js}" defer></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var t = localStorage.getItem('lms-theme') || 'light';
|
var t = localStorage.getItem('lms-theme') || 'light';
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
Перетаскивайте за значок ⠿, чтобы менять порядок глав, разделов и статей, а также
|
Перетаскивайте за значок ⠿, чтобы менять порядок глав, разделов и статей, а также
|
||||||
переносить разделы в другую главу и статьи — в другой раздел.
|
переносить разделы в другую главу и статьи — в другой раздел.
|
||||||
</div>
|
</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="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user