Content: drag & drop reordering and moving of chapters, sections and articles

- academic: POST /api/v1/content/reorder applies a full tree arrangement in one
  request — chapter order, section order and section->chapter moves, article
  order and article->section moves (positions and parent ids updated)
- web: POST /admin/content/reorder accepts the tree from JS (CSRF via query
  param), calls the gateway with the admin token
- /learn admin view: draggable handles (⠿) for chapters, sections and articles;
  drop before/after to reorder, drop into a chapter/section to move; visual
  indicators; structure is collected from DOM and saved, then page reloads
- docs: class diagram method + new sequence diagram for drag & drop
This commit is contained in:
2026-09-16 18:51:21 +03:00
parent 6ffc6304e3
commit 5ffeaa0217
10 changed files with 304 additions and 6 deletions

View File

@@ -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<ReorderArticle> = emptyList())
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
data class ReorderTree(val chapters: List<ReorderChapter> = 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("Глава не найдена") }

View File

@@ -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("Выберите файл")