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

@@ -70,6 +70,10 @@ assets/ # картинки для md
Материалы видны всем; для редактирования — «Материалы» с правами администратора. Материалы видны всем; для редактирования — «Материалы» с правами администратора.
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и
обновить, правки в приложении сохраняются). Для догрузки отдельных статей используйте merge.
Медиа в материалах: картинки и видео кладутся в `assets/` архива и вставляются директивами Медиа в материалах: картинки и видео кладутся в `assets/` архива и вставляются директивами
`:::figure assets/имя.png | Подпись` и `:::video assets/имя.mp4 | Подпись`. Лимиты: картинки — `:::figure assets/имя.png | Подпись` и `:::video assets/имя.mp4 | Подпись`. Лимиты: картинки —
до 10 МБ (`png/jpg/jpeg/gif/svg/webp`), видео — до 100 МБ (`mp4/webm/ogg`). Nginx и сервисы до 10 МБ (`png/jpg/jpeg/gif/svg/webp`), видео — до 100 МБ (`mp4/webm/ogg`). Nginx и сервисы

View File

@@ -85,12 +85,29 @@ printf("Ответ: 42\n");
Поддерживаются `mp4` (рекомендуется, кодек H.264), `webm`, `ogg` — до **100 МБ** на файл. Поддерживаются `mp4` (рекомендуется, кодек H.264), `webm`, `ogg` — до **100 МБ** на файл.
Плеер поддерживает перемотку (сервер отдаёт файл частями). Плеер поддерживает перемотку (сервер отдаёт файл частями).
## Правка прямо на страницах материалов
Администратору доступны действия не только на главной `/learn`, но и на каждой странице:
* **статья** — «Править» (модальное окно с markdown), «Скрыть/Опубликовать», «Удалить»,
а также навигация **← предыдущая / следующая →** (работает и между разделами) и
**боковое содержание** со всеми главами, разделами и статьями;
* **раздел** — «+ Статья», «Переименовать раздел», «Скрыть/Опубликовать»,
«Перенести статьи и удалить», «Удалить раздел»; у каждой статьи в списке — «Править»
(открывает статью с готовым окном правки), скрытие и удаление;
* **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»;
у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править».
## Импорт: что происходит с материалами ## Импорт: что происходит с материалами
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). * Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи).
* Разделы и статьи, которых в архиве нет, но они есть в **главах из архива**, удаляются — * Режим **синхронизации** (галка в форме импорта) дополнительно **удаляет** разделы и статьи
архив считается источником истины (в уведомлении пишется «удалено устаревших: N»). главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»).
* Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть
в архиве. Так можно догрузить отдельные статьи, **не затрагивая правки, сделанные в приложении**.
* Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную. * Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную.
* Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать
в режиме merge: остальные материалы останутся как есть.
* Ссылки вида `assets/имя` автоматически заменяются на публичный адрес * Ссылки вида `assets/имя` автоматически заменяются на публичный адрес
`/api/v1/content/assets/...`; сами файлы складываются в хранилище. `/api/v1/content/assets/...`; сами файлы складываются в хранилище.

View File

@@ -400,7 +400,7 @@ class ContentService(
* картинки в assets/. * картинки в assets/.
*/ */
@Transactional @Transactional
fun importZip(filename: String, bytes: ByteArray): ContentImportResult { fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) { if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив") throw BadRequestException("Ожидается .zip архив")
} }
@@ -509,8 +509,10 @@ class ContentService(
artCount += 1 artCount += 1
} }
// Синхронизация: в главах из архива удаляем разделы и статьи, которых в архиве нет. // Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
for (chapterId in touchedChapters) { // которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
for (chapterId in if (sync) touchedChapters else emptySet()) {
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) { for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
if (section.id in seenSections) { if (section.id in seenSections) {
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) { for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {

View File

@@ -79,9 +79,12 @@ class ContentAdminController(private val contentService: ContentService) {
} }
@PostMapping("/import") @PostMapping("/import")
fun import(@RequestParam("file") file: MultipartFile): ContentImportResult { fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(value = "sync", defaultValue = "true") sync: Boolean,
): ContentImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл") if (file.isEmpty) throw BadRequestException("Выберите файл")
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes) return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync)
} }
@GetMapping("/articles") @GetMapping("/articles")

