Learn: drag & drop sections and articles on chapter and section pages
This commit is contained in:
@@ -100,6 +100,11 @@ printf("Ответ: 42\n");
|
||||
* **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»;
|
||||
у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править».
|
||||
|
||||
Порядок меняется перетаскиванием за значок **⠿** (сохраняется сразу, без перезагрузки страницы):
|
||||
на главной `/learn` — главы, разделы и статьи (в том числе перенос раздела в другую главу и
|
||||
статьи в другой раздел), на **странице главы** — разделы этой главы и статьи внутри них,
|
||||
на **странице раздела** — статьи этого раздела.
|
||||
|
||||
## Импорт: что происходит с материалами
|
||||
|
||||
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи).
|
||||
|
||||
@@ -73,6 +73,12 @@ data class ReorderSection(val id: String, val articles: List<ReorderArticle> = e
|
||||
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
|
||||
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
|
||||
|
||||
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
|
||||
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
|
||||
|
||||
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
|
||||
data class ReorderArticles(val articles: List<ReorderArticle> = 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<ReorderSection>) {
|
||||
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<ReorderArticle>) {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Any> {
|
||||
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<String, Any> {
|
||||
api.reorderArticles(token(session), id, body)
|
||||
return mapOf("ok" to true)
|
||||
}
|
||||
|
||||
@PostMapping("/{kind}/toggle-published")
|
||||
fun togglePublished(
|
||||
@PathVariable kind: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -360,6 +360,16 @@ data class ContentReorderArticleDto(
|
||||
val id: String = "",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SectionsReorderDto(
|
||||
val sections: List<ContentReorderSectionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ArticlesReorderDto(
|
||||
val articles: List<ContentReorderArticleDto> = emptyList(),
|
||||
)
|
||||
|
||||
data class ContentImportResultDto(
|
||||
val chapters: Int = 0,
|
||||
val sections: Int = 0,
|
||||
|
||||
@@ -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) { /* игнорируем */ }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='chapter',data-chapter-id=${chapter.id}">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
@@ -34,100 +34,118 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
|
||||
Перетаскивайте разделы и статьи за значок ⠿, чтобы менять порядок и переносить статьи между
|
||||
разделами этой главы. Порядок сохраняется сразу.
|
||||
</div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
|
||||
<span id="sort-status-text">Изменения не сохранены…</span>
|
||||
</div>
|
||||
|
||||
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
|
||||
В этой главе пока нет разделов.
|
||||
</div>
|
||||
|
||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="group-title m-0">
|
||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
||||
</h2>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<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="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-pad text-sm">
|
||||
<li th:each="article : ${section.articles}" class="flex items-center gap-2">
|
||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">Статья</a>
|
||||
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
|
||||
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
||||
<div data-section-list>
|
||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4"
|
||||
th:attr="data-section-id=${section.id}">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="group-title m-0">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
|
||||
title="Перетащите, чтобы изменить порядок разделов">⠿</span>
|
||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
||||
</h2>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<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="id" th:value="${article.id}">
|
||||
<input type="hidden" name="published" th:value="${!article.published}">
|
||||
<input type="hidden" name="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
</th:block>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Переименовать раздел</h2>
|
||||
<button class="modal-close" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="title" th:value="${section.title}" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog th:id="|modal-move-${section.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Перенести статьи из раздела</h2>
|
||||
<button class="modal-close" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
|
||||
<div><label class="label">Перенести в раздел</label>
|
||||
<select class="input" name="moveTo">
|
||||
<option th:each="pair : ${sectionChoices}"
|
||||
th:if="${pair['id'] != section.id}"
|
||||
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
||||
</select></div>
|
||||
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
<ul class="list-pad text-sm" data-article-list>
|
||||
<li th:each="article : ${section.articles}" class="flex items-center gap-2"
|
||||
th:attr="data-article-id=${article.id}">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
|
||||
title="Перетащите, чтобы изменить порядок или перенести в другой раздел">⠿</span>
|
||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">Статья</a>
|
||||
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
|
||||
<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="id" th:value="${article.id}">
|
||||
<input type="hidden" name="published" th:value="${!article.published}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
</th:block>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Переименовать раздел</h2>
|
||||
<button class="modal-close" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="title" th:value="${section.title}" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog th:id="|modal-move-${section.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Перенести статьи из раздела</h2>
|
||||
<button class="modal-close" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
|
||||
<div><label class="label">Перенести в раздел</label>
|
||||
<select class="input" name="moveTo">
|
||||
<option th:each="pair : ${sectionChoices}"
|
||||
th:if="${pair['id'] != section.id}"
|
||||
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
||||
</select></div>
|
||||
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="modal-rename-chapter" class="modal">
|
||||
@@ -162,6 +180,8 @@
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/js/content-sort.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title>Материалы — LMS</title></head>
|
||||
<body>
|
||||
<div id="content" th:attr="data-csrf=${csrfToken}">
|
||||
<div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='full'">
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<h1 class="page-title">Учебные материалы</h1>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
||||
@@ -34,7 +34,7 @@
|
||||
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
|
||||
<span class="acc-arrow" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="card-body acc-body">
|
||||
<div class="card-body acc-body" data-section-list>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<h2 class="section-title m-0">
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<div id="content" th:attr="data-csrf=${csrfToken},data-sort-scope='section',data-section-id=${section.id}">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
@@ -37,12 +37,22 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
|
||||
Перетаскивайте статьи за значок ⠿, чтобы менять их порядок. Порядок сохраняется сразу.
|
||||
</div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" id="sort-status" class="sort-status" hidden>
|
||||
<span id="sort-status-text">Изменения не сохранены…</span>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="article : ${section.articles}">
|
||||
<tbody data-article-list>
|
||||
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||||
<td>
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
|
||||
title="Перетащите, чтобы изменить порядок статей">⠿</span>
|
||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">—</a>
|
||||
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
|
||||
@@ -125,7 +135,7 @@
|
||||
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
|
||||
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
||||
<p class="hint">
|
||||
Картинка: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>;
|
||||
Картинка: <code class="code">:::figure assets/имя.png | Рис. 1. Подпись</code>;
|
||||
видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>;
|
||||
спойлер: <code class="code">:::spoiler Решение … :::</code>.
|
||||
</p>
|
||||
@@ -133,6 +143,8 @@
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/js/content-sort.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user