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:
@@ -212,6 +212,7 @@ classDiagram
|
|||||||
+moveArticle(UUID, UUID) ContentArticleView
|
+moveArticle(UUID, UUID) ContentArticleView
|
||||||
+deleteArticle(UUID)
|
+deleteArticle(UUID)
|
||||||
+importZip(String, ByteArray) ContentImportResult
|
+importZip(String, ByteArray) ContentImportResult
|
||||||
|
+reorder(ReorderTree)
|
||||||
}
|
}
|
||||||
class LabImportParser {
|
class LabImportParser {
|
||||||
+parse(String, ByteArray) LabImportBundle
|
+parse(String, ByteArray) LabImportBundle
|
||||||
|
|||||||
@@ -322,3 +322,30 @@ sequenceDiagram
|
|||||||
W->>W: сборка матрицы «студент × лабораторная»
|
W->>W: сборка матрицы «студент × лабораторная»
|
||||||
W-->>A: страница группы (оценки, защиты, поиск, фильтры, сортировка)
|
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: выбирает цель и позицию<br/>(до/после либо «внутрь» контейнера)
|
||||||
|
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,<br/>sections.chapter_id/position,<br/>articles.section_id/position
|
||||||
|
AC-->>W: 200
|
||||||
|
W-->>UI: {"ok": true}
|
||||||
|
UI->>UI: location.reload() — страница из серверного состояния
|
||||||
|
Note over AC,DB: один запрос применяет и порядок, и перенос между родителями
|
||||||
|
```
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ data class ContentImportResult(
|
|||||||
val assets: Int,
|
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(
|
private data class ParsedArticle(
|
||||||
val path: String,
|
val path: String,
|
||||||
val chapterPos: Int,
|
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
|
@Transactional
|
||||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
||||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import ru.ysu.lms.academic.content.ContentImportResult
|
import ru.ysu.lms.academic.content.ContentImportResult
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
import ru.ysu.lms.academic.content.ReorderTree
|
||||||
import ru.ysu.lms.common.BadRequestException
|
import ru.ysu.lms.common.BadRequestException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -71,6 +72,12 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
contentService.setArticlePublished(id, body.published)
|
contentService.setArticlePublished(id, body.published)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Новое расположение материалов (drag & drop глав, разделов и статей). */
|
||||||
|
@PostMapping("/reorder")
|
||||||
|
fun reorder(@RequestBody body: ReorderTree) {
|
||||||
|
contentService.reorder(body)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import org.springframework.ui.Model
|
|||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
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.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
import ru.ysu.lms.web.api.ApiException
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.ContentReorderDto
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -46,6 +49,14 @@ class ContentAdminController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сохранение порядка при drag & drop (запрос отправляется из JS). */
|
||||||
|
@PostMapping("/reorder")
|
||||||
|
@ResponseBody
|
||||||
|
fun reorder(@RequestBody body: ContentReorderDto, session: HttpSession): Map<String, Any> {
|
||||||
|
api.reorderContent(token(session), body)
|
||||||
|
return mapOf("ok" to true)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{kind}/toggle-published")
|
@PostMapping("/{kind}/toggle-published")
|
||||||
fun togglePublished(
|
fun togglePublished(
|
||||||
@PathVariable kind: String,
|
@PathVariable kind: String,
|
||||||
|
|||||||
@@ -491,6 +491,17 @@ class ApiClient(
|
|||||||
} ?: emptyList()
|
} ?: 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 {
|
fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto {
|
||||||
val parts = LinkedMultiValueMap<String, Any>()
|
val parts = LinkedMultiValueMap<String, Any>()
|
||||||
parts.add("file", object : ByteArrayResource(bytes) {
|
parts.add("file", object : ByteArrayResource(bytes) {
|
||||||
|
|||||||
@@ -342,6 +342,24 @@ data class ContentArticleDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ContentReorderDto(
|
||||||
|
val chapters: List<ContentReorderChapterDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContentReorderChapterDto(
|
||||||
|
val id: String = "",
|
||||||
|
val sections: List<ContentReorderSectionDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContentReorderSectionDto(
|
||||||
|
val id: String = "",
|
||||||
|
val articles: List<ContentReorderArticleDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContentReorderArticleDto(
|
||||||
|
val id: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
data class ContentImportResultDto(
|
data class ContentImportResultDto(
|
||||||
val chapters: Int = 0,
|
val chapters: Int = 0,
|
||||||
val sections: Int = 0,
|
val sections: Int = 0,
|
||||||
|
|||||||
@@ -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;
|
background: var(--c-surface); color: var(--c-text); font-family: inherit;
|
||||||
}
|
}
|
||||||
th.th-filterable { padding-bottom: 0.35rem; }
|
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; }
|
||||||
|
|||||||
156
web/src/main/resources/static/js/content-sort.js
Normal file
156
web/src/main/resources/static/js/content-sort.js
Normal file
@@ -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);
|
||||||
|
})();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||||
<head><title>Материалы — LMS</title></head>
|
<head><title>Материалы — LMS</title></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="content">
|
<div id="content" th:attr="data-csrf=${csrfToken}">
|
||||||
<div class="flex items-center justify-between gap-4 mb-6">
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
<h1 class="page-title">Учебные материалы</h1>
|
<h1 class="page-title">Учебные материалы</h1>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
|
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
|
||||||
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
|
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
|
||||||
и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его.
|
и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его.
|
||||||
|
Перетаскивайте за значок ⠿, чтобы менять порядок глав, разделов и статей, а также
|
||||||
|
переносить разделы в другую главу и статьи — в другой раздел.
|
||||||
</div>
|
</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>
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
Материалы ещё не добавлены.
|
Материалы ещё не добавлены.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion" th:attr="open=${chStat.last}">
|
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion"
|
||||||
|
th:attr="open=${chStat.last},data-chapter-id=${chapter.id}">
|
||||||
<summary class="card-body acc-head">
|
<summary class="card-body acc-head">
|
||||||
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
|
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
|
||||||
<span class="acc-arrow" aria-hidden="true"></span>
|
<span class="acc-arrow" aria-hidden="true"></span>
|
||||||
@@ -34,6 +37,9 @@
|
|||||||
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
|
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="drag-handle" draggable="true"
|
||||||
|
th:attr="data-drag-kind='chapter',data-drag-id=${chapter.id}"
|
||||||
|
title="Перетащите мышью: изменить порядок или перенести главу">⠿</span>
|
||||||
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
|
<form class="inline-form" th:action="@{/admin/content/chapters/toggle-published}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="id" th:value="${chapter.id}">
|
<input type="hidden" name="id" th:value="${chapter.id}">
|
||||||
@@ -54,7 +60,8 @@
|
|||||||
|
|
||||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
||||||
|
|
||||||
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc">
|
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc"
|
||||||
|
th:attr="data-section-id=${section.id}">
|
||||||
<summary class="sub-head">
|
<summary class="sub-head">
|
||||||
<h3 class="group-title m-0">
|
<h3 class="group-title m-0">
|
||||||
<span class="sub-arrow" aria-hidden="true"></span>
|
<span class="sub-arrow" aria-hidden="true"></span>
|
||||||
@@ -64,7 +71,10 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
|
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="drag-handle" draggable="true"
|
||||||
|
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
|
||||||
|
title="Перетащите мышью: изменить порядок или перенести раздел">⠿</span>
|
||||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="id" th:value="${section.id}">
|
<input type="hidden" name="id" th:value="${section.id}">
|
||||||
@@ -84,12 +94,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-pad text-sm">
|
<ul class="list-pad text-sm" data-article-list>
|
||||||
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
|
<li th:each="article : ${section.articles}" class="flex items-center gap-2"
|
||||||
|
th:attr="data-article-id=${article.id}">
|
||||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||||
th:text="${article.title}">Статья</a>
|
th:text="${article.title}">Статья</a>
|
||||||
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
||||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
|
<span class="drag-handle" draggable="true"
|
||||||
|
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
|
||||||
|
title="Перетащите мышью: изменить порядок или перенести статью">⠿</span>
|
||||||
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<input type="hidden" name="id" th:value="${article.id}">
|
<input type="hidden" name="id" th:value="${article.id}">
|
||||||
@@ -243,6 +257,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
<script src="/js/content-sort.js" defer></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user