UI rework: modal dialogs, inline lab editing, manual creation, content management

- Dashboard: real stats (groups, students, labs, average grade), labs/groups
  summary lists, no placeholder tile
- Lab page: view + edit combined — metadata form in modal, condition rendered
  as markdown, per-level variant tables with add/edit/delete dialogs,
  level rename/delete, student level/variant selectors with group filter
- Labs list: create-lab dialog with manual level entry (code; title lines)
- Groups: create-group dialog, add-student dialog (auto password, toast card)
- Content: public /learn reader is the single place; admin buttons (new
  article with chapter/section create-or-select, rename/delete chapters and
  sections, move articles between sections, import zip) appear for admins;
  removed separate 'Контент' admin section
- Buttons use btn classes instead of nav-link
- Favicon: generated SVG book icon
- Security: CSRF synchronizer token interceptor (replaced spring-security in
  web which broke logback under nested jars), session regenerated on login,
  hardened session cookie, gateway actuator limited to health,info
- Jackson 3: nullable-safe request DTOs for labs (FAIL_ON_NULL_FOR_PRIMITIVES)
- academic: POST /labs (manual create with levels), level add/rename/delete,
  content chapters/sections CRUD, article create/move/delete endpoints
This commit is contained in:
2026-09-11 16:00:56 +03:00
parent 095e337136
commit 006951adae
44 changed files with 2435 additions and 85 deletions

View File

