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