From a4ea358d275f1362698edbeb784cf34986920277 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 17 Sep 2026 19:15:56 +0300 Subject: [PATCH] Learn: drag & drop sections and articles on chapter and section pages --- docs/06-authoring.md | 5 + .../lms/academic/content/ContentService.kt | 38 ++++ .../controller/ContentAdminController.kt | 14 ++ .../lms/web/admin/ContentAdminController.kt | 26 +++ .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 24 +++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 10 + .../main/resources/static/js/content-sort.js | 114 +++++++---- .../resources/templates/learn/chapter.html | 192 ++++++++++-------- .../main/resources/templates/learn/index.html | 4 +- .../resources/templates/learn/section.html | 20 +- 10 files changed, 321 insertions(+), 126 deletions(-) diff --git a/docs/06-authoring.md b/docs/06-authoring.md index be0c3c5..ea65fa2 100644 --- a/docs/06-authoring.md +++ b/docs/06-authoring.md @@ -100,6 +100,11 @@ printf("Ответ: 42\n"); * **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»; у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править». +Порядок меняется перетаскиванием за значок **⠿** (сохраняется сразу, без перезагрузки страницы): +на главной `/learn` — главы, разделы и статьи (в том числе перенос раздела в другую главу и +статьи в другой раздел), на **странице главы** — разделы этой главы и статьи внутри них, +на **странице раздела** — статьи этого раздела. + ## Импорт: что происходит с материалами * Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index 4109fb4..f2cb217 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -73,6 +73,12 @@ data class ReorderSection(val id: String, val articles: List = e data class ReorderChapter(val id: String, val sections: List = emptyList()) data class ReorderTree(val chapters: List = emptyList()) +/** Порядок разделов внутри одной главы (drag & drop на странице главы). */ +data class ReorderSections(val sections: List = emptyList()) + +/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ +data class ReorderArticles(val articles: List = emptyList()) + private data class ParsedArticle( val path: String, val chapterPos: Int, @@ -184,6 +190,38 @@ class ContentService( } } + /** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */ + @Transactional + fun reorderSections(chapterId: UUID, tree: List) { + tree.forEachIndexed { sectionIndex, sectionDto -> + val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + if (section.chapterId != chapterId) return@forEachIndexed + section.position = sectionIndex + 1 + sections.save(section) + + sectionDto.articles.forEachIndexed { articleIndex, articleDto -> + val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + article.sectionId = section.id + article.position = articleIndex + 1 + articles.save(article) + } + } + } + + /** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ + @Transactional + fun reorderArticles(sectionId: UUID, tree: List) { + tree.forEachIndexed { articleIndex, articleDto -> + val article = articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + if (article.sectionId != sectionId) return@forEachIndexed + article.position = articleIndex + 1 + articles.save(article) + } + } + private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index e941900..9bab967 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentService +import ru.ysu.lms.academic.content.ReorderArticles +import ru.ysu.lms.academic.content.ReorderSections import ru.ysu.lms.academic.content.ReorderTree import ru.ysu.lms.common.BadRequestException import java.util.UUID @@ -78,6 +80,18 @@ class ContentAdminController(private val contentService: ContentService) { contentService.reorder(body) } + /** Порядок разделов внутри главы (drag & drop на странице главы). */ + @PostMapping("/chapters/{id}/sections/reorder") + fun reorderSections(@PathVariable id: UUID, @RequestBody body: ReorderSections) { + contentService.reorderSections(id, body.sections) + } + + /** Порядок статей внутри раздела (drag & drop на странице раздела). */ + @PostMapping("/sections/{id}/articles/reorder") + fun reorderArticles(@PathVariable id: UUID, @RequestBody body: ReorderArticles) { + contentService.reorderArticles(id, body.articles) + } + @PostMapping("/import") fun import( @RequestParam("file") file: MultipartFile, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index 56481d4..d5dfe00 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -14,7 +14,9 @@ import org.springframework.web.multipart.MultipartFile import org.springframework.web.servlet.mvc.support.RedirectAttributes import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.ArticlesReorderDto import ru.ysu.lms.web.api.ContentReorderDto +import ru.ysu.lms.web.api.SectionsReorderDto import ru.ysu.lms.web.session.SessionKeys @Controller @@ -61,6 +63,30 @@ class ContentAdminController( return mapOf("ok" to true) } + /** Порядок разделов внутри главы (drag & drop на странице главы). */ + @PostMapping("/chapters/{id}/reorder-sections") + @ResponseBody + fun reorderSections( + @PathVariable id: String, + @RequestBody body: SectionsReorderDto, + session: HttpSession, + ): Map { + api.reorderSections(token(session), id, body) + return mapOf("ok" to true) + } + + /** Порядок статей внутри раздела (drag & drop на странице раздела). */ + @PostMapping("/sections/{id}/reorder-articles") + @ResponseBody + fun reorderArticles( + @PathVariable id: String, + @RequestBody body: ArticlesReorderDto, + session: HttpSession, + ): Map { + api.reorderArticles(token(session), id, body) + return mapOf("ok" to true) + } + @PostMapping("/{kind}/toggle-published") fun togglePublished( @PathVariable kind: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index fcdb815..3b0e497 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -502,6 +502,30 @@ class ApiClient( } } + /** Порядок разделов внутри главы (drag & drop на странице главы). */ + fun reorderSections(token: String, chapterId: String, body: SectionsReorderDto) { + call { + client.post() + .uri("/api/v1/content/chapters/{id}/sections/reorder", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve().toBodilessEntity() + } + } + + /** Порядок статей внутри раздела (drag & drop на странице раздела). */ + fun reorderArticles(token: String, sectionId: String, body: ArticlesReorderDto) { + call { + client.post() + .uri("/api/v1/content/sections/{id}/articles/reorder", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve().toBodilessEntity() + } + } + fun importContent( token: String, filename: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 2d98a7a..898d41e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -360,6 +360,16 @@ data class ContentReorderArticleDto( val id: String = "", ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class SectionsReorderDto( + val sections: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ArticlesReorderDto( + val articles: List = emptyList(), +) + data class ContentImportResultDto( val chapters: Int = 0, val sections: Int = 0, diff --git a/web/src/main/resources/static/js/content-sort.js b/web/src/main/resources/static/js/content-sort.js index 92b49df..793a372 100644 --- a/web/src/main/resources/static/js/content-sort.js +++ b/web/src/main/resources/static/js/content-sort.js @@ -1,6 +1,9 @@ /* Drag & drop для материалов: порядок и перенос между главами/разделами. - Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором - состояния и кнопкой «Сохранить порядок» для повторной попытки. */ + Работает в трёх режимах (атрибут data-sort-scope у #content): + - full — страница «Материалы»: главы, разделы, статьи; + - chapter — страница главы: разделы внутри главы и статьи внутри них; + - section — страница раздела: статьи внутри раздела. + Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором состояния. */ (function () { var state = null; var marker = null; @@ -10,12 +13,20 @@ var hoverTimer = null; var hoverTarget = null; + function contentEl() { return document.getElementById('content'); } + function scope() { return contentEl() ? (contentEl().getAttribute('data-sort-scope') || 'full') : 'full'; } + function csrf() { return contentEl() ? (contentEl().getAttribute('data-csrf') || '') : ''; } + function itemSelector(kind) { if (kind === 'chapter') return '[data-chapter-id]'; if (kind === 'section') return '[data-section-id]'; return '[data-article-id]'; } + /* #content на страницах главы/раздела тоже помечен data-section-id / data-chapter-id, + поэтому его нельзя считать перетаскиваемым элементом. */ + function isRealItem(element) { return element && element !== contentEl(); } + function statusBox() { return document.getElementById('sort-status'); } function statusText() { return document.getElementById('sort-status-text'); } @@ -51,11 +62,16 @@ return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before'; } + function listFor(kind, container) { + if (!container) return null; + return container.querySelector(kind === 'article' ? '[data-article-list]' : '[data-section-list]'); + } + function onDragStart(event) { var handle = event.target.closest('.drag-handle'); if (!handle) return; var element = handle.closest(itemSelector(handle.dataset.dragKind)); - if (!element) return; + if (!isRealItem(element)) return; state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element }; element.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; @@ -75,23 +91,23 @@ var cls = null; if (state.kind === 'article') { - if (article && !isDraggedOrInside(article)) { + if (isRealItem(article) && !isDraggedOrInside(article)) { target = article; cls = placement(event, article); - } else if (section && !isDraggedOrInside(section)) { - var list = section.querySelector(':scope > [data-article-list]'); + } else if (isRealItem(section) && !isDraggedOrInside(section)) { + var list = listFor('article', section); if (list) { target = list; cls = 'drop-into'; } } } else if (state.kind === 'section') { - if (section && section !== state.element && !isDraggedOrInside(section)) { + if (isRealItem(section) && section !== state.element && !isDraggedOrInside(section)) { target = section; cls = placement(event, section); - } else if (chapter && !isDraggedOrInside(chapter)) { - var body = chapter.querySelector(':scope > .acc-body'); - if (body) { target = body; cls = 'drop-into'; } + } else if (isRealItem(chapter) && !isDraggedOrInside(chapter)) { + var sectionList = listFor('section', chapter); + if (sectionList) { target = sectionList; cls = 'drop-into'; } } } else if (state.kind === 'chapter') { - if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) { + if (isRealItem(chapter) && chapter !== state.element && !isDraggedOrInside(chapter)) { target = chapter; cls = placement(event, chapter); } @@ -147,26 +163,60 @@ 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 }); + function collectArticles(container) { + var out = []; + var list = container ? container.querySelector('[data-article-list]') : null; + if (list) { + list.querySelectorAll('[data-article-id]').forEach(function (item) { + out.push({ id: item.getAttribute('data-article-id') }); + }); + } + return out; + } + + function collectSections(container) { + var out = []; + if (!container) return out; + container.querySelectorAll('[data-section-id]').forEach(function (section) { + if (!isRealItem(section)) return; + out.push({ id: section.getAttribute('data-section-id'), articles: collectArticles(section) }); + }); + return out; + } + + function collectPayload() { + var content = contentEl(); + if (!content) return { chapters: [] }; + + if (scope() === 'chapter') { + return { sections: collectSections(content.querySelector('[data-section-list]')) }; + } + if (scope() === 'section') { + return { articles: collectArticles(content) }; + } + + var chapters = []; + content.querySelectorAll('[data-chapter-id]').forEach(function (chapter) { + if (!isRealItem(chapter)) return; + chapters.push({ + id: chapter.getAttribute('data-chapter-id'), + sections: collectSections(chapter.querySelector('[data-section-list]')) }); - chapters.push({ id: chapter.getAttribute('data-chapter-id'), sections: sections }); }); return { chapters: chapters }; } + function saveUrl() { + var content = contentEl(); + if (scope() === 'chapter' && content) { + return '/admin/content/chapters/' + encodeURIComponent(content.getAttribute('data-chapter-id')) + '/reorder-sections'; + } + if (scope() === 'section' && content) { + return '/admin/content/sections/' + encodeURIComponent(content.getAttribute('data-section-id')) + '/reorder-articles'; + } + return '/admin/content/reorder'; + } + function markDirty() { pending = true; showStatus('Изменения не сохранены…'); @@ -178,13 +228,11 @@ 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), { + fetch(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(collectTree()) + body: JSON.stringify(collectPayload()) }).then(function (response) { if (!response.ok) throw new Error('HTTP ' + response.status); pending = false; @@ -208,10 +256,8 @@ 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); + var blob = new Blob([JSON.stringify(collectPayload())], { type: 'application/json' }); + navigator.sendBeacon(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), blob); } catch (e) { /* игнорируем */ } }); }); diff --git a/web/src/main/resources/templates/learn/chapter.html b/web/src/main/resources/templates/learn/chapter.html index fc64302..bb9765b 100644 --- a/web/src/main/resources/templates/learn/chapter.html +++ b/web/src/main/resources/templates/learn/chapter.html @@ -2,7 +2,7 @@ Глава -
+
+
+ Перетаскивайте разделы и статьи за значок ⠿, чтобы менять порядок и переносить статьи между + разделами этой главы. Порядок сохраняется сразу. +
+ +
В этой главе пока нет разделов.
-
-
-