@@ -16,9 +16,13 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AssignVariantPayload
import ru.ysu.lms.web.api.CreateStudentRequestDto
import ru.ysu.lms.web.api.GradeSetPayload
import ru.ysu.lms.web.api.LabCreatePayload
import ru.ysu.lms.web.api.LabImportResultDto
import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.LevelPayload
import ru.ysu.lms.web.api.LevelSavePayload
import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.api.DistributeResultDto
import ru.ysu.lms.web.api.LevelAssignPayload
@@ -30,14 +34,31 @@ import java.nio.charset.StandardCharsets
@Controller
@RequestMapping("/admin")
class AdminController(private val api: ApiClient) {
class AdminController(
private val api: ApiClient,
private val markdown: ru.ysu.lms.markdown.MarkdownRenderer,
) {
@GetMapping
fun dashboard(session: HttpSession, model: Model): String {
val groups = api.listGroups(token(session))
val t = token(session)
val groups = api.listGroups(t)
val labs = api.listLabs(t)
val grades = api.listGrades(t)
model.addAttribute("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount })
model.addAttribute("groups", groups.take(5))
model.addAttribute("groupCount", groups.size)
model.addAttribute("labCount", labs.size)
val gradedByStudent = grades.groupBy { it.studentId }
val averages = gradedByStudent.values.mapNotNull { rows ->
rows.mapNotNull { g -> g.score.toBigDecimalOrNull() }
.takeIf { it.isNotEmpty() }
?.let { nums -> nums.map { it.toDouble() }.average() }
}
model.addAttribute("avgGrade", if (averages.isEmpty()) null else averages.average())
model.addAttribute("publishedLabs", labs.count { it.status == "published" })
model.addAttribute("labs", labs)
model.addAttribute("groups", groups)
return "admin/dashboard"
}
@@ -247,6 +268,9 @@ class AdminController(private val api: ApiClient) {
}
model.addAttribute("lab", lab)
model.addAttribute("groups", groupSections)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
return "admin/lab"
}
@@ -266,6 +290,116 @@ class AdminController(private val api: ApiClient) {
"redirect:/admin/labs/$labId"
}
@PostMapping("/labs/create")
fun createLab(
@RequestParam number: Int,
@RequestParam title: String,
@RequestParam(required = false) deadlineAt: String?,
@RequestParam(required = false) defenseAt: String?,
@RequestParam pointsMax: String,
@RequestParam status: String,
@RequestParam descriptionMd: String,
@RequestParam levels: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
// levels: строки "код;название" по одной на строку
val levelList = levels.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
.mapIndexed { idx, line ->
val parts = line.split(";", limit = 2)
LevelPayload(
code = parts[0].trim(),
title = parts.getOrNull(1)?.trim().takeUnless { it.isNullOrBlank() } ?: parts[0].trim(),
position = idx + 1,
)
}
val created = api.createLab(
token(session),
LabCreatePayload(
number = number,
title = title.trim(),
deadlineAt = deadlineAt?.takeIf { it.isNotBlank() },
defenseAt = defenseAt?.takeIf { it.isNotBlank() },
pointsMax = java.math.BigDecimal(pointsMax),
status = status,
descriptionMd = descriptionMd,
levels = levelList,
),
)
redirect.addFlashAttribute("notice", "Лабораторная №${created.number} создана")
"redirect:/admin/labs/${created.id}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs"
}
@PostMapping("/labs/{labId}/levels/save")
fun saveLevel(
@PathVariable labId: String,
@RequestParam(required = false) levelId: String?,
@RequestParam code: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
if (levelId.isNullOrBlank()) {
api.addLevel(token(session), labId, LevelSavePayload(code, title))
redirect.addFlashAttribute("notice", "Уровень добавлен")
} else {
api.renameLevel(token(session), labId, levelId, title)
redirect.addFlashAttribute("notice", "Уровень обновлён")
}
"redirect:/admin/labs/$labId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId"
}
@PostMapping("/labs/{labId}/levels/{levelId}/delete")
fun deleteLevel(
@PathVariable labId: String,
@PathVariable levelId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteLevel(token(session), labId, levelId)
redirect.addFlashAttribute("notice", "Уровень удалён")
"redirect:/admin/labs/$labId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId"
}
@PostMapping("/groups/{groupId}/students/create")
fun createStudent(
@PathVariable groupId: String,
@RequestParam studentNo: String,
@RequestParam lastName: String,
@RequestParam(required = false) firstName: String?,
@RequestParam(required = false) middleName: String?,
@RequestParam(required = false) basis: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
val result = api.createStudent(
token(session), groupId,
CreateStudentRequestDto(
studentNo = studentNo.trim(),
lastName = lastName.trim(),
firstName = firstName.orEmpty().trim(),
middleName = middleName.orEmpty().trim(),
basis = basis.orEmpty().trim(),
),
)
redirect.addFlashAttribute("createdStudent", result)
"redirect:/admin/groups/$groupId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$groupId"
}
@GetMapping("/labs/{id}/edit")
fun editLabForm(
@PathVariable id: String,

View File

@@ -0,0 +1,216 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.session.SessionKeys
@Controller
@RequestMapping("/admin/content")
class ContentAdminController(
private val api: ApiClient,
) {
@GetMapping
fun index(session: HttpSession): String = "redirect:/learn"
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл")
return "redirect:/admin/content"
}
return try {
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes)
redirect.addFlashAttribute(
"notice",
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, статей ${result.articles}, картинок ${result.assets}",
)
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
}
@PostMapping("/articles/{id}/save")
fun save(
@PathVariable id: String,
@RequestParam title: String,
@RequestParam bodyMd: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.updateContentArticle(token(session), id, title, bodyMd)
redirect.addFlashAttribute("notice", "Статья сохранена")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content/articles/$id/edit"
}
@PostMapping("/articles/{id}/delete")
fun delete(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteContentArticle(token(session), id)
redirect.addFlashAttribute("notice", "Статья удалена")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/chapters/create")
fun createChapter(
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createChapter(token(session), title.trim(), slug)
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/chapters/{id}/rename")
fun renameChapter(
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.renameChapter(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Глава переименована")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/chapters/{id}/delete")
fun deleteChapter(
@PathVariable id: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.deleteChapter(token(session), id)
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/sections/create")
fun createSection(
@RequestParam chapterId: String,
@RequestParam title: String,
@RequestParam(required = false) slug: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createSection(token(session), chapterId, title.trim(), slug)
redirect.addFlashAttribute("notice", "Раздел «${title.trim()}» создан")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/sections/{id}/rename")
fun renameSection(
@PathVariable id: String,
@RequestParam title: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.renameSection(token(session), id, title.trim())
redirect.addFlashAttribute("notice", "Раздел переименован")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/sections/{id}/delete")
fun deleteSection(
@PathVariable id: String,
@RequestParam(required = false) moveTo: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
if (moveTo.isNullOrBlank()) {
api.deleteSection(token(session), id)
redirect.addFlashAttribute("notice", "Раздел удалён вместе со статьями")
} else {
api.deleteSectionWithMove(token(session), id, moveTo)
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
}
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/articles/create")
fun createArticle(
@RequestParam(required = false) chapterId: String?,
@RequestParam(required = false) sectionId: String?,
@RequestParam(required = false) newChapterTitle: String?,
@RequestParam(required = false) newSectionTitle: String?,
@RequestParam title: String,
@RequestParam(required = false, defaultValue = "") bodyMd: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createArticle(
token(session), chapterId, sectionId,
newChapterTitle?.takeIf { it.isNotBlank() },
newSectionTitle?.takeIf { it.isNotBlank() },
title.trim(), bodyMd,
)
redirect.addFlashAttribute("notice", "Статья «${title.trim()}» создана")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
@PostMapping("/articles/{id}/move")
fun moveArticle(
@PathVariable id: String,
@RequestParam sectionId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.moveArticle(token(session), id, sectionId)
redirect.addFlashAttribute("notice", "Статья перемещена")
"redirect:/admin/content"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/content"
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

@@ -291,6 +291,217 @@ class ApiClient(
.retrieve().body(GradeDto::class.java)
}
fun contentTree(token: String?): ContentTreeNodeDto =
call {
val spec = client.get().uri("/api/v1/content/tree")
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve().body(ContentTreeNodeDto::class.java)
}
fun contentArticle(token: String?, chapter: String, section: String, article: String): ContentArticleDto =
call {
val spec = client.get().uri("/api/v1/content/{c}/{s}/{a}", chapter, section, article)
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve().body(ContentArticleDto::class.java)
}
fun contentTreeList(token: String?): List<ContentArticleDto> {
val type = object : ParameterizedTypeReference<List<ContentArticleDto>>() {}
return call {
val spec = client.get().uri("/api/v1/content/articles")
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
.retrieve().body(type)
} ?: emptyList()
}
fun importContent(token: String, filename: String, bytes: ByteArray): ContentImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add("file", object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename
})
return call {
client.post().uri("/api/v1/content/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve().body(ContentImportResultDto::class.java)
}
}
fun updateContentArticle(token: String, articleId: String, title: String, bodyMd: String) {
call {
client.put().uri("/api/v1/content/articles/{id}", articleId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "bodyMd" to bodyMd))
.retrieve().toBodilessEntity()
}
}
fun deleteContentArticle(token: String, articleId: String) {
call {
client.delete().uri("/api/v1/content/articles/{id}", articleId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun contentAsset(name: String): DownloadDto {
val entity = call {
client.get().uri("/api/v1/content/assets/{name}", name)
.retrieve().toEntity(ByteArray::class.java)
}
return DownloadDto(
bytes = entity.body ?: ByteArray(0),
filename = name,
contentType = entity.headers.contentType?.toString() ?: "application/octet-stream",
)
}
fun createLab(token: String, payload: LabCreatePayload): LabDetailDto =
call {
client.post().uri("/api/v1/labs")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(LabDetailDto::class.java)
}
fun addLevel(token: String, labId: String, payload: LevelSavePayload) {
call {
client.post().uri("/api/v1/labs/{id}/levels", labId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().toBodilessEntity()
}
}
fun renameLevel(token: String, labId: String, levelId: String, title: String) {
call {
client.put().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title))
.retrieve().toBodilessEntity()
}
}
fun deleteLevel(token: String, labId: String, levelId: String) {
call {
client.delete().uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun createStudent(token: String, groupId: String, payload: CreateStudentRequestDto): CredentialDto =
call {
client.post().uri("/api/v1/groups/{id}/students", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve().body(CredentialDto::class.java)
}
fun createChapter(token: String, title: String, slug: String?): Map<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
return call {
client.post().uri("/api/v1/content/chapters")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "slug" to slug))
.retrieve().body(type)
}
}
fun renameChapter(token: String, chapterId: String, title: String) {
call {
client.put().uri("/api/v1/content/chapters/{id}", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title))
.retrieve().toBodilessEntity()
}
}
fun deleteChapter(token: String, chapterId: String) {
call {
client.delete().uri("/api/v1/content/chapters/{id}", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun createSection(token: String, chapterId: String, title: String, slug: String?): Map<String, Any> {
val type = object : ParameterizedTypeReference<Map<String, Any>>() {}
return call {
client.post().uri("/api/v1/content/sections?chapterId={cid}", chapterId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title, "slug" to slug))
.retrieve().body(type)
}
}
fun renameSection(token: String, sectionId: String, title: String) {
call {
client.put().uri("/api/v1/content/sections/{id}", sectionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("title" to title))
.retrieve().toBodilessEntity()
}
}
fun deleteSection(token: String, sectionId: String) {
call {
client.delete().uri("/api/v1/content/sections/{id}", sectionId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun deleteSectionWithMove(token: String, sectionId: String, moveTo: String) {
call {
client.delete()
.uri { b -> b.path("/api/v1/content/sections/{id}").queryParam("moveTo", moveTo).build(sectionId) }
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toBodilessEntity()
}
}
fun createArticle(
token: String, chapterId: String?, sectionId: String?,
newChapterTitle: String?, newSectionTitle: String?,
title: String, bodyMd: String,
): ContentArticleDto =
call {
client.post().uri("/api/v1/content/articles")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(
mapOf(
"chapterId" to chapterId,
"sectionId" to sectionId,
"newChapterTitle" to newChapterTitle,
"newSectionTitle" to newSectionTitle,
"title" to title,
"bodyMd" to bodyMd,
),
)
.retrieve().body(ContentArticleDto::class.java)
}
fun moveArticle(token: String, articleId: String, newSectionId: String): ContentArticleDto =
call {
client.post().uri("/api/v1/content/articles/{id}/move", articleId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(ArticleMovePayload(newSectionId))
.retrieve().body(ContentArticleDto::class.java)
}
private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
}

View File

@@ -213,6 +213,105 @@ data class GradeSetPayload(
val score: String,
)
data class LabCreatePayload(
val number: Int,
val title: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: java.math.BigDecimal,
val status: String,
val descriptionMd: String,
val levels: List<LevelPayload>,
)
data class LevelPayload(
val code: String,
val title: String,
val position: Int,
)
data class LevelSavePayload(
val code: String,
val title: String,
val description: String = "",
)
data class LevelRenamePayload(
val title: String,
val description: String = "",
)
data class TitlePayload(val title: String)
data class ChapterCreatePayload(val title: String, val slug: String? = null)
data class SectionCreatePayload(val title: String, val slug: String? = null)
data class ArticleCreatePayload(val sectionId: String, val title: String, val bodyMd: String)
data class ArticleMovePayload(val sectionId: String)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CreateStudentRequestDto(
val studentNo: String,
val lastName: String,
val firstName: String = "",
val middleName: String = "",
val basis: String = "",
val note: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentTreeNodeDto(
val chapters: List<ContentChapterNodeDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentChapterNodeDto(
val id: String = "",
val title: String = "",
val slug: String = "",
val position: Int? = 0,
val sections: List<ContentSectionNodeDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentSectionNodeDto(
val id: String = "",
val title: String = "",
val slug: String = "",
val position: Int? = 0,
val articles: List<ContentArticleNodeDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentArticleNodeDto(
val id: String = "",
val title: String = "",
val slug: String = "",
val position: Int? = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentArticleDto(
val id: String = "",
val chapterSlug: String = "",
val chapterTitle: String = "",
val sectionSlug: String = "",
val sectionTitle: String = "",
val slug: String = "",
val title: String = "",
val bodyMd: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentImportResultDto(
val chapters: Int = 0,
val sections: Int = 0,
val articles: Int = 0,
val assets: Int = 0,
)
class DownloadDto(
val bytes: ByteArray,
val filename: String,

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.web.auth
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
@@ -32,13 +33,13 @@ class AuthPageController(private val api: ApiClient) {
@PostMapping("/login")
fun login(
request: HttpServletRequest,
@RequestParam login: String,
@RequestParam password: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.login(login.trim(), password)
store(session, auth)
store(request, auth)
redirectFor(auth)
} catch (ex: ApiException) {
model.addAttribute("adminExists", api.adminExists())
@@ -57,14 +58,14 @@ class AuthPageController(private val api: ApiClient) {
@PostMapping("/setup")
fun setup(
request: HttpServletRequest,
@RequestParam login: String,
@RequestParam password: String,
@RequestParam password2: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.setup(login.trim(), password, password2)
store(session, auth)
store(request, auth)
"redirect:/admin"
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
@@ -104,7 +105,10 @@ class AuthPageController(private val api: ApiClient) {
private fun currentUser(session: HttpSession): SessionUser? =
session.getAttribute(SessionKeys.USER) as? SessionUser
private fun store(session: HttpSession, auth: AuthResponseDto) {
private fun store(request: HttpServletRequest, auth: AuthResponseDto) {
// Защита от session fixation: новая сессия после аутентификации.
request.getSession(false)?.invalidate()
val session = request.getSession(true)
session.setAttribute(SessionKeys.TOKEN, auth.token)
session.setAttribute(
SessionKeys.USER,

View File

@@ -0,0 +1,43 @@
package ru.ysu.lms.web.config
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.UUID
/**
* Synchronizer token: на каждый GET кладём в сессию свежий токен, на POST
* проверяем поле _csrf. Защита от CSRF для сессионных cookie web-BFF.
*/
@Component
class CsrfInterceptor : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
): Boolean {
val session = request.getSession(true)
var token = session.getAttribute(ATTR) as? String
if (token.isNullOrBlank()) {
token = UUID.randomUUID().toString()
session.setAttribute(ATTR, token)
}
request.setAttribute(ATTR, token)
if (request.method.equals("POST", ignoreCase = true)) {
val sent = request.getParameter(FIELD)
if (sent != token) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch")
return false
}
}
return true
}
companion object {
const val ATTR = "csrfToken"
const val FIELD = "_csrf"
}
}

View File

@@ -15,9 +15,11 @@ import ru.ysu.lms.web.session.SessionUser
class WebConfig(
private val authInterceptor: AuthInterceptor,
private val adminInterceptor: AdminInterceptor,
private val csrfInterceptor: CsrfInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
registry.addInterceptor(authInterceptor)
.addPathPatterns("/admin/**", "/student/**", "/change-password")
registry.addInterceptor(adminInterceptor)

View File

@@ -0,0 +1,26 @@
package ru.ysu.lms.web.learn
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.web.api.ApiClient
/**
* Проксирует картинки статей (public path) через BFF: страницы /learn/...
* отдаются web, а src картинок указывает на этот же хост.
*/
@RestController
class ContentAssetProxy(private val api: ApiClient) {
@GetMapping("/api/v1/content/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<ByteArray> {
val asset = api.contentAsset(name)
val media = runCatching { MediaType.parseMediaType(asset.contentType) }
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok()
.contentType(media)
.body(asset.bytes)
}
}

View File

@@ -0,0 +1,79 @@
package ru.ysu.lms.web.learn
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import ru.ysu.lms.markdown.MarkdownRenderer
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ContentArticleDto
import ru.ysu.lms.web.api.ContentTreeNodeDto
import ru.ysu.lms.web.session.SessionKeys
@Controller
class LearnController(
private val api: ApiClient,
private val markdown: MarkdownRenderer,
) {
private fun token(session: HttpSession): String? =
session.getAttribute(SessionKeys.TOKEN) as? String
@GetMapping("/learn")
fun index(session: HttpSession, model: Model): String {
model.addAttribute("tree", api.contentTree(token(session)))
return "learn/index"
}
@GetMapping("/learn/{chapter}/{section}")
fun section(
@PathVariable chapter: String,
@PathVariable section: String,
session: HttpSession,
model: Model,
): String {
val tree: ContentTreeNodeDto = api.contentTree(token(session))
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
model.addAttribute("tree", tree)
model.addAttribute("chapter", ch)
model.addAttribute("section", sec)
return "learn/section"
}
@GetMapping("/learn/{chapter}/{section}/{article}")
fun article(
@PathVariable chapter: String,
@PathVariable section: String,
@PathVariable article: String,
session: HttpSession,
model: Model,
): String {
val a: ContentArticleDto = api.contentArticle(token(session), chapter, section, article)
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
return "learn/article"
}
}
data class LearnArticleView(
val id: String,
val chapterSlug: String,
val chapterTitle: String,
val sectionSlug: String,
val sectionTitle: String,
val slug: String,
val title: String,
val bodyHtml: String,
)
fun ContentArticleDto.toView(bodyHtml: String): LearnArticleView = LearnArticleView(
id = id,
chapterSlug = chapterSlug,
chapterTitle = chapterTitle,
sectionSlug = sectionSlug,
sectionTitle = sectionTitle,
slug = slug,
title = title,
bodyHtml = bodyHtml,
)

View File

@@ -1,5 +1,10 @@
server:
port: ${SERVER_PORT:8090}
servlet:
session:
cookie:
http-only: true
same-site: lax
spring:
application:

View File

@@ -167,6 +167,27 @@ tr:last-child td { border-bottom: none; }
.filter-select { width: auto; }
.hidden { display: none !important; }
/* Toast-уведомление */
.toast { position: fixed; top: 1rem; right: 1rem; z-index: 50; width: 20rem; }
/* Modals */
dialog.modal {
border: none; border-radius: 0.75rem; padding: 0;
background: var(--c-surface); color: var(--c-text);
width: min(34rem, calc(100vw - 2rem));
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
}
dialog.modal::backdrop { background: rgba(0,0,0,0.55); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--c-border); }
.modal-title { margin: 0; font-size: 1.0625rem; font-weight: 600; }
.modal-close { border: none; background: none; cursor: pointer; font-size: 1.25rem; color: var(--c-text-muted); padding: 0 0.25rem; }
.modal-close:hover { color: var(--c-text); }
.modal-body { padding: 1.5rem; max-height: 70vh; overflow-y: auto; }
/* Двухуровневые формы уровней лабы */
.level-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.level-row .input { flex: 1; }
/* Markdown body */
.md-body { font-size: 0.9375rem; }
.md-body h1, .md-body h2, .md-body h3 { margin: 1rem 0 0.5rem; line-height: 1.3; }

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="6" fill="#6366f1"/>
<path d="M9 8v13h5l-1.5 3H6V8z" fill="#eef2ff"/>
<path d="M23 8v13h-5l1.5 3h5V8z" fill="#c7d2fe"/>
<path d="M11 9c2-1.5 4-1.5 5 0 1-1.5 3-1.5 5 0v10c-2-1.5-4-1.5-5 0-1-1.5-3-1.5-5 0z" fill="#ffffff"/>
<path d="M16 9v10" stroke="#6366f1" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -4,24 +4,51 @@
<body>
<div id="content">
<h1 class="page-title">Панель преподавателя</h1>
<div class="grid grid-stats">
<div class="card card-body"><p class="stat-title">Группы</p><p class="stat-value" th:text="${groupCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Студенты</p><p class="stat-value" th:text="${studentCount}">0</p></div>
<div class="card card-body"><p class="stat-title">Лабораторные</p><p class="stat-value text-muted">скоро</p></div>
<div class="card card-body"><p class="stat-title">Лабораторные</p><p class="stat-value" th:text="${labCount}">0</p>
<p class="hint" th:if="${labCount > 0}">опубликовано: <span th:text="${publishedLabs}">0</span></p></div>
<div class="card card-body"><p class="stat-title">Средний балл</p>
<p class="stat-value" th:if="${avgGrade != null}" th:text="${#numbers.formatDecimal(avgGrade, 1, 2)}">—</p>
<p class="stat-value text-muted" th:if="${avgGrade == null}">—</p></div>
</div>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все группы →</a>
<div class="grid grid-2 mt-8">
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Лабораторные</h2>
<a th:href="@{/admin/labs}" class="text-sm">Все →</a>
</div>
<div class="card card-flush">
<table>
<tbody>
<tr th:each="lab : ${labs}">
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</a></td>
<td class="text-muted" th:text="${lab.variantCount} + ' вар.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td class="empty">Лабораторных нет.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card card-flush">
<table>
<thead><tr><th>Название</th><th>Студентов</th></tr></thead>
<tbody>
<tr th:each="group : ${groups}"><td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td><td th:text="${group.studentCount}">0</td></tr>
<tr th:if="${#lists.isEmpty(groups)}"><td colspan="2" class="empty">Групп пока нет.</td></tr>
</tbody>
</table>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Группы</h2>
<a th:href="@{/admin/groups}" class="text-sm">Все →</a>
</div>
<div class="card card-flush">
<table>
<tbody>
<tr th:each="group : ${groups}">
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
<td class="text-muted" th:text="${group.studentCount} + ' студ.'">—</td>
</tr>
<tr th:if="${#lists.isEmpty(groups)}"><td class="empty">Групп нет.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div>
@@ -67,6 +68,7 @@
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5"
th:value="${score}">
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
<td class="text-center font-semibold">
@@ -95,7 +97,8 @@
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
<td class="text-center">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
<button class="nav-link text-sm" type="submit">Сбросить</button>
<button class="btn btn-danger btn-sm" type="submit">Сбросить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
</tr>
@@ -104,5 +107,42 @@
</table>
</div>
</div>
<div th:if="${createdStudent}" class="card import-card toast">
<div class="card-body">
<h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
</div>
</div>
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="grid grid-2">
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
<input class="input" id="stNo" name="studentNo" required></div>
<div><label class="label" for="stBasis">Основа обучения</label>
<input class="input" id="stBasis" name="basis" placeholder="бюджет"></div>
</div>
<div><label class="label" for="stLast">Фамилия</label>
<input class="input" id="stLast" name="lastName" required></div>
<div class="grid grid-2">
<div><label class="label" for="stFirst">Имя</label>
<input class="input" id="stFirst" name="firstName"></div>
<div><label class="label" for="stMiddle">Отчество</label>
<input class="input" id="stMiddle" name="middleName"></div>
</div>
<p class="hint">Пароль будет сгенерирован автоматически.</p>
<button class="btn btn-primary" type="submit">Добавить</button>
</form>
</div>
</dialog>
</body>
</html>

View File

@@ -11,6 +11,7 @@
<h2 class="section-title">Создать группу</h2>
<div><label class="label" for="name">Название</label><input class="input" id="name" name="name" placeholder="ПИ-11БО" required></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<form class="card form-card form-stack" th:action="@{/admin/groups/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт студентов</h2>
@@ -18,6 +19,7 @@
<div><label class="label" for="file">Файл со списком</label><input class="input" id="file" name="file" type="file" accept=".xls,.xlsx,.csv" required></div>
<div><label class="label" for="groupName">Название группы (необязательно)</label><input class="input" id="groupName" name="name" placeholder="Возьмём из файла"></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
<div class="card card-flush mt-8">

View File

@@ -34,6 +34,7 @@
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14"
th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<h2 class="section-title mt-8">Варианты</h2>
@@ -52,10 +53,12 @@
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>
@@ -75,6 +78,7 @@
<div><label class="label" for="newStatement">Условие (markdown)</label>
<textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</body>

View File

@@ -6,39 +6,83 @@
<a th:href="@{/admin/labs}" class="nav-link">← Все лабораторные</a>
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-6">
<div>
<h1 class="page-title" th:text="|${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></p>
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></span>
<span th:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
</p>
</div>
<div class="flex gap-2">
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-meta')">Править описание</button>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</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>
<h2 class="section-title">Уровни вариантов</h2>
<div class="card card-flush mt-2 mb-6">
<table>
<thead><tr><th>Код</th><th>Название</th><th>Вариантов</th></tr></thead>
<tbody>
<tr th:each="level : ${lab.levels}">
<td class="font-mono" th:text="${level.code}">—</td>
<td th:text="${level.title}">—</td>
<td th:text="${#lists.size(level.variants)}">0</td>
</tr>
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровни не найдены.</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">Условие</h2>
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title">Студенты и варианты</h2>
<h2 class="section-title">Уровни и варианты</h2>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-level')">+ Уровень</button>
</div>
<div th:each="level : ${lab.levels}" class="card mb-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<h3 class="group-title">
<span class="font-mono text-muted" th:text="${level.code}">basic</span> —
<span th:text="${level.title}">Уровень</span>
(<span th:text="${#lists.size(level.variants)}">0</span> вар.)
</h3>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-level-'+${level.id})">Переименовать</button>
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
onsubmit="return confirm('Удалить уровень без вариантов?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<button class="btn btn-primary btn-sm" type="button"
th:onclick="openModal('modal-add-variant-'+${level.id})">+ Вариант</button>
</div>
</div>
<table>
<thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
<tbody>
<tr th:each="v : ${level.variants}">
<td class="font-mono" th:text="${v.number}">—</td>
<td th:text="${v.title}">—</td>
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
<td class="text-center">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-edit-variant-'+${v.id})">Править</button>
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
onsubmit="return confirm('Удалить вариант?')">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
<h2 class="section-title">Студенты и варианты</h2>
<div class="flex items-center justify-between gap-4 mb-4">
<p class="text-sm text-muted">Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.</p>
<label class="text-sm text-muted">Группа:
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
<option value="">Все</option>
@@ -46,8 +90,6 @@
</select>
</label>
</div>
<p class="text-sm text-muted mb-4">Уровень по умолчанию — самый простой. Селекты позволяют выдать вариант посложнее или назначить вариант вручную.</p>
<div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}">
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
<div class="card card-flush mb-6">
@@ -65,6 +107,7 @@
th:selected="${level.id == s.levelId}">—</option>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
<td>
@@ -78,6 +121,7 @@
</optgroup>
</select>
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</td>
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
@@ -88,13 +132,126 @@
</table>
</div>
</div>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
</div>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="metaTitle">Название</label>
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="metaDeadline">Срок сдачи</label>
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="metaDefense">Дата защиты</label>
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="metaPoints">Максимум баллов</label>
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="metaStatus">Статус</label>
<select class="input" id="metaStatus" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
<option value="published" th:selected="${lab.status == 'published'}">published</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="metaDesc">Условие (markdown)</label>
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-add-level" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить уровень</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="lvlCode">Код</label>
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
<div><label class="label" for="lvlTitle">Название</label>
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
</div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать уровень</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-level-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="levelId" th:value="${level.id}">
<input type="hidden" name="code" th:value="${level.code}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${level.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-add-variant-'+[[${level.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" required></div>
<div><label class="label">Название</label><input class="input" name="title"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<th:block th:each="level : ${lab.levels}">
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка варианта</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-variant-'+[[${v.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</th:block>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
</body>
</html>

View File

@@ -3,18 +3,14 @@
<head><title>Лабораторные — LMS</title></head>
<body>
<div id="content">
<h1 class="page-title">Лабораторные</h1>
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Лабораторные</h1>
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
</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 class="grid grid-2">
<form class="card form-card form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
<h2 class="section-title">Импорт лабораторной</h2>
<p class="text-sm text-muted">Принимаются .zip (lab.yaml + README.md + variants/*.md) или .json.</p>
<div><label class="label" for="labFile">Файл</label><input class="input" id="labFile" name="file" type="file" accept=".zip,.json" required></div>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
<div class="card card-flush mt-8">
<div class="card card-flush">
<table>
<thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
<tbody>
@@ -23,12 +19,49 @@
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
<td th:text="${lab.variantCount}">0</td>
<td><span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span></td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><button class="nav-link text-sm" type="submit">Удалить</button></form></td>
<td class="text-center"><form th:action="@{/admin/labs/{id}/delete(id=${lab.id})}" method="post"><input type="hidden" name="_csrf" th:value="${csrfToken}"><button class="btn btn-danger btn-sm" type="submit">Удалить</button></form></td>
</tr>
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div class="grid grid-2">
<div><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
<div><label class="label" for="labPoints">Максимум баллов</label>
<input class="input" id="labPoints" name="pointsMax" type="number" step="0.1" min="0" value="5" required></div>
</div>
<div><label class="label" for="labTitle">Название</label>
<input class="input" id="labTitle" name="title" required></div>
<div class="grid grid-2">
<div><label class="label" for="labDeadline">Срок сдачи</label>
<input class="input" id="labDeadline" name="deadlineAt" type="datetime-local"></div>
<div><label class="label" for="labDefense">Дата защиты</label>
<input class="input" id="labDefense" name="defenseAt" type="datetime-local"></div>
</div>
<div><label class="label" for="labStatus">Статус</label>
<select class="input" id="labStatus" name="status">
<option value="draft">draft</option>
<option value="published">published</option>
</select></div>
<div><label class="label" for="labLevels">Уровни (по одному на строку: код; название)</label>
<textarea class="input" id="labLevels" name="levels" rows="4" placeholder="basic; Основная группа&#10;advanced; Повышенная сложность"></textarea>
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</body>
</html>

View File

@@ -12,6 +12,7 @@
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>

View File

@@ -4,11 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">LMS</title>
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}">
<link rel="stylesheet" th:href="@{/css/app.css}">
<script th:src="@{/js/htmx.min.js}" defer></script>
<script>
(function(){ var t = localStorage.getItem('lms-theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })();
function toggleTheme(){ var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); }
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); }
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
</script>
</head>
<body>
@@ -16,13 +19,17 @@
<header class="page-header">
<div class="page-header-inner">
<a th:href="@{/}" class="logo">LMS</a>
<nav class="nav" th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<span class="nav-sep" th:text="${currentUser.name}">—</span>
<nav class="nav">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<th:block th:if="${currentUser != null}">
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
<span class="nav-sep" th:text="${currentUser.name}">—</span>
</th:block>
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>
<form th:action="@{/logout}" method="post">
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
<button class="btn btn-secondary" type="submit">Выйти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</nav>
</div>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
<body>
<div id="content">
<nav class="text-sm mb-4">
<a th:href="@{/learn}" class="nav-link">Материалы</a>
<span class="nav-sep mx-2">/</span>
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
<span class="nav-sep mx-2">/</span>
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
th:text="${article.sectionTitle}">Раздел</a>
</nav>
<h1 class="page-title" th:text="${article.title}">Статья</h1>
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title>Материалы — LMS</title></head>
<body>
<div id="content">
<div class="flex items-center justify-between gap-4 mb-6">
<h1 class="page-title">Учебные материалы</h1>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-new-article')">+ Статья</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import')">Импорт zip</button>
<button class="btn btn-secondary" type="button" onclick="openModal('modal-manage')">Главы и разделы</button>
</div>
</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="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
Материалы ещё не добавлены.
</div>
<div th:each="chapter : ${tree.chapters}" class="card mb-4" th:id="|chapter-${chapter.id}|">
<div class="card-body">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-chapter-'+${chapter.id})">Переименовать</button>
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
</form>
</div>
</div>
<div th:each="section : ${chapter.sections}" class="mt-4"
th:id="|section-${section.id}|">
<div class="flex items-center justify-between gap-4">
<h3 class="group-title">
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
</h3>
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-rename-section-'+${section.id})">Переименовать</button>
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-move-'+${section.id})">Перенести статьи</button>
<form th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<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}" 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>
<th:block th:if="${currentUser != null and currentUser.admin}">
<button class="btn btn-secondary btn-sm" type="button"
th:onclick="openModal('modal-edit-article-'+${article.id})">Править</button>
<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}">
<button class="btn btn-danger btn-sm" type="submit">✕</button>
</form>
</th:block>
</li>
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
</ul>
</div>
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
</div>
</div>
</div>
<dialog id="modal-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт материалов (zip)</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<p class="text-sm text-muted">
Структура: <code class="code">chapters/01-глава/01-раздел/01-статья.md</code>, картинки в <code class="code">assets/</code>.
</p>
<div><label class="label" for="file">Архив</label>
<input class="input" id="file" name="file" type="file" accept=".zip" required></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}">
<div><label class="label" for="naChapter">Глава (можно создать новую)</label>
<div class="inline-form w-full">
<select class="input" id="naChapter" name="chapterId" th:if="${tree != null}">
<option th:each="chapter : ${tree.chapters}" th:value="${chapter.id}" th:text="${chapter.title}">—</option>
</select>
<span class="text-muted">или</span>
<input class="input" name="newChapterTitle" placeholder="Новая глава">
</div>
</div>
<div><label class="label" for="naSection">Раздел (можно создать новый)</label>
<input class="input" id="naSection" name="newSectionTitle" placeholder="Новый раздел"></div>
<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="10"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
</form>
</div>
</dialog>
<th:block th:each="chapter : ${tree?.chapters ?: {}}">
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать главу</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-chapter-'+[[${chapter.id}]])">✕</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}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${chapter.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<th:block th:each="section : ${chapter.sections}">
<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:onclick="closeModal('modal-rename-section-'+[[${section.id}]])">✕</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}">
<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:onclick="closeModal('modal-move-'+[[${section.id}]])">✕</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}">
<p class="text-sm text-muted">Укажите раздел, в который перенести статьи, затем подтвердите удаление раздела.</p>
<div><label class="label">Перенести в раздел</label>
<select class="input" name="moveTo" th:if="${tree != null}">
<th:block th:each="ch2 : ${tree.chapters}">
<optgroup th:label="${ch2.title}">
<option th:each="s2 : ${ch2.sections}"
th:if="${s2.id != section.id}"
th:value="${s2.id}" th:text="${ch2.title} / ${s2.title}">—</option>
</optgroup>
</th:block>
</select></div>
<button class="btn btn-primary" type="submit">Перенести и удалить раздел</button>
</form>
</div>
</dialog>
</th:block>
</th:block>
<th:block th:each="a : ${articles}">
<dialog th:id="|modal-edit-article-${a.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка статьи</h2>
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-article-'+[[${a.id}]])">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${a.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label">Заголовок</label>
<input class="input" name="title" th:value="${a.title}" required></div>
<div><label class="label">Текст (markdown)</label>
<textarea class="input" name="bodyMd" rows="14" th:text="${a.bodyMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
</th:block>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
<body>
<div id="content">
<a th:href="@{/learn}" class="nav-link">← Все материалы</a>
<h1 class="page-title mt-4" th:text="${section.title}">Раздел</h1>
<p class="text-sm text-muted mb-6">Глава: <span th:text="${chapter.title}">—</span></p>
<div class="card card-flush">
<table>
<thead><tr><th>Статья</th></tr></thead>
<tbody>
<tr th:each="article : ${section.articles}">
<td><a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
th:text="${article.title}">—</a></td>
</tr>
<tr th:if="${#lists.isEmpty(section.articles)}"><td class="empty">Статей пока нет.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -13,6 +13,7 @@
<div><label class="label" for="login">Логин</label><input class="input" id="login" name="login" th:value="${login}" autofocus required></div>
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required></div>
<button class="btn btn-primary" type="submit">Войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>

View File

@@ -12,6 +12,7 @@
<div><label class="label" for="password">Пароль</label><input class="input" id="password" name="password" type="password" required><p class="hint">Минимум 8 символов.</p></div>
<div><label class="label" for="password2">Повторите пароль</label><input class="input" id="password2" name="password2" type="password" required></div>
<button class="btn btn-primary" type="submit">Создать и войти</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</div>