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 статьи).
|
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + 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 ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
|
||||||
data class ReorderTree(val chapters: List<ReorderChapter> = 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(
|
private data class ParsedArticle(
|
||||||
val path: String,
|
val path: String,
|
||||||
val chapterPos: Int,
|
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? =
|
private fun uuidOrNull(value: String): UUID? =
|
||||||
runCatching { UUID.fromString(value) }.getOrNull()
|
runCatching { UUID.fromString(value) }.getOrNull()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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.ReorderArticles
|
||||||
|
import ru.ysu.lms.academic.content.ReorderSections
|
||||||
import ru.ysu.lms.academic.content.ReorderTree
|
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
|
||||||
@@ -78,6 +80,18 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
contentService.reorder(body)
|
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")
|
@PostMapping("/import")
|
||||||
fun import(
|
fun import(
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ 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.ArticlesReorderDto
|
||||||
import ru.ysu.lms.web.api.ContentReorderDto
|
import ru.ysu.lms.web.api.ContentReorderDto
|
||||||
|
import ru.ysu.lms.web.api.SectionsReorderDto
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -61,6 +63,30 @@ class ContentAdminController(
|
|||||||
return mapOf("ok" to true)
|
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")
|
@PostMapping("/{kind}/toggle-published")
|
||||||
fun togglePublished(
|
fun togglePublished(
|
||||||
@PathVariable kind: String,
|
@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(
|
fun importContent(
|
||||||
token: String,
|
token: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
|
|||||||
@@ -360,6 +360,16 @@ data class ContentReorderArticleDto(
|
|||||||
val id: String = "",
|
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(
|
data class ContentImportResultDto(
|
||||||
val chapters: Int = 0,
|
val chapters: Int = 0,
|
||||||
val sections: Int = 0,
|
val sections: Int = 0,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* Drag & drop для материалов: порядок и перенос между главами/разделами.
|
/* Drag & drop для материалов: порядок и перенос между главами/разделами.
|
||||||
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором
|
Работает в трёх режимах (атрибут data-sort-scope у #content):
|
||||||
состояния и кнопкой «Сохранить порядок» для повторной попытки. */
|
- full — страница «Материалы»: главы, разделы, статьи;
|
||||||
|
- chapter — страница главы: разделы внутри главы и статьи внутри них;
|
||||||
|
- section — страница раздела: статьи внутри раздела.
|
||||||
|
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором состояния. */
|
||||||
(function () {
|
(function () {
|
||||||
var state = null;
|
var state = null;
|
||||||
var marker = null;
|
var marker = null;
|
||||||
@@ -10,12 +13,20 @@
|
|||||||
var hoverTimer = null;
|
var hoverTimer = null;
|
||||||
var hoverTarget = 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) {
|
function itemSelector(kind) {
|
||||||
if (kind === 'chapter') return '[data-chapter-id]';
|
if (kind === 'chapter') return '[data-chapter-id]';
|
||||||
if (kind === 'section') return '[data-section-id]';
|
if (kind === 'section') return '[data-section-id]';
|
||||||
return '[data-article-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 statusBox() { return document.getElementById('sort-status'); }
|
||||||
function statusText() { return document.getElementById('sort-status-text'); }
|
function statusText() { return document.getElementById('sort-status-text'); }
|
||||||
|
|
||||||
@@ -51,11 +62,16 @@
|
|||||||
return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before';
|
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) {
|
function onDragStart(event) {
|
||||||
var handle = event.target.closest('.drag-handle');
|
var handle = event.target.closest('.drag-handle');
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
var element = handle.closest(itemSelector(handle.dataset.dragKind));
|
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 };
|
state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element };
|
||||||
element.classList.add('dragging');
|
element.classList.add('dragging');
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -75,23 +91,23 @@
|
|||||||
var cls = null;
|
var cls = null;
|
||||||
|
|
||||||
if (state.kind === 'article') {
|
if (state.kind === 'article') {
|
||||||
if (article && !isDraggedOrInside(article)) {
|
if (isRealItem(article) && !isDraggedOrInside(article)) {
|
||||||
target = article;
|
target = article;
|
||||||
cls = placement(event, article);
|
cls = placement(event, article);
|
||||||
} else if (section && !isDraggedOrInside(section)) {
|
} else if (isRealItem(section) && !isDraggedOrInside(section)) {
|
||||||
var list = section.querySelector(':scope > [data-article-list]');
|
var list = listFor('article', section);
|
||||||
if (list) { target = list; cls = 'drop-into'; }
|
if (list) { target = list; cls = 'drop-into'; }
|
||||||
}
|
}
|
||||||
} else if (state.kind === 'section') {
|
} else if (state.kind === 'section') {
|
||||||
if (section && section !== state.element && !isDraggedOrInside(section)) {
|
if (isRealItem(section) && section !== state.element && !isDraggedOrInside(section)) {
|
||||||
target = section;
|
target = section;
|
||||||
cls = placement(event, section);
|
cls = placement(event, section);
|
||||||
} else if (chapter && !isDraggedOrInside(chapter)) {
|
} else if (isRealItem(chapter) && !isDraggedOrInside(chapter)) {
|
||||||
var body = chapter.querySelector(':scope > .acc-body');
|
var sectionList = listFor('section', chapter);
|
||||||
if (body) { target = body; cls = 'drop-into'; }
|
if (sectionList) { target = sectionList; cls = 'drop-into'; }
|
||||||
}
|
}
|
||||||
} else if (state.kind === 'chapter') {
|
} else if (state.kind === 'chapter') {
|
||||||
if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) {
|
if (isRealItem(chapter) && chapter !== state.element && !isDraggedOrInside(chapter)) {
|
||||||
target = chapter;
|
target = chapter;
|
||||||
cls = placement(event, chapter);
|
cls = placement(event, chapter);
|
||||||
}
|
}
|
||||||
@@ -147,26 +163,60 @@
|
|||||||
clearMarker();
|
clearMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTree() {
|
function collectArticles(container) {
|
||||||
var content = document.getElementById('content');
|
var out = [];
|
||||||
var chapters = [];
|
var list = container ? container.querySelector('[data-article-list]') : null;
|
||||||
content.querySelectorAll(':scope > details[data-chapter-id]').forEach(function (chapter) {
|
if (list) {
|
||||||
var sections = [];
|
list.querySelectorAll('[data-article-id]').forEach(function (item) {
|
||||||
chapter.querySelectorAll(':scope > .acc-body > details[data-section-id]').forEach(function (section) {
|
out.push({ id: item.getAttribute('data-article-id') });
|
||||||
var articles = [];
|
});
|
||||||
var list = section.querySelector(':scope > [data-article-list]');
|
}
|
||||||
if (list) {
|
return out;
|
||||||
list.querySelectorAll(':scope > [data-article-id]').forEach(function (item) {
|
}
|
||||||
articles.push({ id: item.getAttribute('data-article-id') });
|
|
||||||
});
|
function collectSections(container) {
|
||||||
}
|
var out = [];
|
||||||
sections.push({ id: section.getAttribute('data-section-id'), articles: articles });
|
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 };
|
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() {
|
function markDirty() {
|
||||||
pending = true;
|
pending = true;
|
||||||
showStatus('Изменения не сохранены…');
|
showStatus('Изменения не сохранены…');
|
||||||
@@ -178,13 +228,11 @@
|
|||||||
if (!pending || saving) return;
|
if (!pending || saving) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
var content = document.getElementById('content');
|
|
||||||
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
|
||||||
showStatus('Сохранение…');
|
showStatus('Сохранение…');
|
||||||
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), {
|
fetch(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(collectTree())
|
body: JSON.stringify(collectPayload())
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
pending = false;
|
pending = false;
|
||||||
@@ -208,10 +256,8 @@
|
|||||||
window.addEventListener('beforeunload', function () {
|
window.addEventListener('beforeunload', function () {
|
||||||
if (!pending || saving) return;
|
if (!pending || saving) return;
|
||||||
try {
|
try {
|
||||||
var content = document.getElementById('content');
|
var blob = new Blob([JSON.stringify(collectPayload())], { type: 'application/json' });
|
||||||
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
navigator.sendBeacon(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), blob);
|
||||||
var blob = new Blob([JSON.stringify(collectTree())], { type: 'application/json' });
|
|
||||||
navigator.sendBeacon('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), blob);
|
|
||||||
} catch (e) { /* игнорируем */ }
|
} catch (e) { /* игнорируем */ }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 th:text="${chapter.title} + ' — LMS'">Глава</title></head>
|
<head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head>
|
||||||
<body>
|
<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">
|
<nav class="text-sm mb-4 breadcrumb">
|
||||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||||
<span class="nav-sep">/</span>
|
<span class="nav-sep">/</span>
|
||||||
@@ -34,100 +34,118 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
|
||||||
В этой главе пока нет разделов.
|
В этой главе пока нет разделов.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
<div data-section-list>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
<div th:each="section : ${chapter.sections}" class="card card-body mb-4"
|
||||||
<h2 class="group-title m-0">
|
th:attr="data-section-id=${section.id}">
|
||||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
<h2 class="group-title m-0">
|
||||||
</h2>
|
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
title="Перетащите, чтобы изменить порядок разделов">⠿</span>
|
||||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
</h2>
|
||||||
<input type="hidden" name="id" th:value="${section.id}">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||||
<input type="hidden" name="published" th:value="${!section.published}">
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
<input type="hidden" name="back" th:value="${back}">
|
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||||
<button class="btn btn-sm" type="submit"
|
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||||
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">
|
|
||||||
<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="${section.id}">
|
||||||
<input type="hidden" name="published" th:value="${!article.published}">
|
<input type="hidden" name="published" th:value="${!section.published}">
|
||||||
<input type="hidden" name="back" th:value="${back}">
|
<input type="hidden" name="back" th:value="${back}">
|
||||||
<button class="btn btn-sm" type="submit"
|
<button class="btn btn-sm" type="submit"
|
||||||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||||
</form>
|
</form>
|
||||||
</th:block>
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
</li>
|
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
|
||||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||||
</ul>
|
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}">
|
<ul class="list-pad text-sm" data-article-list>
|
||||||
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
<li th:each="article : ${section.articles}" class="flex items-center gap-2"
|
||||||
<div class="modal-header">
|
th:attr="data-article-id=${article.id}">
|
||||||
<h2 class="modal-title">Переименовать раздел</h2>
|
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||||
<button class="modal-close" type="button"
|
th:attr="data-drag-kind='article',data-drag-id=${article.id}"
|
||||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
|
title="Перетащите, чтобы изменить порядок или перенести в другой раздел">⠿</span>
|
||||||
</div>
|
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||||
<div class="modal-body">
|
th:text="${article.title}">Статья</a>
|
||||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
<input type="hidden" name="back" th:value="${back}">
|
<a class="btn btn-secondary btn-sm"
|
||||||
<div><label class="label">Название</label>
|
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
|
||||||
<input class="input" name="title" th:value="${section.title}" required></div>
|
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
||||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</form>
|
<input type="hidden" name="id" th:value="${article.id}">
|
||||||
</div>
|
<input type="hidden" name="published" th:value="${!article.published}">
|
||||||
</dialog>
|
<input type="hidden" name="back" th:value="${back}">
|
||||||
<dialog th:id="|modal-move-${section.id}|" class="modal">
|
<button class="btn btn-sm" type="submit"
|
||||||
<div class="modal-header">
|
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||||||
<h2 class="modal-title">Перенести статьи из раздела</h2>
|
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||||
<button class="modal-close" type="button"
|
</form>
|
||||||
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
|
</th:block>
|
||||||
</div>
|
</li>
|
||||||
<div class="modal-body">
|
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
|
</ul>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
|
||||||
<input type="hidden" name="back" th:value="${back}">
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
|
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
||||||
<div><label class="label">Перенести в раздел</label>
|
<div class="modal-header">
|
||||||
<select class="input" name="moveTo">
|
<h2 class="modal-title">Переименовать раздел</h2>
|
||||||
<option th:each="pair : ${sectionChoices}"
|
<button class="modal-close" type="button"
|
||||||
th:if="${pair['id'] != section.id}"
|
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||||
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
</div>
|
||||||
</select></div>
|
<div class="modal-body">
|
||||||
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
|
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
||||||
</form>
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</div>
|
<input type="hidden" name="back" th:value="${back}">
|
||||||
</dialog>
|
<div><label class="label">Название</label>
|
||||||
</th:block>
|
<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>
|
</div>
|
||||||
|
|
||||||
<dialog id="modal-rename-chapter" class="modal">
|
<dialog id="modal-rename-chapter" class="modal">
|
||||||
@@ -162,6 +180,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/js/content-sort.js" defer></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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" 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">
|
<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">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<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>
|
||||||
</summary>
|
</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">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||||
<h2 class="section-title m-0">
|
<h2 class="section-title m-0">
|
||||||
<span th:text="${chapter.title}">Глава</span>
|
<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})}">
|
<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>
|
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
|
||||||
<body>
|
<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">
|
<nav class="text-sm mb-4 breadcrumb">
|
||||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||||
<span class="nav-sep">/</span>
|
<span class="nav-sep">/</span>
|
||||||
@@ -37,12 +37,22 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="card card-flush">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
|
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
|
||||||
<tbody>
|
<tbody data-article-list>
|
||||||
<tr th:each="article : ${section.articles}">
|
<tr th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||||||
<td>
|
<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})}"
|
<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 ml-1">скрыто</span>
|
<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>
|
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
|
||||||
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
||||||
<p class="hint">
|
<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">:::video assets/имя.mp4 | Подпись</code>;
|
||||||
спойлер: <code class="code">:::spoiler Решение … :::</code>.
|
спойлер: <code class="code">:::spoiler Решение … :::</code>.
|
||||||
</p>
|
</p>
|
||||||
@@ -133,6 +143,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/js/content-sort.js" defer></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user