View File

@@ -29,6 +29,7 @@ class ContentAdminController(
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") sync: Boolean,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String { ): String {
@@ -37,12 +38,13 @@ class ContentAdminController(
return "redirect:/admin/content" return "redirect:/admin/content"
} }
return try { 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 "" val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
redirect.addFlashAttribute( redirect.addFlashAttribute(
"notice", "notice",
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " + "Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
"статей ${result.articles}, медиафайлов ${result.assets}$removedNote", "статей ${result.articles}, медиафайлов ${result.assets}$removedNote$modeNote",
) )
"redirect:/admin/content" "redirect:/admin/content"
} catch (ex: ApiException) { } catch (ex: ApiException) {
@@ -64,6 +66,7 @@ class ContentAdminController(
@PathVariable kind: String, @PathVariable kind: String,
@RequestParam id: String, @RequestParam id: String,
@RequestParam published: Boolean, @RequestParam published: Boolean,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
@@ -72,7 +75,7 @@ class ContentAdminController(
"notice", "notice",
if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов", if (published) "Материал опубликован — теперь его видят студенты" else "Материал скрыт от студентов",
) )
"redirect:/learn" redirectBack(back, "redirect:/learn")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/learn" "redirect:/learn"
@@ -83,12 +86,13 @@ class ContentAdminController(
@PathVariable id: String, @PathVariable id: String,
@RequestParam title: String, @RequestParam title: String,
@RequestParam bodyMd: String, @RequestParam bodyMd: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.updateContentArticle(token(session), id, title, bodyMd) api.updateContentArticle(token(session), id, title, bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена") redirect.addFlashAttribute("notice", "Статья сохранена")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content/articles/$id/edit" "redirect:/admin/content/articles/$id/edit"
@@ -97,12 +101,13 @@ class ContentAdminController(
@PostMapping("/articles/{id}/delete") @PostMapping("/articles/{id}/delete")
fun delete( fun delete(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.deleteContentArticle(token(session), id) api.deleteContentArticle(token(session), id)
redirect.addFlashAttribute("notice", "Статья удалена") redirect.addFlashAttribute("notice", "Статья удалена")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -112,12 +117,13 @@ class ContentAdminController(
fun createChapter( fun createChapter(
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false) slug: String?, @RequestParam(required = false) slug: String?,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.createChapter(token(session), title.trim(), slug) api.createChapter(token(session), title.trim(), slug)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана") redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -127,12 +133,13 @@ class ContentAdminController(
fun renameChapter( fun renameChapter(
@PathVariable id: String, @PathVariable id: String,
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.renameChapter(token(session), id, title.trim()) api.renameChapter(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована") redirect.addFlashAttribute("notice", "Глава переименована")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -141,12 +148,13 @@ class ContentAdminController(
@PostMapping("/chapters/{id}/delete") @PostMapping("/chapters/{id}/delete")
fun deleteChapter( fun deleteChapter(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.deleteChapter(token(session), id) api.deleteChapter(token(session), id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым") redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -157,12 +165,13 @@ class ContentAdminController(
@RequestParam chapterId: String, @RequestParam chapterId: String,
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false) slug: String?, @RequestParam(required = false) slug: String?,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.createSection(token(session), chapterId, title.trim(), slug) api.createSection(token(session), chapterId, title.trim(), slug)
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан") redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -172,12 +181,13 @@ class ContentAdminController(
fun renameSection( fun renameSection(
@PathVariable id: String, @PathVariable id: String,
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.renameSection(token(session), id, title.trim()) api.renameSection(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован") redirect.addFlashAttribute("notice", "Раздел переименован")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -187,6 +197,7 @@ class ContentAdminController(
fun deleteSection( fun deleteSection(
@PathVariable id: String, @PathVariable id: String,
@RequestParam(required = false) moveTo: String?, @RequestParam(required = false) moveTo: String?,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
@@ -197,7 +208,7 @@ class ContentAdminController(
api.deleteSectionWithMove(token(session), id, moveTo) api.deleteSectionWithMove(token(session), id, moveTo)
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён") redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
} }
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -211,6 +222,7 @@ class ContentAdminController(
@RequestParam(required = false) newSectionTitle: String?, @RequestParam(required = false) newSectionTitle: String?,
@RequestParam title: String, @RequestParam title: String,
@RequestParam(required = false, defaultValue = "") bodyMd: String, @RequestParam(required = false, defaultValue = "") bodyMd: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
@@ -221,7 +233,7 @@ class ContentAdminController(
title.trim(), bodyMd, title.trim(), bodyMd,
) )
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана") redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "redirect:/admin/content"
@@ -231,17 +243,26 @@ class ContentAdminController(
fun moveArticle( fun moveArticle(
@PathVariable id: String, @PathVariable id: String,
@RequestParam sectionId: String, @RequestParam sectionId: String,
@RequestParam(required = false) back: String?,
session: HttpSession, session: HttpSession,
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = try { ): String = try {
api.moveArticle(token(session), id, sectionId) api.moveArticle(token(session), id, sectionId)
redirect.addFlashAttribute("notice", "Статья перемещена") redirect.addFlashAttribute("notice", "Статья перемещена")
"redirect:/admin/content" redirectBack(back, "redirect:/admin/content")
} catch (ex: ApiException) { } catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message) redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content" "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 = private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново") ?: 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>() val parts = LinkedMultiValueMap<String, Any>()
parts.add("sync", sync.toString())
parts.add("file", object : ByteArrayResource(bytes) { parts.add("file", object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename 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.SessionKeys
import ru.ysu.lms.web.session.SessionUser 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 @Controller
class LearnController( class LearnController(
private val api: ApiClient, private val api: ApiClient,
@@ -25,6 +35,17 @@ class LearnController(
private fun isAdmin(session: HttpSession): Boolean = private fun isAdmin(session: HttpSession): Boolean =
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true (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") @GetMapping("/learn")
fun index(session: HttpSession, model: Model): String { fun index(session: HttpSession, model: Model): String {
val t = token(session) val t = token(session)
@@ -33,18 +54,7 @@ class LearnController(
model.addAttribute("tree", tree) model.addAttribute("tree", tree)
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList() val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
model.addAttribute("articles", articles) model.addAttribute("articles", articles)
model.addAttribute( model.addAttribute("sectionChoices", sectionChoices(tree))
"sectionChoices",
tree.chapters.flatMap { chapter ->
chapter.sections.map { section ->
mapOf(
"id" to section.id,
"chapterId" to chapter.id,
"label" to "${chapter.title} / ${section.title}",
)
}
},
)
return "learn/index" return "learn/index"
} }
@@ -58,6 +68,9 @@ class LearnController(
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)
model.addAttribute("sectionChoices", sectionChoices(tree))
model.addAttribute("back", "/learn/$chapter")
model.addAttribute("backParent", "/learn")
return "learn/chapter" return "learn/chapter"
} }
@@ -74,6 +87,9 @@ class LearnController(
model.addAttribute("tree", tree) model.addAttribute("tree", tree)
model.addAttribute("chapter", ch) model.addAttribute("chapter", ch)
model.addAttribute("section", sec) model.addAttribute("section", sec)
model.addAttribute("sectionChoices", sectionChoices(tree))
model.addAttribute("back", "/learn/$chapter/$section")
model.addAttribute("backParent", "/learn/$chapter")
return "learn/section" return "learn/section"
} }
@@ -85,12 +101,39 @@ class LearnController(
session: HttpSession, session: HttpSession,
model: Model, model: Model,
): String { ): 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 { 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) { } catch (ex: ApiException) {
return "redirect:/learn" 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("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" return "learn/article"
} }
} }
@@ -104,6 +147,7 @@ data class LearnArticleView(
val slug: String, val slug: String,
val title: String, val title: String,
val bodyHtml: String, val bodyHtml: String,
val bodyMd: String,
val published: Boolean, val published: Boolean,
) )
@@ -116,5 +160,6 @@ fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleV
slug = slug, slug = slug,
title = title, title = title,
bodyHtml = bodyHtml, bodyHtml = bodyHtml,
bodyMd = bodyMd,
published = published, published = published,
) )

View File

@@ -89,6 +89,7 @@ class StudentController(
val subs = api.mySubmissions(t, id) val subs = api.mySubmissions(t, id)
model.addAttribute("submissions", subs.map { it.copyMsk() }) model.addAttribute("submissions", subs.map { it.copyMsk() })
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt)) model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("defenseMsk", DateUtils.formatMsk(lab.defenseAt))
val gid = studentGroupId(t) val gid = studentGroupId(t)
val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null
model.addAttribute("canSeeGrade", myGrade != 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; border-radius: 0.5rem; padding: 0.5rem 0.75rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); font-size: 0.85rem; 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> <head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
<body> <body>
<div id="content"> <div id="content">
<nav class="text-sm mb-4 breadcrumb"> <div class="learn-layout">
<a th:href="@{/learn}" class="nav-link">Материалы</a> <div class="learn-main">
<span class="nav-sep">/</span> <nav class="text-sm mb-4 breadcrumb">
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a> <a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep">/</span> <span class="nav-sep">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link" <a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
th:text="${article.sectionTitle}">Раздел</a> <span class="nav-sep">/</span>
<span class="nav-sep">/</span> <a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
<span th:text="${article.title}">Статья</span> th:text="${article.sectionTitle}">Раздел</a>
</nav> <span class="nav-sep">/</span>
<h1 class="page-title"> <span th:text="${article.title}">Статья</span>
<span th:text="${article.title}">Статья</span> </nav>
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
</h1> <h1 class="page-title">
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div> <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> </div>
</body> </body>
</html> </html>

View File

@@ -8,29 +8,160 @@
<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"> <h1 class="page-title">
<span th:text="${chapter.title}">Глава</span> <span th:text="${chapter.title}">Глава</span>
<span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span> <span th:if="${!chapter.published}" class="badge badge-warn ml-2">скрыто</span>
</h1> </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 th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
В этой главе пока нет разделов. В этой главе пока нет разделов.
</div> </div>
<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"> <div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a> <h2 class="group-title m-0">
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span> <a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
</h2> <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"> <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})}" <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> <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>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li> <li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul> </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> </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> </div>
</body> </body>
</html> </html>

View File

@@ -198,6 +198,14 @@
</p> </p>
<div><label class="label" for="file">Архив</label> <div><label class="label" for="file">Архив</label>
<input class="input" id="file" name="file" type="file" accept=".zip" required></div> <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> <button class="btn btn-primary" type="submit">Импортировать</button>
</form> </form>
</div> </div>

View File

@@ -10,13 +10,36 @@
<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"> <h1 class="page-title">
<span th:text="${section.title}">Раздел</span> <span th:text="${section.title}">Раздел</span>
<span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span> <span th:if="${!section.published}" class="badge badge-warn ml-2">скрыто</span>
</h1> </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"> <div class="card card-flush">
<table> <table>
<thead><tr><th>Статья</th></tr></thead> <thead><tr><th>Статья</th><th class="th-w-md" data-nosort>Действия</th></tr></thead>
<tbody> <tbody>
<tr th:each="article : ${section.articles}"> <tr th:each="article : ${section.articles}">
<td> <td>
@@ -24,11 +47,91 @@
th:text="${article.title}">—</a> 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 ml-1">скрыто</span>
</td> </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>
<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> </tbody>
</table> </table>
</div> </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> </div>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,11 @@
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1> <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> <span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div> </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="${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>

View File

@@ -16,7 +16,11 @@
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span> <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> <span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
</div> </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> </div>
<div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Опубликованных лабораторных пока нет.</div> <div th:if="${#lists.isEmpty(cards)}" class="card card-body text-muted">Опубликованных лабораторных пока нет.</div>