From d24e92005a2d6247a239898bd79cea9581de848b Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 16 Sep 2026 19:00:03 +0300 Subject: [PATCH] Content sort: autosave without reload; keep page state across reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
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 --- web/src/main/resources/static/css/app.css | 10 ++ .../main/resources/static/js/content-sort.js | 104 ++++++++++++++---- web/src/main/resources/static/js/ui-state.js | 59 ++++++++++ .../resources/templates/fragments/base.html | 1 + .../main/resources/templates/learn/index.html | 4 + 5 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 web/src/main/resources/static/js/ui-state.js diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index d714d1c..e1957f6 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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; +} diff --git a/web/src/main/resources/static/js/content-sort.js b/web/src/main/resources/static/js/content-sort.js index 0cd4007..92b49df 100644 --- a/web/src/main/resources/static/js/content-sort.js +++ b/web/src/main/resources/static/js/content-sort.js @@ -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) { /* игнорируем */ } + }); + }); })(); diff --git a/web/src/main/resources/static/js/ui-state.js b/web/src/main/resources/static/js/ui-state.js new file mode 100644 index 0000000..76fc62e --- /dev/null +++ b/web/src/main/resources/static/js/ui-state.js @@ -0,0 +1,59 @@ +/* Состояние страницы при перезагрузке: открытые
и позиция прокрутки. + Нужно, чтобы после сохранения формы или перерисовки страница возвращалась + туда же, а не «схлопывала» разделы и не сбрасывала прокрутку в начало. */ +(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); +})(); diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 78200b4..c924004 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -11,6 +11,7 @@ +