- Раздел - скрыто -

-
- -
- - - - - -
- -
- - - -
-
-
- -
    -
  • - Статья - скрыто - - Править -
    +
    +
    +
    +

    + ⠿ + Раздел + скрыто +

    +
    + + - - + + + th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'" + th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть - -
  • -
  • Статей пока нет
  • -
+ +
+ + + +
+
+
- - - - - - - - - - + + + + + + + + + + + + + @@ -162,6 +180,8 @@ + + diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 9f03be5..259f07a 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -2,7 +2,7 @@ Материалы — LMS -
+

Учебные материалы

@@ -34,7 +34,7 @@

Глава

-
+

Глава diff --git a/web/src/main/resources/templates/learn/section.html b/web/src/main/resources/templates/learn/section.html index 8610caf..2efdeee 100644 --- a/web/src/main/resources/templates/learn/section.html +++ b/web/src/main/resources/templates/learn/section.html @@ -2,7 +2,7 @@ Раздел -
+
+
+ Перетаскивайте статьи за значок ⠿, чтобы менять их порядок. Порядок сохраняется сразу. +
+ +
- - + +
СтатьяДействия
+ ⠿ — скрыто @@ -125,7 +135,7 @@

- Картинка: :::figure assets/имя.svg | Рис. 1. Подпись; + Картинка: :::figure assets/имя.png | Рис. 1. Подпись; видео: :::video assets/имя.mp4 | Подпись; спойлер: :::spoiler Решение … :::.

@@ -133,6 +143,8 @@ + +