diff --git a/docs/03-domain-model.md b/docs/03-domain-model.md index 86096c2..3ef00af 100644 --- a/docs/03-domain-model.md +++ b/docs/03-domain-model.md @@ -212,6 +212,7 @@ classDiagram +moveArticle(UUID, UUID) ContentArticleView +deleteArticle(UUID) +importZip(String, ByteArray) ContentImportResult + +reorder(ReorderTree) } class LabImportParser { +parse(String, ByteArray) LabImportBundle diff --git a/docs/04-sequences.md b/docs/04-sequences.md index 84fd381..cd6bec5 100644 --- a/docs/04-sequences.md +++ b/docs/04-sequences.md @@ -322,3 +322,30 @@ sequenceDiagram W->>W: сборка матрицы «студент × лабораторная» W-->>A: страница группы (оценки, защиты, поиск, фильтры, сортировка) ``` + +## 11. Drag & drop материалов (порядок и перенос) + +```mermaid +sequenceDiagram + autonumber + actor A as Администратор + participant UI as /learn (JS content-sort.js) + participant W as web + participant G as gateway + participant AC as academic + participant DB as PostgreSQL + + A->>UI: тянет за значок ⠿ (глава, раздел или статья) + UI->>UI: dragover: выбирает цель и позицию
(до/после либо «внутрь» контейнера) + A->>UI: отпускает кнопку мыши + UI->>UI: перемещает элемент в DOM (перенос между главами/разделами) + UI->>UI: collectTree() — собирает порядок глав, разделов и статей + UI->>W: POST /admin/content/reorder?_csrf=... (JSON) + W->>G: POST /api/v1/content/reorder (Bearer admin) + G->>AC: ReorderTree + AC->>DB: UPDATE chapters.position,
sections.chapter_id/position,
articles.section_id/position + AC-->>W: 200 + W-->>UI: {"ok": true} + UI->>UI: location.reload() — страница из серверного состояния + Note over AC,DB: один запрос применяет и порядок, и перенос между родителями +``` 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 344ddf1..334d1d9 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 @@ -61,6 +61,16 @@ data class ContentImportResult( val assets: Int, ) +/** + * Новое расположение материалов: порядок глав, порядок разделов внутри глав + * (и перенос разделов между главами), порядок статей внутри разделов + * (и перенос статей между разделами). Применяется одним запросом. + */ +data class ReorderArticle(val id: String) +data class ReorderSection(val id: String, val articles: List = emptyList()) +data class ReorderChapter(val id: String, val sections: List = emptyList()) +data class ReorderTree(val chapters: List = emptyList()) + private data class ParsedArticle( val path: String, val chapterPos: Int, @@ -145,6 +155,36 @@ class ContentService( ) } + /** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */ + @Transactional + fun reorder(tree: ReorderTree) { + tree.chapters.forEachIndexed { chapterIndex, chapterDto -> + val chapter = chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + chapter.position = chapterIndex + 1 + chapters.save(chapter) + + chapterDto.sections.forEachIndexed { sectionIndex, sectionDto -> + val section = sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + section.chapterId = chapter.id + 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) + } + } + } + } + + private fun uuidOrNull(value: String): UUID? = + runCatching { UUID.fromString(value) }.getOrNull() + @Transactional fun setChapterPublished(chapterId: UUID, published: Boolean) { val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } 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 dd6de0c..49a7cda 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,7 @@ 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.ReorderTree import ru.ysu.lms.common.BadRequestException import java.util.UUID @@ -71,6 +72,12 @@ class ContentAdminController(private val contentService: ContentService) { contentService.setArticlePublished(id, body.published) } + /** Новое расположение материалов (drag & drop глав, разделов и статей). */ + @PostMapping("/reorder") + fun reorder(@RequestBody body: ReorderTree) { + contentService.reorder(body) + } + @PostMapping("/import") fun import(@RequestParam("file") file: MultipartFile): ContentImportResult { if (file.isEmpty) throw BadRequestException("Выберите файл") 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 7669f59..159f369 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 @@ -6,12 +6,15 @@ import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody 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.ContentReorderDto import ru.ysu.lms.web.session.SessionKeys @Controller @@ -46,6 +49,14 @@ class ContentAdminController( } } + /** Сохранение порядка при drag & drop (запрос отправляется из JS). */ + @PostMapping("/reorder") + @ResponseBody + fun reorder(@RequestBody body: ContentReorderDto, session: HttpSession): Map { + api.reorderContent(token(session), 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 5f12759..10e7087 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 @@ -491,6 +491,17 @@ class ApiClient( } ?: emptyList() } + /** Сохраняет новое расположение глав, разделов и статей. */ + fun reorderContent(token: String, tree: ContentReorderDto) { + call { + client.post().uri("/api/v1/content/reorder") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(tree) + .retrieve().toBodilessEntity() + } + } + fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto { val parts = LinkedMultiValueMap() parts.add("file", object : ByteArrayResource(bytes) { 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 04997bd..3d4b472 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 @@ -342,6 +342,24 @@ data class ContentArticleDto( ) @JsonIgnoreProperties(ignoreUnknown = true) +data class ContentReorderDto( + val chapters: List = emptyList(), +) + +data class ContentReorderChapterDto( + val id: String = "", + val sections: List = emptyList(), +) + +data class ContentReorderSectionDto( + val id: String = "", + val articles: List = emptyList(), +) + +data class ContentReorderArticleDto( + val id: String = "", +) + data class ContentImportResultDto( val chapters: Int = 0, val sections: Int = 0, diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 392b0e5..d714d1c 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -345,3 +345,15 @@ th.th-sortable.is-desc::after { content: " ▼"; color: var(--c-text-muted); fon background: var(--c-surface); color: var(--c-text); font-family: inherit; } th.th-filterable { padding-bottom: 0.35rem; } + +/* drag & drop материалов */ +.drag-handle { + cursor: grab; user-select: none; color: var(--c-text-muted); + font-size: 1rem; line-height: 1; padding: 0.15rem 0.3rem; border-radius: 0.35rem; +} +.drag-handle:hover { background: var(--c-bg); color: var(--c-text); } +.drag-handle:active { cursor: grabbing; } +.dragging { opacity: 0.45; } +.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; } diff --git a/web/src/main/resources/static/js/content-sort.js b/web/src/main/resources/static/js/content-sort.js new file mode 100644 index 0000000..0cd4007 --- /dev/null +++ b/web/src/main/resources/static/js/content-sort.js @@ -0,0 +1,156 @@ +/* Drag & drop для материалов: меняем порядок и переносим между родителями. + Ручки .drag-handle (data-drag-kind/id) — draggable, контейнеры — зоны сброса. + После перетаскивания собираем дерево из DOM и сохраняем одним запросом. */ +(function () { + var state = null; + var marker = null; + + function itemSelector(kind) { + if (kind === 'chapter') return '[data-chapter-id]'; + if (kind === 'section') return '[data-section-id]'; + return '[data-article-id]'; + } + + function clearMarker() { + if (marker && marker.element) { + marker.element.classList.remove('drop-before', 'drop-after', 'drop-into'); + } + marker = null; + } + + function setMarker(element, cls) { + if (marker && marker.element === element && marker.cls === cls) return; + clearMarker(); + element.classList.add(cls); + marker = { element: element, cls: cls }; + } + + function placement(event, element) { + var rect = element.getBoundingClientRect(); + return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before'; + } + + function onDragStart(event) { + var handle = event.target.closest('.drag-handle'); + if (!handle) return; + var element = handle.closest(itemSelector(handle.dataset.dragKind)); + if (!element) return; + 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 onDragOver(event) { + if (!state) return; + var article = event.target.closest('[data-article-id]'); + var section = event.target.closest('[data-section-id]'); + var chapter = event.target.closest('[data-chapter-id]'); + var target = null; + var cls = null; + + if (state.kind === 'article') { + if (article && !state.element.contains(article)) { + target = article; + cls = placement(event, article); + } else if (section && !state.element.contains(section)) { + var list = section.querySelector(':scope > [data-article-list]'); + if (list) { + target = list; + cls = 'drop-into'; + } + } + } else if (state.kind === 'section') { + if (section && section !== state.element && !state.element.contains(section)) { + target = section; + cls = placement(event, section); + } else if (chapter && !state.element.contains(chapter)) { + var body = chapter.querySelector(':scope > .acc-body'); + if (body) { + target = body; + cls = 'drop-into'; + } + } + } else if (state.kind === 'chapter') { + if (chapter && chapter !== state.element && !state.element.contains(chapter)) { + target = chapter; + cls = placement(event, chapter); + } + } + + if (!target) { + clearMarker(); + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setMarker(target, cls); + } + + function onDrop(event) { + if (!state || !marker) return; + event.preventDefault(); + event.stopPropagation(); + var target = marker.element; + var cls = marker.cls; + var dragged = state.element; + + if (cls === 'drop-into') { + target.appendChild(dragged); + } else if (cls === 'drop-after') { + target.parentNode.insertBefore(dragged, target.nextSibling); + } else { + target.parentNode.insertBefore(dragged, target); + } + clearMarker(); + save(); + } + + function onDragEnd() { + if (state && state.element) state.element.classList.remove('dragging'); + state = null; + 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 }); + }); + chapters.push({ id: chapter.getAttribute('data-chapter-id'), sections: sections }); + }); + return { chapters: chapters }; + } + + function save() { + var content = document.getElementById('content'); + var csrf = content ? (content.getAttribute('data-csrf') || '') : ''; + 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(); + }).catch(function (error) { + alert('Не удалось сохранить порядок: ' + error.message); + window.location.reload(); + }); + } + + document.addEventListener('dragstart', onDragStart); + document.addEventListener('dragover', onDragOver); + document.addEventListener('drop', onDrop); + document.addEventListener('dragend', onDragEnd); +})(); diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 16b21a2..56ee70b 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -2,7 +2,7 @@ Материалы — LMS -
+

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

@@ -14,6 +14,8 @@
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто» и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его. + Перетаскивайте за значок ⠿, чтобы менять порядок глав, разделов и статей, а также + переносить разделы в другую главу и статьи — в другой раздел.
@@ -22,7 +24,8 @@ Материалы ещё не добавлены.
-
+

Глава

@@ -34,6 +37,9 @@ скрыто
+ ⠿
@@ -54,7 +60,8 @@

Разделов пока нет

-
+

@@ -64,7 +71,10 @@

-
+
+ ⠿ @@ -84,12 +94,16 @@
-
+