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:
2026-09-16 18:29:44 +03:00
parent aa1fb5c791
commit 30b8064718
13 changed files with 225 additions and 26 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>