Content: publish/hide chapters, sections and articles
- migration V5 adds published flag (default true) to chapters/sections/articles
- ContentService: tree(includeHidden) filters, treeAll(), findArticle(includeHidden),
set*Published; public tree/article expose only published items
- admin API: GET /tree/all, GET /all/{chapter}/{section}/{article}, PUT
/{chapters|sections|articles}/{id}/published
- web: learn pages for admins load the full tree, show "скрыто" badges and
publish/hide buttons for every chapter, section and article; hidden articles
open in preview for admins, guests get "not found"; article view carries
published flag
This commit is contained in:
@@ -19,6 +19,7 @@ data class ArticleTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
data class SectionTreeNode(
|
||||
@@ -26,6 +27,7 @@ data class SectionTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
val articles: List<ArticleTreeNode>,
|
||||
)
|
||||
|
||||
@@ -34,6 +36,7 @@ data class ChapterTreeNode(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val position: Int,
|
||||
val published: Boolean = true,
|
||||
val sections: List<SectionTreeNode>,
|
||||
)
|
||||
|
||||
@@ -48,6 +51,7 @@ data class ContentArticleView(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
data class ContentImportResult(
|
||||
@@ -78,10 +82,14 @@ class ContentService(
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun tree(): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
|
||||
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.chapterId }
|
||||
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
||||
.filter { includeHidden || it.published }
|
||||
.groupBy { it.sectionId }
|
||||
return ContentTree(
|
||||
chapterList.map { ch ->
|
||||
ChapterTreeNode(
|
||||
@@ -89,14 +97,16 @@ class ContentService(
|
||||
title = ch.title,
|
||||
slug = ch.slug,
|
||||
position = ch.position,
|
||||
published = ch.published,
|
||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||
SectionTreeNode(
|
||||
id = sec.id.toString(),
|
||||
title = sec.title,
|
||||
slug = sec.slug,
|
||||
position = sec.position,
|
||||
published = sec.published,
|
||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position)
|
||||
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -105,11 +115,23 @@ class ContentService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Полное дерево (включая скрытые) — только для админки. */
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(chapterSlug: String, sectionSlug: String, articleSlug: String): ContentArticleView {
|
||||
fun treeAll(): ContentTree = tree(includeHidden = true)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findArticle(
|
||||
chapterSlug: String,
|
||||
sectionSlug: String,
|
||||
articleSlug: String,
|
||||
includeHidden: Boolean = false,
|
||||
): ContentArticleView {
|
||||
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
|
||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
|
||||
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт")
|
||||
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
|
||||
return ContentArticleView(
|
||||
id = a.id.toString(),
|
||||
chapterSlug = ch.slug,
|
||||
@@ -119,9 +141,31 @@ class ContentService(
|
||||
slug = a.slug,
|
||||
title = a.title,
|
||||
bodyMd = a.bodyMd,
|
||||
published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setChapterPublished(chapterId: UUID, published: Boolean) {
|
||||
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
|
||||
ch.published = published
|
||||
chapters.save(ch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setSectionPublished(sectionId: UUID, published: Boolean) {
|
||||
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
|
||||
sec.published = published
|
||||
sections.save(sec)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun setArticlePublished(articleId: UUID, published: Boolean) {
|
||||
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
|
||||
a.published = published
|
||||
articles.save(a)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<ContentArticleView> {
|
||||
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||
@@ -164,7 +208,10 @@ class ContentService(
|
||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
||||
return ChapterTreeNode(ch.id.toString(), ch.title, ch.slug, ch.position, emptyList())
|
||||
return ChapterTreeNode(
|
||||
id = ch.id.toString(), title = ch.title, slug = ch.slug,
|
||||
position = ch.position, published = ch.published, sections = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -192,7 +239,10 @@ class ContentService(
|
||||
val sec = sections.save(
|
||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
||||
)
|
||||
return SectionTreeNode(sec.id.toString(), sec.title, sec.slug, sec.position, emptyList())
|
||||
return SectionTreeNode(
|
||||
id = sec.id.toString(), title = sec.title, slug = sec.slug,
|
||||
position = sec.position, published = sec.published, articles = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -33,9 +33,44 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
)
|
||||
data class ArticleMovePayload(val sectionId: String)
|
||||
data class TitlePayload(val title: String)
|
||||
data class PublishedPayload(val published: Boolean)
|
||||
data class ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||
data class SectionCreatePayload(val title: String, val slug: String? = null)
|
||||
|
||||
/** Полное дерево материалов, включая скрытые (для админки). */
|
||||
@GetMapping("/tree/all")
|
||||
fun treeAll() = contentService.treeAll()
|
||||
|
||||
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
|
||||
@GetMapping("/all/{chapter}/{section}/{article}")
|
||||
fun articleAll(
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
): ContentPublicController.ContentArticleViewDto {
|
||||
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
|
||||
return ContentPublicController.ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/chapters/{id}/published")
|
||||
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setChapterPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/sections/{id}/published")
|
||||
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setSectionPublished(id, body.published)
|
||||
}
|
||||
|
||||
@PutMapping("/articles/{id}/published")
|
||||
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
|
||||
contentService.setArticlePublished(id, body.published)
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||
|
||||
@@ -32,7 +32,7 @@ class ContentPublicController(
|
||||
return ContentArticleViewDto(
|
||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd,
|
||||
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class ContentPublicController(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyMd: String,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
@GetMapping("/assets/{name}")
|
||||
|
||||
@@ -14,6 +14,7 @@ class ContentChapter(
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@@ -24,6 +25,7 @@ class ContentSection(
|
||||
@Column(nullable = false, length = 200) var title: String = "",
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@@ -35,4 +37,5 @@ class ContentArticle(
|
||||
@Column(nullable = false, length = 200) var slug: String = "",
|
||||
@Column(nullable = false) var bodyMd: String = "",
|
||||
@Column(nullable = false) var position: Int = 0,
|
||||
@Column(nullable = false) var published: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Публикация учебных материалов: главы, разделы и статьи можно скрывать от студентов.
|
||||
ALTER TABLE chapters ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE sections ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE articles ADD COLUMN published boolean NOT NULL DEFAULT true;
|
||||
@@ -46,6 +46,25 @@ class ContentAdminController(
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{kind}/toggle-published")
|
||||
fun togglePublished(
|
||||
@PathVariable kind: String,
|
||||
@RequestParam id: String,
|
||||
@RequestParam published: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.setContentPublished(token(session), kind, id, published)
|
||||
redirect.addFlashAttribute(
|
||||
"notice",
|
||||
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
|
||||
)
|
||||
"redirect:/learn"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/learn"
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/save")
|
||||
fun save(
|
||||
@PathVariable id: String,
|
||||
|
||||
@@ -443,20 +443,45 @@ class ApiClient(
|
||||
}
|
||||
|
||||
|
||||
fun contentTree(token: String?): ContentTreeNodeDto =
|
||||
fun contentTree(token: String?, all: Boolean = false): ContentTreeNodeDto =
|
||||
call {
|
||||
val spec = client.get().uri("/api/v1/content/tree")
|
||||
val uri = if (all) "/api/v1/content/tree/all" else "/api/v1/content/tree"
|
||||
val spec = client.get().uri(uri)
|
||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||
.retrieve().body(ContentTreeNodeDto::class.java)
|
||||
}
|
||||
|
||||
fun contentArticle(token: String?, chapter: String, section: String, article: String): ContentArticleDto =
|
||||
fun contentArticle(
|
||||
token: String?,
|
||||
chapter: String,
|
||||
section: String,
|
||||
article: String,
|
||||
all: Boolean = false,
|
||||
): ContentArticleDto =
|
||||
call {
|
||||
val spec = client.get().uri("/api/v1/content/{c}/{s}/{a}", chapter, section, article)
|
||||
val uri = if (all) "/api/v1/content/all/{c}/{s}/{a}" else "/api/v1/content/{c}/{s}/{a}"
|
||||
val spec = client.get().uri(uri, chapter, section, article)
|
||||
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||
.retrieve().body(ContentArticleDto::class.java)
|
||||
}
|
||||
|
||||
/** Публикация/скрытие главы, раздела или статьи. kind: chapters|sections|articles */
|
||||
fun setContentPublished(token: String, kind: String, id: String, published: Boolean) {
|
||||
val path = when (kind) {
|
||||
"chapters" -> "/api/v1/content/chapters/{id}/published"
|
||||
"sections" -> "/api/v1/content/sections/{id}/published"
|
||||
"articles" -> "/api/v1/content/articles/{id}/published"
|
||||
else -> throw IllegalArgumentException("Неизвестный тип материала: $kind")
|
||||
}
|
||||
call {
|
||||
client.put().uri(path, id)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(mapOf("published" to published))
|
||||
.retrieve().toBodilessEntity()
|
||||
}
|
||||
}
|
||||
|
||||
fun contentTreeList(token: String?): List<ContentArticleDto> {
|
||||
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
|
||||
return call {
|
||||
|
||||
@@ -305,6 +305,7 @@ data class ContentChapterNodeDto(
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val position: Int? = 0,
|
||||
val published: Boolean = true,
|
||||
val sections: List<ContentSectionNodeDto> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -314,6 +315,7 @@ data class ContentSectionNodeDto(
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val position: Int? = 0,
|
||||
val published: Boolean = true,
|
||||
val articles: List<ContentArticleNodeDto> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -323,6 +325,7 @@ data class ContentArticleNodeDto(
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val position: Int? = 0,
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -335,6 +338,7 @@ data class ContentArticleDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val bodyMd: String = "",
|
||||
val published: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -7,9 +7,11 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
import ru.ysu.lms.web.api.ApiException
|
||||
import ru.ysu.lms.web.api.ContentArticleDto
|
||||
import ru.ysu.lms.web.api.ContentTreeNodeDto
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
import ru.ysu.lms.web.session.SessionUser
|
||||
|
||||
@Controller
|
||||
class LearnController(
|
||||
@@ -20,13 +22,16 @@ class LearnController(
|
||||
private fun token(session: HttpSession): String? =
|
||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
|
||||
private fun isAdmin(session: HttpSession): Boolean =
|
||||
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
|
||||
|
||||
@GetMapping("/learn")
|
||||
fun index(session: HttpSession, model: Model): String {
|
||||
val t = token(session)
|
||||
val tree = api.contentTree(t)
|
||||
val admin = isAdmin(session)
|
||||
val tree = api.contentTree(t, all = admin)
|
||||
model.addAttribute("tree", tree)
|
||||
val isAdmin = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.isAdmin == true
|
||||
val articles = if (isAdmin && t != null) api.contentTreeList(t) else emptyList()
|
||||
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
|
||||
model.addAttribute("articles", articles)
|
||||
model.addAttribute(
|
||||
"sectionChoices",
|
||||
@@ -49,7 +54,7 @@ class LearnController(
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val tree: ContentTreeNodeDto = api.contentTree(token(session))
|
||||
val tree: ContentTreeNodeDto = api.contentTree(token(session), all = isAdmin(session))
|
||||
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
|
||||
model.addAttribute("tree", tree)
|
||||
model.addAttribute("chapter", ch)
|
||||
@@ -63,7 +68,7 @@ class LearnController(
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val tree: ContentTreeNodeDto = api.contentTree(token(session))
|
||||
val tree: ContentTreeNodeDto = api.contentTree(token(session), all = isAdmin(session))
|
||||
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
|
||||
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
|
||||
model.addAttribute("tree", tree)
|
||||
@@ -80,7 +85,11 @@ class LearnController(
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val a: ContentArticleDto = api.contentArticle(token(session), chapter, section, article)
|
||||
val a: ContentArticleDto = try {
|
||||
api.contentArticle(token(session), chapter, section, article, all = isAdmin(session))
|
||||
} catch (ex: ApiException) {
|
||||
return "redirect:/learn"
|
||||
}
|
||||
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
|
||||
return "learn/article"
|
||||
}
|
||||
@@ -95,6 +104,7 @@ data class LearnArticleView(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val bodyHtml: String,
|
||||
val published: Boolean,
|
||||
)
|
||||
|
||||
fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView(
|
||||
@@ -106,4 +116,5 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV
|
||||
slug = slug,
|
||||
title = title,
|
||||
bodyHtml = bodyHtml,
|
||||
published = published,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${article.title}">Статья</h1>
|
||||
<h1 class="page-title">
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
|
||||
</h1>
|
||||
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${chapter.title}">Глава</h1>
|
||||
<h1 class="page-title">
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
|
||||
</h1>
|
||||
|
||||
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
|
||||
В этой главе пока нет разделов.
|
||||
@@ -17,11 +20,13 @@
|
||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
||||
<h2 class="group-title">
|
||||
<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>
|
||||
<ul class="list-pad text-sm">
|
||||
<li th:each="article : ${section.articles}">
|
||||
<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>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="card card-body text-sm text-muted mb-4">
|
||||
Режим администратора: скрытые главы, разделы и статьи помечены значком «скрыто»
|
||||
и видны только вам. Кнопка-значок публикует материал для студентов или снова скрывает его.
|
||||
</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>
|
||||
|
||||
@@ -25,8 +29,19 @@
|
||||
</summary>
|
||||
<div class="card-body acc-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||
<h2 class="section-title m-0">
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
|
||||
</h2>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap items-center gap-2">
|
||||
<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="id" th:value="${chapter.id}">
|
||||
<input type="hidden" name="published" th:value="${!chapter.published}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть главу</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
|
||||
@@ -44,11 +59,20 @@
|
||||
<h3 class="group-title m-0">
|
||||
<span class="sub-arrow" aria-hidden="true"></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 ml-2">скрыто</span>
|
||||
</h3>
|
||||
</summary>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
|
||||
<div class="spacer"></div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||
<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}">
|
||||
<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-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
@@ -64,7 +88,16 @@
|
||||
<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}">
|
||||
<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}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-edit-article-${article.id}|" onclick="openModalData(this)">Править</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
|
||||
|
||||
@@ -10,14 +10,20 @@
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${section.title}">Раздел</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${section.title}">Раздел</h1>
|
||||
<h1 class="page-title">
|
||||
<span th:text="${section.title}">Раздел</span>
|
||||
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
|
||||
</h1>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Статья</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="article : ${section.articles}">
|
||||
<td><a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">—</a></td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user