Fix learn dialog rendering, add lab publish/draft toggle, bump to v0.1.2
- learn/index.html: restructured dialogs inside section divs to fix scope - learn/index.html: edit-article dialogs via flat articles list in modal blocks - LearnController: sectionChoices for select options; secure article fetching - ContentService: createArticlePlaced: new section adjacent to anchor section - lab status: published = student-visible, draft = hidden - lab toggle: click badge in labs list or button in lab header to switch status - added badge-btn CSS class, removed inline styles - removed all th:onclick with expressions → data-modal attributes - footer v0.1.2
This commit is contained in:
@@ -231,6 +231,13 @@ class ContentService(
|
|||||||
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
fun createArticlePlaced(title: String, bodyMd: String, chapterId: UUID?, newChapterTitle: String?,
|
||||||
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
sectionId: UUID?, newSectionTitle: String?): ContentArticleView {
|
||||||
val targetSection: ContentSection = when {
|
val targetSection: ContentSection = when {
|
||||||
|
sectionId != null && !newSectionTitle.isNullOrBlank() -> {
|
||||||
|
val anchor = sections.findById(sectionId)
|
||||||
|
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
val node = createSection(anchor.chapterId, newSectionTitle, null)
|
||||||
|
sections.findById(UUID.fromString(node.id))
|
||||||
|
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
|
}
|
||||||
sectionId != null -> sections.findById(sectionId)
|
sectionId != null -> sections.findById(sectionId)
|
||||||
.orElseThrow { NotFoundException("Раздел не найден") }
|
.orElseThrow { NotFoundException("Раздел не найден") }
|
||||||
!newSectionTitle.isNullOrBlank() -> {
|
!newSectionTitle.isNullOrBlank() -> {
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class LabController(private val labService: LabService) {
|
|||||||
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
|
||||||
labService.deleteVariant(id, variantId)
|
labService.deleteVariant(id, variantId)
|
||||||
|
|
||||||
|
@PostMapping("/{id}/toggle-status")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
fun toggleStatus(@PathVariable id: UUID) {
|
||||||
|
labService.toggleStatus(id)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
|
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
|
||||||
|
|||||||
@@ -257,10 +257,10 @@ class LabService(
|
|||||||
return detail(labId)
|
return detail(labId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
fun addLevel(labId: UUID, request: LevelSaveRequest) {
|
||||||
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
if (request.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
|
||||||
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
val existing = labLevels.findByLabIdOrderByPosition(labId)
|
||||||
labLevels.save(
|
labLevels.save(
|
||||||
LabLevel(
|
LabLevel(
|
||||||
@@ -273,6 +273,13 @@ class LabService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun toggleStatus(labId: UUID) {
|
||||||
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
|
lab.status = if (lab.status == "published") "draft" else "published"
|
||||||
|
labs.save(lab)
|
||||||
|
}
|
||||||
|
|
||||||
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
private fun findOrCreateSemester(title: String, year: Int): Semester {
|
||||||
semesters.findFirstByActiveTrue()?.let { return it }
|
semesters.findFirstByActiveTrue()?.let { return it }
|
||||||
val t = title.ifBlank { "осень" }
|
val t = title.ifBlank { "осень" }
|
||||||
|
|||||||
@@ -213,6 +213,20 @@ class AdminController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/labs/{id}/toggle-status")
|
||||||
|
fun toggleStatus(
|
||||||
|
@PathVariable id: String,
|
||||||
|
session: HttpSession,
|
||||||
|
redirect: RedirectAttributes,
|
||||||
|
): String = try {
|
||||||
|
api.toggleLabStatus(token(session), id)
|
||||||
|
redirect.addFlashAttribute("notice", "Статус лабораторной изменён")
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
redirect.addFlashAttribute("error", ex.message)
|
||||||
|
"redirect:/admin/labs/$id"
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/labs/{id}/delete")
|
@PostMapping("/labs/{id}/delete")
|
||||||
fun deleteLab(
|
fun deleteLab(
|
||||||
@PathVariable id: String,
|
@PathVariable id: String,
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toggleLabStatus(token: String, labId: String) {
|
||||||
|
call {
|
||||||
|
client.post().uri("/api/v1/labs/{id}/toggle-status", labId)
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||||
|
.retrieve().toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getLevels(token: String, labId: String): List<LevelAssignmentResultDto> {
|
fun getLevels(token: String, labId: String): List<LevelAssignmentResultDto> {
|
||||||
val type = object : ParameterizedTypeReference<List<LevelAssignmentResultDto>>() {}
|
val type = object : ParameterizedTypeReference<List<LevelAssignmentResultDto>>() {}
|
||||||
return call {
|
return call {
|
||||||
|
|||||||
@@ -22,7 +22,24 @@ class LearnController(
|
|||||||
|
|
||||||
@GetMapping("/learn")
|
@GetMapping("/learn")
|
||||||
fun index(session: HttpSession, model: Model): String {
|
fun index(session: HttpSession, model: Model): String {
|
||||||
model.addAttribute("tree", api.contentTree(token(session)))
|
val t = token(session)
|
||||||
|
val tree = api.contentTree(t)
|
||||||
|
model.addAttribute("tree", tree)
|
||||||
|
val isAdmin = (session.getAttribute(SessionKeys.USER) as? ru.ysu.lms.web.session.SessionUser)?.isAdmin == true
|
||||||
|
val articles = if (isAdmin && t != null) api.contentTreeList(t) else emptyList()
|
||||||
|
model.addAttribute("articles", articles)
|
||||||
|
model.addAttribute(
|
||||||
|
"sectionChoices",
|
||||||
|
tree.chapters.flatMap { chapter ->
|
||||||
|
chapter.sections.map { section ->
|
||||||
|
mapOf(
|
||||||
|
"id" to section.id,
|
||||||
|
"chapterId" to chapter.id,
|
||||||
|
"label" to "${chapter.title} / ${section.title}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
return "learn/index"
|
return "learn/index"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ a:hover { color: var(--c-text-link-hover); }
|
|||||||
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 500; }
|
.badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 500; }
|
||||||
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); }
|
.badge-ok { background: var(--c-badge-ok-bg); color: var(--c-badge-ok-text); }
|
||||||
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); }
|
.badge-warn { background: var(--c-badge-warn-bg); color: var(--c-badge-warn-text); }
|
||||||
|
.badge-btn { border: none; cursor: pointer; padding: 0.125rem 0.625rem; }
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
table { width: 100%; font-size: 0.875rem; border-collapse: collapse; }
|
table { width: 100%; font-size: 0.875rem; border-collapse: collapse; }
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
|
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
|
||||||
|
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
|
||||||
|
</form>
|
||||||
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></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:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
|
||||||
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
||||||
@@ -47,14 +51,14 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-rename-level-'+${level.id})">Переименовать</button>
|
th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="openModalData(this)">Переименовать</button>
|
||||||
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
|
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
|
||||||
onsubmit="return confirm('Удалить уровень без вариантов?')">
|
onsubmit="return confirm('Удалить уровень без вариантов?')">
|
||||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-primary btn-sm" type="button"
|
<button class="btn btn-primary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-add-variant-'+${level.id})">+ Вариант</button>
|
th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="openModalData(this)">+ Вариант</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
|
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-edit-variant-'+${v.id})">Править</button>
|
th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="openModalData(this)">Править</button>
|
||||||
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
||||||
onsubmit="return confirm('Удалить вариант?')">
|
onsubmit="return confirm('Удалить вариант?')">
|
||||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||||
@@ -189,7 +193,7 @@
|
|||||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
|
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Переименовать уровень</h2>
|
<h2 class="modal-title">Переименовать уровень</h2>
|
||||||
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-level-'+[[${level.id}]])">✕</button>
|
<button class="modal-close" type="button" th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
|
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
|
<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>
|
<button class="modal-close" type="button" th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||||
@@ -227,7 +231,7 @@
|
|||||||
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
|
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Правка варианта</h2>
|
<h2 class="modal-title">Правка варианта</h2>
|
||||||
<button class="modal-close" type="button" th:onclick="closeModal('modal-edit-variant-'+[[${v.id}]])">✕</button>
|
<button class="modal-close" type="button" th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="closeModalData(this)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<h1 class="page-title">Лабораторные</h1>
|
<h1 class="page-title">Лабораторные</h1>
|
||||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
|
||||||
|
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
|
||||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||||
|
|
||||||
@@ -18,7 +20,14 @@
|
|||||||
<td th:text="${lab.number}">1</td>
|
<td th:text="${lab.number}">1</td>
|
||||||
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
|
<td><a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="${lab.title}">—</a></td>
|
||||||
<td th:text="${lab.variantCount}">0</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>
|
||||||
|
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<button class="badge badge-btn"
|
||||||
|
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
|
||||||
|
type="submit" th:text="${lab.status}">—</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>
|
<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>
|
||||||
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
|
<tr th:if="${#lists.isEmpty(labs)}"><td colspan="5" class="empty">Лабораторных пока нет.</td></tr>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
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 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 openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); }
|
||||||
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
|
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
|
||||||
|
function openModalData(btn){ openModal(btn.dataset.modal); }
|
||||||
|
function closeModalData(btn){ closeModal(btn.dataset.modal); }
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
<th:block th:replace="${content}" />
|
<th:block th:replace="${content}" />
|
||||||
</main>
|
</main>
|
||||||
<footer class="page-footer">
|
<footer class="page-footer">
|
||||||
LMS v0.1.1
|
LMS v0.1.2
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
<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-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-import')">Импорт zip</button>
|
||||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-manage')">Главы и разделы</button>
|
<button class="btn btn-secondary" type="button" onclick="openModal('modal-new-chapter')">+ Глава</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||||
@@ -17,31 +17,34 @@
|
|||||||
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
|
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted">
|
||||||
Материалы ещё не добавлены.
|
Материалы ещё не добавлены.
|
||||||
</div>
|
</div>
|
||||||
<div th:each="chapter : ${tree.chapters}" class="card mb-4" th:id="|chapter-${chapter.id}|">
|
|
||||||
|
<div th:each="chapter : ${tree?.chapters}" class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||||
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
|
<h2 class="section-title" th:text="${chapter.title}">Глава</h2>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-rename-chapter-'+${chapter.id})">Переименовать</button>
|
th:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||||
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
|
<form th:action="@{/admin/content/chapters/{id}/delete(id=${chapter.id})}" method="post"
|
||||||
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
|
onsubmit="return confirm('Удалить главу со всеми разделами и статьями?')">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
<button class="btn btn-danger btn-sm" type="submit">Удалить главу</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div th:each="section : ${chapter.sections}" class="mt-4"
|
|
||||||
th:id="|section-${section.id}|">
|
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
|
<div th:each="section : ${chapter.sections}" class="mt-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h3 class="group-title">
|
<h3 class="group-title">
|
||||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex gap-2">
|
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-rename-section-'+${section.id})">Переименовать</button>
|
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-move-'+${section.id})">Перенести статьи</button>
|
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи</button>
|
||||||
<form th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
<form th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||||
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
|
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
th:text="${article.title}">Статья</a>
|
th:text="${article.title}">Статья</a>
|
||||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
<button class="btn btn-secondary btn-sm" type="button"
|
<button class="btn btn-secondary btn-sm" type="button"
|
||||||
th:onclick="openModal('modal-edit-article-'+${article.id})">Править</button>
|
th:data-modal="|modal-edit-article-${article.id}|" onclick="openModalData(this)">Править</button>
|
||||||
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
|
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
|
||||||
onsubmit="return confirm('Удалить статью?')">
|
onsubmit="return confirm('Удалить статью?')">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
@@ -65,131 +68,141 @@
|
|||||||
</li>
|
</li>
|
||||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
|
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">Переименовать раздел</h2>
|
||||||
|
<button class="modal-close" type="button"
|
||||||
|
th:data-modal="|modal-rename-section-${section.id}|" onclick="closeModalData(this)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<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}">
|
||||||
|
<p class="text-sm text-muted">Выберите раздел-приёмник: статьи перенесутся туда, а текущий раздел будет удалён.</p>
|
||||||
|
<div><label class="label">Перенести в раздел</label>
|
||||||
|
<select class="input" name="moveTo">
|
||||||
|
<option th:each="pair : ${sectionChoices}"
|
||||||
|
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
||||||
|
</select></div>
|
||||||
|
<button class="btn btn-primary" type="submit">Перенести и удалить раздел</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
|
||||||
|
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||||
|
<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:data-modal="|modal-rename-chapter-${chapter.id}|" onclick="closeModalData(this)">✕</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="modal-import" class="modal">
|
<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">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Переименовать главу</h2>
|
<h2 class="modal-title">Импорт материалов (zip)</h2>
|
||||||
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-chapter-'+[[${chapter.id}]])">✕</button>
|
<button class="modal-close" type="button" onclick="closeModal('modal-import')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/content/chapters/{id}/rename(id=${chapter.id})}" method="post">
|
<form class="form-stack" th:action="@{/admin/content/import}" method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<div><label class="label">Название</label>
|
<p class="text-sm text-muted">
|
||||||
<input class="input" name="title" th:value="${chapter.title}" required></div>
|
Структура: <code class="code">chapters/01-глава/01-раздел/01-статья.md</code>, картинки в <code class="code">assets/</code>.
|
||||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<th:block th:each="section : ${chapter.sections}">
|
|
||||||
<dialog th:id="|modal-rename-section-${section.id}|" class="modal">
|
<dialog id="modal-new-chapter" class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">Новая глава</h2>
|
||||||
|
<button class="modal-close" type="button" onclick="closeModal('modal-new-chapter')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form class="form-stack" th:action="@{/admin/content/chapters/create}" method="post">
|
||||||
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
|
<div><label class="label" for="chTitle">Название</label>
|
||||||
|
<input class="input" id="chTitle" name="title" 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>
|
||||||
|
<select class="input" id="naChapter" name="sectionId">
|
||||||
|
<option th:each="pair : ${sectionChoices}"
|
||||||
|
th:value="${pair['id']}" th:text="${pair['label']}">—</option>
|
||||||
|
</select></div>
|
||||||
|
<div><label class="label" for="naNewSection">…или новый раздел в выбранной главе</label>
|
||||||
|
<input class="input" id="naNewSection" 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:if="${currentUser != null and currentUser.admin}" th:each="a : ${articles}">
|
||||||
|
<dialog th:id="|modal-edit-article-${a.id}|" class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Переименовать раздел</h2>
|
<h2 class="modal-title">Правка статьи</h2>
|
||||||
<button class="modal-close" type="button" th:onclick="closeModal('modal-rename-section-'+[[${section.id}]])">✕</button>
|
<button class="modal-close" type="button"
|
||||||
|
th:data-modal="|modal-edit-article-${a.id}|" onclick="closeModalData(this)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-stack" th:action="@{/admin/content/sections/{id}/rename(id=${section.id})}" method="post">
|
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${a.id})}" method="post">
|
||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<div><label class="label">Название</label>
|
<div><label class="label">Заголовок</label>
|
||||||
<input class="input" name="title" th:value="${section.title}" required></div>
|
<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>
|
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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>
|
</div>
|
||||||
|
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user