Phase 6: accordion in /learn; README feature notes; tests green

- collapse chapters/sections via styled <details> with chevrons
- identity testcontainers smoke passes on JDK 21
- README: student UX, comments, JDK note (logback ThrowableProxy in
  fat-jars → restart recovery), data dirs
This commit is contained in:
2026-09-12 02:00:45 +03:00
parent d355319a20
commit 08830c67f1
3 changed files with 51 additions and 13 deletions

View File

@@ -59,10 +59,28 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP
(`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/ (`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/
удаление глав, разделов, статей; перенос статей между разделами. удаление глав, разделов, статей; перенос статей между разделами.
- **Студент**: видит опубликованные лабы, свой вариант с условием (markdown), - **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка),
сроки сдачи/защиты и оценки. карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача
файлов (zip/.c, переотправка до дедлайна, история, скачивание последней),
комментарий преподавателя к оценке, защита по одноразовому коду (30 мин).
- **Комментарии к оценкам**: тональность (pos/neu/neg), хранятся в БД,
автоподсказки при вводе ранжируются по частоте использования; видны студенту чипсами.
- UI: светлая/тёмная тема (сохраняется в localStorage), модальные окна вместо - UI: светлая/тёмная тема (сохраняется в localStorage), модальные окна вместо
отдельных страниц, фавиконка, адаптивная вёрстка без inline-стилей. отдельных страниц, аккордеоны глав/разделов в `/learn`, фавиконка,
адаптивная вёрстка без inline-стилей.
## Примечания
- **JDK 21 обязателен для runtime**. В связке Spring Boot 4.0.x + fat-jar logback
не находит `ThrowableProxy`, и это падает при любом исключении в потоке ответа.
Если после деплоя сервис вдруг «висит» на страницах (а `/actuator/health` даёт 200) —
лечится перезапуском (`./run.sh restart`).
- Даты хранятся в UTC (instant), а в UI конвертируются в Europe/Moscow
(`DateUtils`); формы datetime-local отправляются в московском времени.
- Капча/rate-limit на входе отсутствует — для учебного стендома оставлено;
пароли студентов показываются один раз при выдаче.
- `lms.submissions.dir` (default ./data/submissions) и `lms.content.assets-dir`
держите на постоянном томе при деплое.
## Сборка и тесты ## Сборка и тесты

View File

@@ -231,3 +231,16 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.chip-neg { background: #b3283b; color: #fff; } .chip-neg { background: #b3283b; color: #fff; }
/* Defense timer */ /* Defense timer */
/* Accordion (главы/разделы в /learn) */
details.accordion > summary.acc-head { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; }
details.accordion > summary::-webkit-details-marker { display: none; }
details.accordion[open] > .acc-head, details.accordion > summary { margin-bottom: 0; }
.acc-arrow { width: 10px; height: 10px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; margin-left: .75rem;}
details.accordion[open] > summary > .acc-arrow { transform: rotate(-135deg); }
.acc-title { margin: 0; font-size: 1.0625rem; }
.acc-body { border-top: 1px solid var(--c-border); }
details.sub-acc > summary.sub-head { list-style: none; cursor: pointer; }
details.sub-acc > summary::-webkit-details-marker { display: none; }
.sub-arrow { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; margin-right: 6px; vertical-align: 2px;}
details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); }
.sub-actions { margin: 0 0 2px 18px; }

View File

@@ -18,8 +18,12 @@
Материалы ещё не добавлены. Материалы ещё не добавлены.
</div> </div>
<div th:each="chapter : ${tree?.chapters}" class="card mb-4"> <details th:each="chapter : ${tree?.chapters}" class="card mb-4 accordion" open>
<div class="card-body"> <summary class="card-body acc-head">
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
<span class="acc-arrow" aria-hidden="true"></span>
</summary>
<div class="card-body acc-body">
<div class="flex flex-wrap 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 flex-wrap gap-2"> <div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2">
@@ -35,20 +39,24 @@
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p> <p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
<div th:each="section : ${chapter.sections}" class="mt-4"> <details th:each="section : ${chapter.sections}" class="mt-4 sub-acc" open>
<div class="flex flex-wrap items-center justify-between gap-4"> <summary class="sub-head">
<h3 class="group-title"> <h3 class="group-title m-0">
<span class="sub-arrow" aria-hidden="true"></span>
<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>
</summary>
<div class="flex flex-wrap items-center justify-between gap-4 sub-actions">
<div class="spacer"></div>
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap 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:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</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:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи</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 class="inline-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}">
<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>
@@ -105,8 +113,7 @@
</div> </div>
</dialog> </dialog>
</th:block> </th:block>
</div> </details>
<th:block th:if="${currentUser != null and currentUser.admin}"> <th:block th:if="${currentUser != null and currentUser.admin}">
<dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal"> <dialog th:id="|modal-rename-chapter-${chapter.id}|" class="modal">
<div class="modal-header"> <div class="modal-header">
@@ -125,7 +132,7 @@
</dialog> </dialog>
</th:block> </th:block>
</div> </div>
</div> </details>
<dialog id="modal-import" class="modal"> <dialog id="modal-import" class="modal">
<div class="modal-header"> <div class="modal-header">