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:
@@ -70,6 +70,10 @@ assets/ # картинки для md
|
||||
|
||||
Материалы видны всем; для редактирования — «Материалы» с правами администратора.
|
||||
|
||||
Импорт бывает в двух режимах: с галочкой «Удалять разделы и статьи, которых нет в архиве»
|
||||
(синхронизация — архив считается источником истины) и без неё (merge — только добавить и
|
||||
обновить, правки в приложении сохраняются). Для догрузки отдельных статей используйте merge.
|
||||
|
||||
Медиа в материалах: картинки и видео кладутся в `assets/` архива и вставляются директивами
|
||||
`:::figure assets/имя.png | Подпись` и `:::video assets/имя.mp4 | Подпись`. Лимиты: картинки —
|
||||
до 10 МБ (`png/jpg/jpeg/gif/svg/webp`), видео — до 100 МБ (`mp4/webm/ogg`). Nginx и сервисы
|
||||
|
||||
@@ -85,12 +85,29 @@ printf("Ответ: 42\n");
|
||||
Поддерживаются `mp4` (рекомендуется, кодек H.264), `webm`, `ogg` — до **100 МБ** на файл.
|
||||
Плеер поддерживает перемотку (сервер отдаёт файл частями).
|
||||
|
||||
## Правка прямо на страницах материалов
|
||||
|
||||
Администратору доступны действия не только на главной `/learn`, но и на каждой странице:
|
||||
|
||||
* **статья** — «Править» (модальное окно с markdown), «Скрыть/Опубликовать», «Удалить»,
|
||||
а также навигация **← предыдущая / следующая →** (работает и между разделами) и
|
||||
**боковое содержание** со всеми главами, разделами и статьями;
|
||||
* **раздел** — «+ Статья», «Переименовать раздел», «Скрыть/Опубликовать»,
|
||||
«Перенести статьи и удалить», «Удалить раздел»; у каждой статьи в списке — «Править»
|
||||
(открывает статью с готовым окном правки), скрытие и удаление;
|
||||
* **глава** — «+ Раздел», «Переименовать главу», «Скрыть/Опубликовать», «Удалить главу»;
|
||||
у каждого раздела — переименование, скрытие, перенос статей и удаление, у каждой статьи — «Править».
|
||||
|
||||
## Импорт: что происходит с материалами
|
||||
|
||||
* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи).
|
||||
* Разделы и статьи, которых в архиве нет, но они есть в **главах из архива**, удаляются —
|
||||
архив считается источником истины (в уведомлении пишется «удалено устаревших: N»).
|
||||
* Режим **синхронизации** (галка в форме импорта) дополнительно **удаляет** разделы и статьи
|
||||
главы, которых нет в архиве — архив считается источником истины («удалено устаревших: N»).
|
||||
* Без галки импорт работает в режиме **merge**: только добавляет и обновляет то, что есть
|
||||
в архиве. Так можно догрузить отдельные статьи, **не затрагивая правки, сделанные в приложении**.
|
||||
* Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную.
|
||||
* Минимальный архив (например, только один раздел + `assets/`) безопасно импортировать
|
||||
в режиме merge: остальные материалы останутся как есть.
|
||||
* Ссылки вида `assets/имя` автоматически заменяются на публичный адрес
|
||||
`/api/v1/content/assets/...`; сами файлы складываются в хранилище.
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ class ContentService(
|
||||
* картинки в assets/.
|
||||
*/
|
||||
@Transactional
|
||||
fun importZip(filename: String, bytes: ByteArray): ContentImportResult {
|
||||
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Ожидается .zip архив")
|
||||
}
|
||||
@@ -509,8 +509,10 @@ class ContentService(
|
||||
artCount += 1
|
||||
}
|
||||
|
||||
// Синхронизация: в главах из архива удаляем разделы и статьи, которых в архиве нет.
|
||||
for (chapterId in touchedChapters) {
|
||||
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
|
||||
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
|
||||
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
|
||||
for (chapterId in if (sync) touchedChapters else emptySet()) {
|
||||
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
|
||||
if (section.id in seenSections) {
|
||||
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
|
||||
|
||||
@@ -79,9 +79,12 @@ class ContentAdminController(private val contentService: ContentService) {
|
||||
}
|
||||
|
||||
@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("Выберите файл")
|
||||
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes)
|
||||
return contentService.importZip(file.originalFilename ?: "content.zip", file.bytes, sync)
|
||||
}
|
||||
|
||||
@GetMapping("/articles")
|
||||
|
||||
@@ -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, "Сессия истекла, войдите заново")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,16 +35,7 @@ class LearnController(
|
||||
private fun isAdmin(session: HttpSession): Boolean =
|
||||
(session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
|
||||
|
||||
@GetMapping("/learn")
|
||||
fun index(session: HttpSession, model: Model): String {
|
||||
val t = token(session)
|
||||
val admin = isAdmin(session)
|
||||
val tree = api.contentTree(t, all = admin)
|
||||
model.addAttribute("tree", tree)
|
||||
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
|
||||
model.addAttribute("articles", articles)
|
||||
model.addAttribute(
|
||||
"sectionChoices",
|
||||
private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> =
|
||||
tree.chapters.flatMap { chapter ->
|
||||
chapter.sections.map { section ->
|
||||
mapOf(
|
||||
@@ -43,8 +44,17 @@ class LearnController(
|
||||
"label" to "${chapter.title} / ${section.title}",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/learn")
|
||||
fun index(session: HttpSession, model: Model): String {
|
||||
val t = token(session)
|
||||
val admin = isAdmin(session)
|
||||
val tree = api.contentTree(t, all = admin)
|
||||
model.addAttribute("tree", tree)
|
||||
val articles = if (admin && t != null) api.contentTreeList(t) else emptyList()
|
||||
model.addAttribute("articles", articles)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<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>
|
||||
@@ -13,11 +15,115 @@
|
||||
<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>
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user