Learn UI: prev/next navigation, sidebar outline, editing on every page; merge import; defense date

- article page: ← / → navigation across the whole material tree (including
  between sections), sticky sidebar outline with the current article highlighted
- editing on chapter, section and article pages: rename, publish/hide, delete,
  create section/article, move articles while deleting a section; forms return
  to the page they were opened from (validated internal `back` parameter)
- article page opens the edit dialog directly via ?edit=1 (used by "Править"
  links on section/chapter pages)
- import: optional sync flag — with it stale sections/articles of archived
  chapters are deleted (previous behaviour), without it import only adds and
  updates, so material edited in the UI is not overwritten
- student area: lab page and lab cards now show the defense date next to the
  submission deadline
- fix SpEL crash on the student lab page (`and` with nullable strings)
This commit is contained in:
2026-09-17 17:14:16 +03:00
parent 396221047c
commit 09721747d1
15 changed files with 533 additions and 61 deletions

View File

@@ -29,6 +29,7 @@ class ContentAdminController(
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") sync: Boolean,
session: HttpSession,
redirect: RedirectAttributes,
): String {
@@ -37,12 +38,13 @@ class ContentAdminController(
return "redirect:/admin/content"
}
return try {
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes)
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes, sync)
val modeNote = if (sync) "" else " (режим «только добавить», удаления не было)"
val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
redirect.addFlashAttribute(
"notice",
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
"статей ${result.articles}, медиафайлов ${result.assets}$removedNote",
"статей ${result.articles}, медиафайлов ${result.assets}$removedNote$modeNote",
)
"redirect:/admin/content"
} catch (ex: ApiException) {
@@ -64,6 +66,7 @@ class ContentAdminController(
@PathVariable kind: String,
@RequestParam id: String,
@RequestParam published: Boolean,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
@@ -72,7 +75,7 @@ class ContentAdminController(
"notice",
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
)
"redirect:/learn"
redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/learn"
@@ -83,12 +86,13 @@ class ContentAdminController(
@PathVariable id: String,
@RequestParam title: String,
@RequestParam bodyMd: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.updateContentArticle(token(session), id, title, bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content/articles/$id/edit"
@@ -97,12 +101,13 @@ class ContentAdminController(
@PostMapping("/articles/{id}/delete")
fun delete(
@PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteContentArticle(token(session), id)
redirect.addFlashAttribute("notice", "Статья удалена")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -112,12 +117,13 @@ class ContentAdminController(
fun createChapter(
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createChapter(token(session), title.trim(), slug)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -127,12 +133,13 @@ class ContentAdminController(
fun renameChapter(
@PathVariable id: String,
@RequestParam title: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.renameChapter(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -141,12 +148,13 @@ class ContentAdminController(
@PostMapping("/chapters/{id}/delete")
fun deleteChapter(
@PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteChapter(token(session), id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -157,12 +165,13 @@ class ContentAdminController(
@RequestParam chapterId: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createSection(token(session), chapterId, title.trim(), slug)
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -172,12 +181,13 @@ class ContentAdminController(
fun renameSection(
@PathVariable id: String,
@RequestParam title: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.renameSection(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -187,6 +197,7 @@ class ContentAdminController(
fun deleteSection(
@PathVariable id: String,
@RequestParam(required = false) moveTo: String?,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
@@ -197,7 +208,7 @@ class ContentAdminController(
api.deleteSectionWithMove(token(session), id, moveTo)
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
}
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -211,6 +222,7 @@ class ContentAdminController(
@RequestParam(required = false) newSectionTitle: String?,
@RequestParam title: String,
@RequestParam(required = false, defaultValue = "") bodyMd: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
@@ -221,7 +233,7 @@ class ContentAdminController(
title.trim(), bodyMd,
)
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
@@ -231,17 +243,26 @@ class ContentAdminController(
fun moveArticle(
@PathVariable id: String,
@RequestParam sectionId: String,
@RequestParam(required = false) back: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.moveArticle(token(session), id, sectionId)
redirect.addFlashAttribute("notice", "Статья перемещена")
"redirect:/admin/content"
redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
private fun redirectBack(back: String?, fallback: String): String =
if (!back.isNullOrBlank() && back.startsWith("/") && !back.startsWith("//")) {
"redirect:$back"
} else {
fallback
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")

View File

@@ -502,8 +502,14 @@ class ApiClient(
}
}
fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto {
fun importContent(
token: String,
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add("sync", sync.toString())
parts.add("file", object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename
})

View File

@@ -13,6 +13,16 @@ import ru.ysu.lms.web.api.ContentTreeNodeDto
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
/** Ссылка на статью для навигации «назад/далее» и бокового содержания. */
data class LearnArticleLink(
val chapterSlug: String = "",
val chapterTitle: String = "",
val sectionSlug: String = "",
val sectionTitle: String = "",
val slug: String = "",
val title: String = "",
)
@Controller
class LearnController(
private val api: ApiClient,
@@ -25,6 +35,17 @@ class LearnController(
private fun isAdmin(session: HttpSession): Boolean =
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> =
tree.chapters.flatMap { chapter ->
chapter.sections.map { section ->
mapOf(
"id" to section.id,
"chapterId" to chapter.id,
"label" to "${chapter.title} / ${section.title}",
)
}
}
@GetMapping("/learn")
fun index(session: HttpSession, model: Model): String {
val t = token(session)
@@ -33,18 +54,7 @@ class LearnController(
model.addAttribute("tree", tree)
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
model.addAttribute("articles", articles)
model.addAttribute(
"sectionChoices",
tree.chapters.flatMap { chapter ->
chapter.sections.map { section ->
mapOf(
"id" to section.id,
"chapterId" to chapter.id,
"label" to "${chapter.title} / ${section.title}",
)
}
},
)
model.addAttribute("sectionChoices", sectionChoices(tree))
return "learn/index"
}
@@ -58,6 +68,9 @@ class LearnController(
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
model.addAttribute("tree", tree)
model.addAttribute("chapter", ch)
model.addAttribute("sectionChoices", sectionChoices(tree))
model.addAttribute("back", "/learn/$chapter")
model.addAttribute("backParent", "/learn")
return "learn/chapter"
}
@@ -74,6 +87,9 @@ class LearnController(
model.addAttribute("tree", tree)
model.addAttribute("chapter", ch)
model.addAttribute("section", sec)
model.addAttribute("sectionChoices", sectionChoices(tree))
model.addAttribute("back", "/learn/$chapter/$section")
model.addAttribute("backParent", "/learn/$chapter")
return "learn/section"
}
@@ -85,12 +101,39 @@ class LearnController(
session: HttpSession,
model: Model,
): String {
val admin = isAdmin(session)
val tree: ContentTreeNodeDto = api.contentTree(token(session), all = admin)
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
if (sec.articles.none { it.slug == article }) return "redirect:/learn"
val a: ContentArticleDto = try {
api.contentArticle(token(session), chapter, section, article, all = isAdmin(session))
api.contentArticle(token(session), chapter, section, article, all = admin)
} catch (ex: ApiException) {
return "redirect:/learn"
}
// Плоский список всех статей по порядку — для навигации «назад/далее» через разделы.
val flat = tree.chapters.flatMap { c ->
c.sections.flatMap { s ->
s.articles.map { art ->
LearnArticleLink(
chapterSlug = c.slug, chapterTitle = c.title,
sectionSlug = s.slug, sectionTitle = s.title,
slug = art.slug, title = art.title,
)
}
}
}
val index = flat.indexOfFirst {
it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article
}
model.addAttribute("tree", tree)
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
model.addAttribute("prevArticle", flat.getOrNull(index - 1))
model.addAttribute("nextArticle", flat.getOrNull(index + 1))
model.addAttribute("back", "/learn/$chapter/$section/$article")
model.addAttribute("backParent", "/learn/$chapter/$section")
return "learn/article"
}
}
@@ -104,6 +147,7 @@ data class LearnArticleView(
val slug: String,
val title: String,
val bodyHtml: String,
val bodyMd: String,
val published: Boolean,
)
@@ -116,5 +160,6 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV
slug = slug,
title = title,
bodyHtml = bodyHtml,
bodyMd = bodyMd,
published = published,
)

View File

@@ -89,6 +89,7 @@ class StudentController(
val subs = api.mySubmissions(t, id)
model.addAttribute("submissions", subs.map { it.copyMsk() })
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("defenseMsk", DateUtils.formatMsk(lab.defenseAt))
val gid = studentGroupId(t)
val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null
model.addAttribute("canSeeGrade", myGrade != null)

View File

@@ -367,3 +367,20 @@ th.th-filterable { padding-bottom: 0.35rem; }
border-radius: 0.5rem; padding: 0.5rem 0.75rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem;
}
/* Чтение материалов: контент + боковое содержание */
.learn-layout { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.5rem; align-items: start; }
.learn-side { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow: auto; font-size: 0.875rem; }
.side-chapter { font-weight: 600; margin: 0.6rem 0 0.2rem; }
.side-section { margin: 0.25rem 0 0.5rem 0.75rem; }
.side-section > a { color: var(--c-text-muted); }
.side-articles { list-style: none; margin: 0.2rem 0 0 0.75rem; padding: 0; }
.side-articles li { margin: 0.1rem 0; }
.side-articles a { display: inline-block; }
.side-current { font-weight: 600; text-decoration: underline; }
.article-nav { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.article-nav .ml-auto, .article-nav a:last-child:not(:only-child) { margin-left: auto; }
@media (max-width: 1000px) {
.learn-layout { grid-template-columns: 1fr; }
.learn-side { position: static; max-height: none; order: -1; }
}

View File

@@ -3,21 +3,127 @@
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
<body>
<div id="content">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<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="learn-layout">
<div class="learn-main">
<nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
<span class="nav-sep">/</span>
<span th:text="${article.title}">Статья</span>
</nav>
<h1 class="page-title">
<span th:text="${article.title}">Статья</span>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-edit-article')">Править</button>
<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}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть от студентов' : 'Опубликовать'">Скрыть</button>
</form>
<a class="btn btn-secondary btn-sm"
th:href="@{/learn/{c}(c=${article.chapterSlug})}">К главе</a>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить статью</button>
</form>
</div>
<nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary btn-sm"
th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}"
th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-secondary btn-sm ml-auto"
th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}"
th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
<nav class="article-nav mt-6" th:if="${prevArticle != null or nextArticle != null}">
<a th:if="${prevArticle != null}" class="btn btn-secondary"
th:href="@{/learn/{c}/{s}/{a}(c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}">
← <span th:text="${prevArticle.title}">Предыдущая</span>
</a>
<span th:if="${prevArticle == null}"></span>
<a th:if="${nextArticle != null}" class="btn btn-primary ml-auto"
th:href="@{/learn/{c}/{s}/{a}(c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}">
<span th:text="${nextArticle.title}">Следующая</span> →
</a>
</nav>
</div>
<aside class="learn-side">
<div class="card card-body">
<p class="text-sm text-muted mb-2">Содержание</p>
<div th:each="c : ${tree.chapters}">
<div class="side-chapter">
<a th:href="@{/learn/{c}(c=${c.slug})}" th:text="${c.title}">Глава</a>
<span th:if="${!c.published}" class="badge badge-warn">скрыто</span>
</div>
<div th:each="s : ${c.sections}" class="side-section">
<a th:href="@{/learn/{c}/{s}(c=${c.slug}, s=${s.slug})}" th:text="${s.title}">Раздел</a>
<span th:if="${!s.published}" class="badge badge-warn">скрыто</span>
<ul class="side-articles">
<li th:each="a : ${s.articles}">
<a th:href="@{/learn/{c}/{s}/{a2}(c=${c.slug}, s=${s.slug}, a2=${a.slug})}"
th:text="${a.title}"
th:classappend="${a.slug == article.slug and s.slug == article.sectionSlug and c.slug == article.chapterSlug} ? 'side-current'">Статья</a>
<span th:if="${!a.published}" class="badge badge-warn">скрыто</span>
</li>
</ul>
</div>
</div>
</div>
</aside>
</div>
<dialog id="modal-edit-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка статьи</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-edit-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${article.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="artTitle">Заголовок</label>
<input class="input" id="artTitle" name="title" th:value="${article.title}" required></div>
<div><label class="label" for="artBody">Текст (markdown)</label>
<textarea class="input" id="artBody" name="bodyMd" rows="18" th:text="${article.bodyMd}"></textarea></div>
<p class="hint">
Картинка с подписью: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>.
Видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>.
Решение под спойлером: <code class="code">:::spoiler Решение … :::</code>.
</p>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<script th:if="${currentUser != null and currentUser.admin}">
if (window.location.search.indexOf('edit=1') >= 0) {
document.addEventListener('DOMContentLoaded', function () { openModal('modal-edit-article'); });
}
</script>
</div>
</body>
</html>
</html>

View File

@@ -8,29 +8,160 @@
<span class="nav-sep">/</span>
<span th:text="${chapter.title}">Глава</span>
</nav>
<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="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-section')">+ Раздел</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-chapter')">Переименовать главу</button>
<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}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${chapter.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${chapter.published} ? 'Скрыть главу' : 'Опубликовать главу'">Скрыть</button>
</form>
<form class="inline-form" th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button>
</form>
</div>
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
В этой главе пока нет разделов.
</div>
<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>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="group-title m-0">
<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>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
<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}">
<input type="hidden" name="back" th:value="${back}">
<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-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</div>
</div>
<ul class="list-pad text-sm">
<li th:each="article : ${section.articles}">
<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 ml-1">скрыто</span>
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
<th:block th:if="${currentUser != null and currentUser.admin}">
<a class="btn btn-secondary btn-sm"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
<th:block th:if="${currentUser != null and currentUser.admin}">
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${section.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog th:id="|modal-move-${section.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи из раздела</h2>
<button class="modal-close" type="button"
th:data-modal="|modal-move-${section.id}|" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел удалится.</p>
<div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo">
<option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
</form>
</div>
</dialog>
</th:block>
</div>
<dialog id="modal-rename-chapter" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-chapter')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="chTitle">Название</label>
<input class="input" id="chTitle" name="title" th:value="${chapter.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog id="modal-new-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый раздел в главе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/create}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="chapterId" th:value="${chapter.id}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="secNewTitle">Название раздела</label>
<input class="input" id="secNewTitle" name="title" required></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
</div>
</body>
</html>

View File

@@ -198,6 +198,14 @@
</p>
<div><label class="label" for="file">Архив</label>
<input class="input" id="file" name="file" type="file" accept=".zip" required></div>
<label class="tone-label">
<input type="checkbox" name="sync" value="true">
Удалять разделы и статьи, которых нет в архиве (синхронизация)
</label>
<p class="hint">
Без этой галочки импорт <b>только добавляет и обновляет</b> материалы из архива —
статьи, отредактированные прямо в приложении, не затрагиваются.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>

View File

@@ -10,13 +10,36 @@
<span class="nav-sep">/</span>
<span th:text="${section.title}">Раздел</span>
</nav>
<h1 class="page-title">
<span th:text="${section.title}">Раздел</span>
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-rename-section')">Переименовать раздел</button>
<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}">
<input type="hidden" name="back" th:value="${back}">
<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" onclick="openModal('modal-move-section')">Перенести статьи и удалить</button>
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button>
</form>
</div>
<div class="card card-flush">
<table>
<thead><tr><th>Статья</th></tr></thead>
<thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<td>
@@ -24,11 +47,91 @@
th:text="${article.title}">—</a>
<span th:if="${!article.published}" class="badge badge-warn ml-1">скрыто</span>
</td>
<td class="text-right actions-cell">
<a class="btn btn-secondary btn-sm"
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="id" th:value="${article.id}">
<input type="hidden" name="published" th:value="${!article.published}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-sm" type="submit"
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
</form>
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
onsubmit="return confirm('Удалить статью?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td colspan="2" class="empty">Статей пока нет.</td></tr>
</tbody>
</table>
</div>
<dialog id="modal-rename-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-rename-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="secTitle">Название</label>
<input class="input" id="secTitle" name="title" th:value="${section.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<dialog id="modal-move-section" class="modal">
<div class="modal-header">
<h2 class="modal-title">Перенести статьи и удалить раздел</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-move-section')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="back" th:value="${backParent}">
<p class="text-sm text-muted">Выберите раздел-приёмник: его статьи перенесутся туда, а текущий раздел удалится.</p>
<div><label class="label" for="moveTo">Перенести в раздел</label>
<select class="input" id="moveTo" name="moveTo">
<option th:each="pair : ${sectionChoices}"
th:if="${pair['id'] != section.id}"
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить</button>
</form>
</div>
</dialog>
<dialog id="modal-new-article" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая статья в разделе</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new-article')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/create}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="sectionId" th:value="${section.id}">
<input type="hidden" name="back" th:value="${back}">
<div><label class="label" for="naTitle">Заголовок</label>
<input class="input" id="naTitle" name="title" required></div>
<div><label class="label" for="naBody">Текст (markdown)</label>
<textarea class="input" id="naBody" name="bodyMd" rows="14"></textarea></div>
<p class="hint">
Картинка: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>;
видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>;
спойлер: <code class="code">:::spoiler Решение … :::</code>.
</p>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
</div>
</body>
</html>
</html>

View File

@@ -8,7 +8,11 @@
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
<p class="text-sm text-muted mb-6">
<span th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</span>
<span th:if="${deadlineMsk != null and defenseMsk != null}" class="nav-sep">·</span>
<span th:if="${defenseMsk}">Защита: <b th:text="${defenseMsk}"></b> МСК</span>
</p>
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>

View File

@@ -16,7 +16,11 @@
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
</div>
<p class="text-sm text-muted mt-2" th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</p>
<p class="text-sm text-muted mt-2">
<span th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</span>
<span th:if="${c.deadlineMsk != null and c.defenseMsk != null}" class="nav-sep">·</span>
<span th:if="${c.defenseMsk}" th:text="|Защита: ${c.defenseMsk} МСК|">—</span>
</p>
</div>
</div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Опубликованных лабораторных пока нет.</div>