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 title: String,
|
||||||
val slug: String,
|
val slug: String,
|
||||||
val position: Int,
|
val position: Int,
|
||||||
|
val published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SectionTreeNode(
|
data class SectionTreeNode(
|
||||||
@@ -26,6 +27,7 @@ data class SectionTreeNode(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val slug: String,
|
val slug: String,
|
||||||
val position: Int,
|
val position: Int,
|
||||||
|
val published: Boolean = true,
|
||||||
val articles: List<ArticleTreeNode>,
|
val articles: List<ArticleTreeNode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ data class ChapterTreeNode(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val slug: String,
|
val slug: String,
|
||||||
val position: Int,
|
val position: Int,
|
||||||
|
val published: Boolean = true,
|
||||||
val sections: List<SectionTreeNode>,
|
val sections: List<SectionTreeNode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ data class ContentArticleView(
|
|||||||
val slug: String,
|
val slug: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val bodyMd: String,
|
val bodyMd: String,
|
||||||
|
val published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ContentImportResult(
|
data class ContentImportResult(
|
||||||
@@ -78,10 +82,14 @@ class ContentService(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun tree(): ContentTree {
|
fun tree(includeHidden: Boolean = false): ContentTree {
|
||||||
val chapterList = chapters.findAllByOrderByPositionAscIdAsc()
|
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
|
||||||
val sectionList = sections.findAllByOrderByPositionAscIdAsc().groupBy { it.chapterId }
|
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
|
||||||
val articleList = articles.findAllByOrderByPositionAscIdAsc().groupBy { it.sectionId }
|
.filter { includeHidden || it.published }
|
||||||
|
.groupBy { it.chapterId }
|
||||||
|
val articleList = articles.findAllByOrderByPositionAscIdAsc()
|
||||||
|
.filter { includeHidden || it.published }
|
||||||
|
.groupBy { it.sectionId }
|
||||||
return ContentTree(
|
return ContentTree(
|
||||||
chapterList.map { ch ->
|
chapterList.map { ch ->
|
||||||
ChapterTreeNode(
|
ChapterTreeNode(
|
||||||
@@ -89,14 +97,16 @@ class ContentService(
|
|||||||
title = ch.title,
|
title = ch.title,
|
||||||
slug = ch.slug,
|
slug = ch.slug,
|
||||||
position = ch.position,
|
position = ch.position,
|
||||||
|
published = ch.published,
|
||||||
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
|
||||||
SectionTreeNode(
|
SectionTreeNode(
|
||||||
id = sec.id.toString(),
|
id = sec.id.toString(),
|
||||||
title = sec.title,
|
title = sec.title,
|
||||||
slug = sec.slug,
|
slug = sec.slug,
|
||||||
position = sec.position,
|
position = sec.position,
|
||||||
|
published = sec.published,
|
||||||
articles = (articleList[sec.id] ?: emptyList()).map { a ->
|
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)
|
@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("Глава не найдена")
|
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
|
||||||
|
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
|
||||||
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: 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("Статья не найдена")
|
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
|
||||||
|
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
|
||||||
return ContentArticleView(
|
return ContentArticleView(
|
||||||
id = a.id.toString(),
|
id = a.id.toString(),
|
||||||
chapterSlug = ch.slug,
|
chapterSlug = ch.slug,
|
||||||
@@ -119,9 +141,31 @@ class ContentService(
|
|||||||
slug = a.slug,
|
slug = a.slug,
|
||||||
title = a.title,
|
title = a.title,
|
||||||
bodyMd = a.bodyMd,
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun listAll(): List<ContentArticleView> {
|
fun listAll(): List<ContentArticleView> {
|
||||||
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
|
||||||
@@ -164,7 +208,10 @@ class ContentService(
|
|||||||
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
|
||||||
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
|
||||||
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
|
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
|
@Transactional
|
||||||
@@ -192,7 +239,10 @@ class ContentService(
|
|||||||
val sec = sections.save(
|
val sec = sections.save(
|
||||||
ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1),
|
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
|
@Transactional
|
||||||
|
|||||||
@@ -33,9 +33,44 @@ class ContentAdminController(private val contentService: ContentService) {
|
|||||||
)
|
)
|
||||||
data class ArticleMovePayload(val sectionId: String)
|
data class ArticleMovePayload(val sectionId: String)
|
||||||
data class TitlePayload(val title: 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 ChapterCreatePayload(val title: String, val slug: String? = null)
|
||||||
data class SectionCreatePayload(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")
|
@PostMapping("/import")
|
||||||
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult {
|
||||||
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
if (file.isEmpty) throw BadRequestException("Выберите файл")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ContentPublicController(
|
|||||||
return ContentArticleViewDto(
|
return ContentArticleViewDto(
|
||||||
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
|
||||||
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
|
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 slug: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val bodyMd: String,
|
val bodyMd: String,
|
||||||
|
val published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/assets/{name}")
|
@GetMapping("/assets/{name}")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ContentChapter(
|
|||||||
@Column(nullable = false, length = 200) var title: String = "",
|
@Column(nullable = false, length = 200) var title: String = "",
|
||||||
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
@Column(nullable = false, unique = true, length = 200) var slug: String = "",
|
||||||
@Column(nullable = false) var position: Int = 0,
|
@Column(nullable = false) var position: Int = 0,
|
||||||
|
@Column(nullable = false) var published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -24,6 +25,7 @@ class ContentSection(
|
|||||||
@Column(nullable = false, length = 200) var title: String = "",
|
@Column(nullable = false, length = 200) var title: String = "",
|
||||||
@Column(nullable = false, length = 200) var slug: String = "",
|
@Column(nullable = false, length = 200) var slug: String = "",
|
||||||
@Column(nullable = false) var position: Int = 0,
|
@Column(nullable = false) var position: Int = 0,
|
||||||
|
@Column(nullable = false) var published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -35,4 +37,5 @@ class ContentArticle(
|
|||||||
@Column(nullable = false, length = 200) var slug: String = "",
|
@Column(nullable = false, length = 200) var slug: String = "",
|
||||||
@Column(nullable = false) var bodyMd: String = "",
|
@Column(nullable = false) var bodyMd: String = "",
|
||||||
@Column(nullable = false) var position: Int = 0,
|
@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")
|
@PostMapping("/articles/{id}/save")
|
||||||
fun save(
|
fun save(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
|
|||||||
@@ -443,20 +443,45 @@ class ApiClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun contentTree(token: String?): ContentTreeNodeDto =
|
fun contentTree(token: String?, all: Boolean = false): ContentTreeNodeDto =
|
||||||
call {
|
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)
|
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||||
.retrieve().body(ContentTreeNodeDto::class.java)
|
.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 {
|
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)
|
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||||
.retrieve().body(ContentArticleDto::class.java)
|
.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> {
|
fun contentTreeList(token: String?): List<ContentArticleDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
|
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ data class ContentChapterNodeDto(
|
|||||||
val title: String = "",
|
val title: String = "",
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val position: Int? = 0,
|
val position: Int? = 0,
|
||||||
|
val published: Boolean = true,
|
||||||
val sections: List<ContentSectionNodeDto> = emptyList(),
|
val sections: List<ContentSectionNodeDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -314,6 +315,7 @@ data class ContentSectionNodeDto(
|
|||||||
val title: String = "",
|
val title: String = "",
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val position: Int? = 0,
|
val position: Int? = 0,
|
||||||
|
val published: Boolean = true,
|
||||||
val articles: List<ContentArticleNodeDto> = emptyList(),
|
val articles: List<ContentArticleNodeDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,6 +325,7 @@ data class ContentArticleNodeDto(
|
|||||||
val title: String = "",
|
val title: String = "",
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val position: Int? = 0,
|
val position: Int? = 0,
|
||||||
|
val published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -335,6 +338,7 @@ data class ContentArticleDto(
|
|||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val bodyMd: String = "",
|
val bodyMd: String = "",
|
||||||
|
val published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import ru.ysu.lms.markdown.MarkdownRenderer
|
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||||
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.ContentArticleDto
|
import ru.ysu.lms.web.api.ContentArticleDto
|
||||||
import ru.ysu.lms.web.api.ContentTreeNodeDto
|
import ru.ysu.lms.web.api.ContentTreeNodeDto
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
class LearnController(
|
class LearnController(
|
||||||
@@ -20,13 +22,16 @@ class LearnController(
|
|||||||
private fun token(session: HttpSession): String? =
|
private fun token(session: HttpSession): String? =
|
||||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
|
||||||
|
private fun isAdmin(session: HttpSession): Boolean =
|
||||||
|
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
|
||||||
|
|
||||||
@GetMapping("/learn")
|
@GetMapping("/learn")
|
||||||
fun index(session: HttpSession, model: Model): String {
|
fun index(session: HttpSession, model: Model): String {
|
||||||
val t = token(session)
|
val t = token(session)
|
||||||
val tree = api.contentTree(t)
|
val admin = isAdmin(session)
|
||||||
|
val tree = api.contentTree(t, all = admin)
|
||||||
model.addAttribute("tree", tree)
|
model.addAttribute("tree", tree)
|
||||||
val isAdmin = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.isAdmin == true
|
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
|
||||||
val articles = if (isAdmin && t != null) api.contentTreeList(t) else emptyList()
|
|
||||||
model.addAttribute("articles", articles)
|
model.addAttribute("articles", articles)
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"sectionChoices",
|
"sectionChoices",
|
||||||
@@ -49,7 +54,7 @@ class LearnController(
|
|||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): 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 ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
|
||||||
model.addAttribute("tree", tree)
|
model.addAttribute("tree", tree)
|
||||||
model.addAttribute("chapter", ch)
|
model.addAttribute("chapter", ch)
|
||||||
@@ -63,7 +68,7 @@ class LearnController(
|
|||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): 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 ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
|
||||||
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
|
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
|
||||||
model.addAttribute("tree", tree)
|
model.addAttribute("tree", tree)
|
||||||
@@ -80,7 +85,11 @@ class LearnController(
|
|||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): 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)))
|
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
|
||||||
return "learn/article"
|
return "learn/article"
|
||||||
}
|
}
|
||||||
@@ -95,6 +104,7 @@ data class LearnArticleView(
|
|||||||
val slug: String,
|
val slug: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val bodyHtml: String,
|
val bodyHtml: String,
|
||||||
|
val published: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView(
|
fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView(
|
||||||
@@ -106,4 +116,5 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV
|
|||||||
slug = slug,
|
slug = slug,
|
||||||
title = title,
|
title = title,
|
||||||
bodyHtml = bodyHtml,
|
bodyHtml = bodyHtml,
|
||||||
|
published = published,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<span class="nav-sep">/</span>
|
<span class="nav-sep">/</span>
|
||||||
<span th:text="${article.title}">Статья</span>
|
<span th:text="${article.title}">Статья</span>
|
||||||
</nav>
|
</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 class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
<span class="nav-sep">/</span>
|
<span class="nav-sep">/</span>
|
||||||
<span th:text="${chapter.title}">Глава</span>
|
<span th:text="${chapter.title}">Глава</span>
|
||||||
</nav>
|
</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">
|
<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">
|
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
||||||
<h2 class="group-title">
|
<h2 class="group-title">
|
||||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
<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>
|
</h2>
|
||||||
<ul class="list-pad text-sm">
|
<ul class="list-pad text-sm">
|
||||||
<li th:each="article : ${section.articles}">
|
<li th:each="article : ${section.articles}">
|
||||||
<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>
|
||||||
</li>
|
</li>
|
||||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
|
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
|
||||||
</div>
|
</div>
|
||||||
</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="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||||
|
|
||||||
@@ -25,8 +29,19 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="card-body acc-body">
|
<div class="card-body acc-body">
|
||||||
<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" th:text="${chapter.title}">Глава</h2>
|
<h2 class="section-title m-0">
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
<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"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</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"
|
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
|
||||||
@@ -44,11 +59,20 @@
|
|||||||
<h3 class="group-title m-0">
|
<h3 class="group-title m-0">
|
||||||
<span class="sub-arrow" aria-hidden="true"></span>
|
<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>
|
<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>
|
</h3>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
|
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
<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"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||||
<button class="btn btn-secondary btn-sm" type="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">
|
<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})}"
|
<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">скрыто</span>
|
||||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
<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"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:data-modal="|modal-edit-article-${article.id}|" onclick="openModalData(this)">Править</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"
|
<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 class="nav-sep">/</span>
|
||||||
<span th:text="${section.title}">Раздел</span>
|
<span th:text="${section.title}">Раздел</span>
|
||||||
</nav>
|
</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">
|
<div class="card card-flush">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Статья</th></tr></thead>
|
<thead><tr><th>Статья</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="article : ${section.articles}">
|
<tr th:each="article : ${section.articles}">
|
||||||
<td><a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
<td>
|
||||||
th:text="${article.title}">—</a></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>
|
||||||
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
|
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user