Files
Learn_System/frontend/classes.html
T
Maxim Dolgolyov d3b16f55c8 refactor: 4 модалки → LS.modal (classes ×2, library ×2)
classes.html (modal-overlay: 5 → 3):
  - modal-class — создание класса
  - modal-edit-assign — редактирование задания

library.html (modal-overlay: 5 → 3):
  - folder-modal — создание/переименование папки
  - move-modal — перемещение файла в папку

Везде один паттерн:
  1. Удалить inline <div class="modal-overlay">...</div> разметку
  2. Заменить openX/closeX функции на LS.modal({content, actions})
  3. Сохранить state в локальной переменной _xModal вместо
     document.getElementById('modal-id').classList.add('open')
  4. setError() / close() через ссылку на modal-instance
  5. Удалить орфанные closeX функции

Чистый эффект: −154 строки HTML/CSS дубликатов, единое поведение
ESC/backdrop/focus, accessibility (role/aria-modal) автоматически.

Осталось:
  classes.html — modal-assign (128 строк, complex tabs), review-modal
  library.html — folder-access-modal, assign-modal, upload-modal (все
    более сложные с tabs и multi-step)
  frontend/red-book.html (17 modal-overlay — отдельный заход)
  flashcards (5), course (4), dashboard (2), и другие
2026-05-16 19:17:49 +03:00

2572 lines
149 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Классы — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
/* ── Split layout ── */
.sb-content.classes-split { display: flex; height: 100vh; overflow: hidden; background: #f4f5f8; }
/* Left: class list */
.cl-side { width: 300px; flex-shrink: 0; border-right: 1px solid var(--border); background: #fff; display: flex; flex-direction: column; overflow: hidden; }
.cl-side-header { padding: 20px 16px 14px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.cl-side-title { font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; flex: 1; }
.btn-new-cl { width: 32px; height: 32px; border: none; border-radius: 10px; background: var(--grad-1); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; flex-shrink: 0; }
.btn-new-cl:hover { transform: scale(1.1); box-shadow: 0 4px 14px rgba(6,214,224,0.35); }
.btn-new-cl svg { width: 16px; height: 16px; }
.cl-list { flex: 1; overflow-y: auto; padding: 10px 8px; display: flex; flex-direction: column; gap: 3px; }
.cl-item { display: flex; align-items: center; gap: 12px; padding: 12px 13px; border-radius: 14px; cursor: pointer; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; border: 1.5px solid transparent; }
.cl-item:hover { background: rgba(155,93,229,0.05); border-color: rgba(155,93,229,0.10); }
.cl-item.active { background: rgba(155,93,229,0.09); border-color: rgba(155,93,229,0.22); box-shadow: 0 2px 10px rgba(155,93,229,0.10); }
.cl-avatar { width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #fff; }
.cl-info { flex: 1; min-width: 0; }
.cl-name { font-size: 0.88rem; font-weight: 700; color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.25; }
.cl-meta { font-size: 0.73rem; color: var(--text-3); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cl-chevron { color: #cbd5e1; flex-shrink: 0; transition: color 0.15s; }
.cl-item.active .cl-chevron { color: var(--violet); }
.cl-item:hover .cl-chevron { color: #94a3b8; }
.cl-works-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pink); flex-shrink: 0; }
.cl-empty-side { padding: 40px 16px; text-align: center; color: var(--text-3); font-size: 0.8rem; line-height: 1.6; }
/* Right: class detail */
.cl-main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; min-width: 0; }
@keyframes panelFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
#detail-panel.panel-visible,
#personal-panel.panel-visible { animation: panelFadeIn 0.22s ease forwards; }
.cl-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; padding: 80px 40px; text-align: center; }
.cl-placeholder-icon { width: 72px; height: 72px; border-radius: 22px; background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(6,214,224,0.08)); display: flex; align-items: center; justify-content: center; margin-bottom: 4px; }
.cl-placeholder-icon svg { width: 32px; height: 32px; stroke: var(--violet); stroke-width: 1.6; }
.cl-placeholder-title { font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; color: #0F172A; }
.cl-placeholder-sub { font-size: 0.82rem; color: var(--text-3); max-width: 220px; line-height: 1.6; }
.cl-detail-wrap { padding: 28px 32px 60px; flex: 1; }
.btn-primary { padding: 10px 24px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
.btn-primary:hover { transform: translateY(-1px); }
.btn-delete-class { padding: 7px 16px; border: 1.5px solid rgba(241,91,181,0.3); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-delete-class:hover { border-color: var(--pink); color: #c0306a; background: rgba(241,91,181,0.06); }
/* ── Detail panel ── */
.detail-panel { background: #fff; border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 24px 28px; box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.invite-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 12px; border-radius: var(--r-pill);
background: rgba(155,93,229,0.07); color: var(--violet);
font-family: monospace; font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.05em; cursor: pointer;
transition: background var(--tr);
border: 1px dashed rgba(155,93,229,0.25);
}
.invite-badge:hover { background: rgba(155,93,229,0.14); }
.detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
.detail-title { font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 800; }
.detail-sub { font-size: 0.84rem; color: var(--text-3); margin-top: 4px; }
.detail-actions { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; }
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 4px; width: fit-content; }
.tab-btn { padding: 7px 20px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.tab-btn.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* ── Members table ── */
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
th { padding: 10px 14px; text-align: left; font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid var(--border); background: rgba(238,242,255,0.5); }
td { padding: 11px 14px; font-size: 0.83rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
.pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
.pct-hi { color: var(--green); } .pct-mid { color: var(--amber); } .pct-lo { color: var(--pink); }
/* ── Assignments ── */
.assign-list { display: flex; flex-direction: column; gap: 12px; }
.assign-item { border: 1px solid var(--border); border-radius: 14px; padding: 16px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; transition: border-color var(--tr); }
.assign-item:hover { border-color: var(--border-h); }
.assign-title { font-size: 0.9rem; font-weight: 700; flex: 1; }
.assign-meta { font-size: 0.76rem; color: var(--text-3); margin-top: 3px; }
.assign-progress { text-align: right; }
.assign-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; color: var(--violet); }
.assign-sub { font-size: 0.72rem; color: var(--text-3); }
.deadline-badge { display: inline-block; padding: 2px 9px; border-radius: var(--r-pill); font-size: 0.7rem; font-weight: 700; }
.deadline-ok { background: rgba(6,214,100,0.1); color: var(--green); }
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
/* ── Student search ── */
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
.student-search-wrap .form-input { width: 100%; }
.student-dropdown { position: absolute; top: calc(100% + 4px); left: 0; right: 0; background: #fff; border: 1.5px solid var(--border-h); border-radius: 12px; box-shadow: 0 8px 32px rgba(15,23,42,0.12); max-height: 240px; overflow-y: auto; z-index: 50; display: none; }
.student-dropdown.open { display: block; }
.student-opt { padding: 10px 14px; cursor: pointer; font-size: 0.86rem; transition: background var(--tr); }
.student-opt:hover, .student-opt.focused { background: rgba(155,93,229,0.07); }
.student-opt-name { font-weight: 700; color: var(--text); }
.student-opt-email { font-size: 0.76rem; color: var(--text-3); }
.student-opt-empty { padding: 12px 14px; color: var(--text-3); font-size: 0.84rem; text-align: center; }
/* ── Assignment type toggle ── */
.atype-tabs { display: flex; gap: 0; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 3px; margin-bottom: 20px; flex-wrap: wrap; }
.atype-tab { flex: 1 1 auto; padding: 7px 10px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); text-align: center; white-space: nowrap; }
.atype-tab.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
/* ── File picker ── */
.file-picker-search { width: 100%; }
.file-picker-list { max-height: 200px; overflow-y: auto; border: 1.5px solid var(--border-h); border-radius: 10px; margin-top: 8px; }
.file-pick-item { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; transition: background var(--tr); }
.file-pick-item:last-child { border-bottom: none; }
.file-pick-item:hover { background: rgba(155,93,229,0.05); }
.file-pick-item.selected { background: rgba(155,93,229,0.10); }
.file-pick-name { font-size: 0.86rem; font-weight: 600; flex: 1; }
.file-pick-subj { font-size: 0.74rem; color: var(--text-3); }
.file-pick-check { color: var(--violet); font-size: 1rem; }
/* ── Modal ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.35); backdrop-filter: blur(4px); z-index: 200; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: var(--r-lg); padding: 32px; width: 100%; max-width: 520px; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(15,23,42,0.2); }
.modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 24px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
.form-input, .form-textarea, .form-select { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); background: #fafbff; transition: border-color var(--tr); }
.form-input:focus, .form-textarea:focus, .form-select:focus { outline: none; border-color: var(--violet); }
.btn-cancel { padding: 10px 22px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; }
.btn-save { padding: 10px 28px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; }
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
.spinner { width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 30px auto; }
/* ── announcements ── */
.ann-item { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; gap: 12px; }
.ann-body { flex: 1; }
.ann-author { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
.ann-text { font-size: 0.87rem; margin-top: 4px; white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
.ann-date { font-size: 0.72rem; color: var(--text-3); margin-top: 6px; }
.ann-del { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 1rem; padding: 0; transition: color var(--tr); }
.ann-del:hover { color: var(--pink); }
/* ── notification bell ── */
.notif-bell-wrap { position: relative; }
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 8px; border-bottom: 1px solid var(--border); }
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.75rem; font-weight: 800; }
.notif-read-all { background: none; border: none; font-size: 0.72rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
.notif-item { display: flex; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; transition: background var(--tr); }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: rgba(155,93,229,0.04); }
.notif-item.unread { background: rgba(155,93,229,0.06); }
.notif-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
.notif-msg { font-size: 0.79rem; line-height: 1.4; flex: 1; }
.notif-time { font-size: 0.68rem; color: var(--text-3); margin-top: 2px; }
.notif-empty { padding: 24px 14px; text-align: center; color: var(--text-3); font-size: 0.82rem; }
.empty { text-align: center; padding: 30px; color: var(--text-3); font-size: 0.88rem; }
.error { color: var(--pink); font-size: 0.85rem; padding: 8px 0; }
.toast { position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%); background: #0F172A; color: #fff; padding: 10px 24px; border-radius: var(--r-pill); font-size: 0.85rem; font-weight: 600; z-index: 999; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
/* ── Teacher dashboard ── */
.dash-stat-row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.dash-stat-card { flex: 1; min-width: 120px; background: rgba(155,93,229,0.06); border: 1.5px solid rgba(155,93,229,0.14); border-radius: 14px; padding: 16px 20px; }
.dash-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 900; color: var(--violet); }
.dash-stat-lbl { font-size: 0.75rem; color: var(--text-3); font-weight: 600; margin-top: 4px; }
.dash-assign-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.dash-assign-row:last-child { border-bottom: none; }
.dash-assign-bar-wrap { flex: 1; height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; min-width: 80px; }
.dash-assign-bar { height: 100%; border-radius: 99px; background: linear-gradient(90deg,#06D6E0,#9B5DE5); transition: width .5s ease; }
.dash-lagging { background: rgba(241,91,181,0.07); border: 1.5px solid rgba(241,91,181,0.18); border-radius: 12px; padding: 10px 14px; font-size: 0.82rem; color: var(--pink); margin-top: 4px; display: inline-block; }
/* ── Journal / grade matrix ── */
.journal-wrap { overflow-x: auto; }
.journal-table { border-collapse: collapse; min-width: 100%; }
.journal-table th { padding: 8px 12px; font-size: 0.7rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1.5px solid var(--border); background: rgba(238,242,255,0.5); white-space: nowrap; }
.journal-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.82rem; white-space: nowrap; text-align: center; }
.journal-table td:first-child { text-align: left; font-weight: 600; color: var(--text); }
.journal-table tr:last-child td { border-bottom: none; }
.j-cell-hi { background: rgba(6,214,100,0.12); color: #059669; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
.j-cell-mid { background: rgba(255,179,71,0.14); color: #b45309; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
.j-cell-lo { background: rgba(241,91,181,0.12); color: #be185d; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
.j-cell-none { color: var(--text-3); font-size: 0.76rem; }
/* ── Settings tab ── */
.settings-section { background: #fff; border: 1.5px solid var(--border); border-radius: 14px; padding: 20px 24px; margin-bottom: 16px; }
.settings-section-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; margin-bottom: 16px; color: var(--text); }
.settings-danger { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.03); }
.settings-danger .settings-section-title { color: #be185d; }
.invite-regen-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.invite-code-display { font-family: monospace; font-size: 1.1rem; font-weight: 700; color: var(--violet); background: rgba(155,93,229,0.08); border: 1.5px dashed rgba(155,93,229,0.3); border-radius: 10px; padding: 8px 18px; letter-spacing: 0.08em; }
/* results overlay */
.results-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.45); backdrop-filter: blur(6px); z-index: 300; display: none; align-items: center; justify-content: center; padding: 20px; }
.results-overlay.open { display: flex; }
.results-box { background: #fff; border-radius: var(--r-lg); padding: 28px 32px 32px; width: 100%; max-width: 740px; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(15,23,42,0.2); }
/* overlay header */
.res-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.res-header-title { font-family: 'Unbounded',sans-serif; font-weight: 800; font-size: 1rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.res-csv-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.78rem; font-weight: 600; color: var(--text-2); cursor: pointer; transition: all var(--tr); white-space: nowrap; }
.res-csv-btn:hover { border-color: var(--violet); color: var(--violet); }
.res-close-btn { padding: 6px 14px; border: 1.5px solid rgba(241,91,181,0.3); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.78rem; font-weight: 600; color: #c0306a; cursor: pointer; transition: all var(--tr); white-space: nowrap; }
.res-close-btn:hover { border-color: var(--pink); background: rgba(241,91,181,0.06); }
/* overlay tabs */
.res-tabs { display: flex; gap: 4px; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 4px; margin-bottom: 20px; width: fit-content; }
.res-tab-btn { padding: 6px 18px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.res-tab-btn.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
/* summary stats chips */
.res-stat-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.res-stat-chip { flex: 1; min-width: 90px; background: rgba(155,93,229,0.06); border: 1.5px solid rgba(155,93,229,0.13); border-radius: 12px; padding: 12px 16px; text-align: center; }
.res-stat-val { font-family: 'Unbounded',sans-serif; font-size: 1.3rem; font-weight: 900; color: var(--violet); }
.res-stat-lbl { font-size: 0.7rem; color: var(--text-3); font-weight: 600; margin-top: 3px; }
/* completion bar */
.res-prog-wrap { height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; margin-bottom: 20px; }
.res-prog-fill { height: 100%; background: linear-gradient(90deg,#06D6E0,#9B5DE5); border-radius: 99px; transition: width .5s ease; }
/* score distribution */
.res-distrib { margin-bottom: 20px; }
.res-distrib-title { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing:.06em; margin-bottom: 10px; }
.res-distrib-bars { display: flex; gap: 6px; align-items: flex-end; height: 72px; }
.res-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.res-bar-fill { width: 100%; border-radius: 6px 6px 0 0; min-height: 4px; transition: height .4s ease; }
.res-bar-lbl { font-size: 0.66rem; color: var(--text-3); font-weight: 600; text-align: center; line-height: 1.2; }
.res-bar-cnt { font-size: 0.72rem; font-weight: 700; color: var(--text); }
/* student list */
.res-row { display: flex; align-items: center; gap: 14px; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background var(--tr); }
.res-row:hover { background: rgba(155,93,229,0.05); }
.res-row + .res-row { margin-top: 2px; }
.res-rank { font-family: 'Unbounded', sans-serif; font-size: 0.85rem; font-weight: 800; min-width: 28px; color: var(--text-3); }
.res-name { flex: 1; }
.res-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
.res-score { font-size: 0.8rem; color: var(--text-3); }
.res-pending { color: var(--text-3); font-style: italic; font-size: 0.82rem; }
/* question stats tab */
.qs-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 10px; margin-bottom: 6px; background: rgba(15,23,42,0.02); border: 1px solid var(--border); }
.qs-num { font-family: 'Unbounded',sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--text-3); min-width: 20px; }
.qs-text { flex: 1; font-size: 0.82rem; font-weight: 500; line-height: 1.4; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.qs-bar-wrap { width: 90px; height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; flex-shrink: 0; }
.qs-bar-fill { height: 100%; border-radius: 99px; }
.qs-pct { font-family: 'Unbounded',sans-serif; font-size: 0.78rem; font-weight: 800; min-width: 38px; text-align: right; flex-shrink: 0; }
.qs-cnt { font-size: 0.7rem; color: var(--text-3); min-width: 54px; text-align: right; flex-shrink: 0; }
/* drill-down review */
.res-drill { display: none; }
.res-drill.open { display: block; }
.res-drill-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
.res-back-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-2); cursor: pointer; transition: all var(--tr); }
.res-back-btn:hover { border-color: var(--violet); color: var(--violet); }
.res-drill-name { font-family: 'Unbounded',sans-serif; font-size: 0.95rem; font-weight: 800; }
.res-drill-pct { font-family: 'Unbounded',sans-serif; font-size: 0.85rem; font-weight: 800; margin-left: auto; }
/* question review items */
.rq-item { border: 1.5px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; }
.rq-item.correct { border-color: rgba(6,214,100,0.3); background: rgba(6,214,100,0.04); }
.rq-item.wrong { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.04); }
.rq-item.skipped { border-color: rgba(15,23,42,0.12); background: rgba(15,23,42,0.02); }
.rq-num { font-size: 0.68rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing:.05em; margin-bottom: 5px; }
.rq-text { font-size: 0.88rem; font-weight: 600; line-height: 1.45; margin-bottom: 10px; }
.rq-opts { display: flex; flex-direction: column; gap: 5px; }
.rq-opt { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; padding: 5px 10px; border-radius: 8px; }
.rq-opt.chosen { background: rgba(241,91,181,0.1); color: #be185d; font-weight: 600; }
.rq-opt.answer { background: rgba(6,214,100,0.1); color: #059669; font-weight: 600; }
.rq-opt.both { background: rgba(6,214,100,0.12); color: #059669; font-weight: 700; }
.rq-icon { font-size: 0.9rem; flex-shrink: 0; }
.rq-expl { margin-top: 10px; font-size: 0.79rem; color: var(--text-3); background: rgba(155,93,229,0.05); border-radius: 8px; padding: 8px 12px; line-height: 1.5; }
/* ── Mobile responsive ── */
@media (max-width: 1000px) {
.cl-side { width: 240px; }
}
@media (max-width: 900px) {
.cl-side { width: 200px; }
.cl-chevron { display: none; }
}
@media (max-width: 768px) {
/* Split → stacked with toggle */
.sb-content.classes-split { flex-direction: column; height: auto; overflow: visible; }
.cl-side {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
max-height: none; overflow: visible;
}
.cl-side-header { padding: 14px 12px 10px; }
.cl-list {
display: flex; flex-direction: row; overflow-x: auto; overflow-y: hidden;
gap: 6px; padding: 8px 10px; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.cl-list::-webkit-scrollbar { display: none; }
.cl-item {
flex-shrink: 0; padding: 8px 12px; border-radius: 12px;
min-width: auto; gap: 8px;
}
.cl-avatar { width: 32px; height: 32px; border-radius: 10px; font-size: 0.7rem; }
.cl-avatar svg, .cl-avatar i { width: 16px !important; height: 16px !important; }
.cl-name { font-size: 0.78rem; max-width: 100px; }
.cl-meta { display: none; }
.cl-item-personal { padding: 8px 12px; }
.cl-item-personal .cl-avatar { width: 32px; height: 32px; }
.cl-personal-sep { display: none; }
.cl-main { min-height: 50vh; }
.cl-detail-wrap { padding: 16px 12px 40px; }
.cl-placeholder { padding: 40px 20px; }
/* Detail panel */
.detail-panel { padding: 16px 14px; border-radius: 14px; }
.detail-header { gap: 10px; flex-direction: column; }
.detail-title { font-size: 0.95rem; }
.detail-sub { font-size: 0.78rem; word-break: break-word; }
.detail-actions { margin-left: 0; width: 100%; justify-content: flex-start; }
.detail-actions .btn-ghost,
.detail-actions .invite-badge { font-size: 0.72rem; padding: 5px 10px; }
/* Tabs → scrollable */
.tabs {
width: 100%; overflow-x: auto; flex-wrap: nowrap;
scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { display: none; }
.tab-btn { padding: 6px 14px; font-size: 0.76rem; white-space: nowrap; flex-shrink: 0; }
/* Tables */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 500px; }
th, td { padding: 8px 10px; font-size: 0.76rem; }
/* Assignments */
.assign-item { padding: 12px 14px; gap: 10px; }
.assign-title { font-size: 0.82rem; }
.assign-item .btn-ghost { font-size: 0.72rem; padding: 5px 10px; }
/* Modals */
.modal { max-width: calc(100vw - 24px); margin: 0 12px; border-radius: 18px; }
.modal-title { font-size: 0.95rem; }
/* Icon picker */
.icon-picker { grid-template-columns: repeat(auto-fill, 36px); max-height: 140px; }
.ip-btn { width: 36px; height: 36px; }
.ip-btn svg { width: 16px; height: 16px; }
.ip-preview { width: 44px; height: 44px; }
.ip-preview svg { width: 22px; height: 22px; }
/* Assignment type tabs */
.atype-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.atype-tabs::-webkit-scrollbar { display: none; }
.atype-tab { font-size: 0.72rem; padding: 6px 8px; white-space: nowrap; flex-shrink: 0; }
/* Works cards */
.work-card { flex-wrap: wrap; gap: 8px; }
.work-meta { width: 100%; display: flex; align-items: center; gap: 8px; justify-content: flex-start; }
/* Settings */
.settings-section { padding: 14px 0; }
/* Announcements */
.announcement-card { padding: 14px; }
/* Journal table */
.journal-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.journal-table { min-width: 500px; }
}
@media (max-width: 480px) {
.cl-detail-wrap { padding: 12px 8px 30px; }
.detail-panel { padding: 12px 10px; }
.detail-title { font-size: 0.88rem; }
.tabs { gap: 2px; padding: 3px; }
.tab-btn { padding: 5px 10px; font-size: 0.72rem; }
.modal { padding: 18px 14px; }
}
/* ── Submissions (works tab) ── */
.works-filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; align-items: center; }
.works-chip {
padding: 4px 14px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.1);
background: #fff; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: 0.74rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.works-chip.active { background: #0F172A; color: #fff; border-color: #0F172A; }
.works-chip:hover:not(.active) { border-color: rgba(155,93,229,0.35); color: var(--violet); }
.work-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-left: 4px solid var(--wc, #9B5DE5);
border-radius: 14px; padding: 14px 16px;
display: flex; align-items: flex-start; gap: 14px;
cursor: pointer; transition: all 0.18s;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
margin-bottom: 8px;
}
.work-card:hover { box-shadow: 0 6px 24px rgba(15,23,42,0.1); border-color: rgba(15,23,42,0.14); }
.work-card.reviewed { --wc: #059652; }
.work-card.new { --wc: #06D6E0; }
.work-icon {
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
background: rgba(155,93,229,0.08); color: var(--violet);
display: flex; align-items: center; justify-content: center;
}
.work-icon svg { width: 18px; height: 18px; }
.work-body { flex: 1; min-width: 0; }
.work-student { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 2px; }
.work-assign { font-size: 0.76rem; color: var(--violet); font-weight: 600; margin-bottom: 3px; }
.work-file { font-size: 0.74rem; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-msg { font-size: 0.76rem; color: #3D4F6B; margin-top: 5px; line-height: 1.4; font-style: italic; }
.work-note { font-size: 0.74rem; color: #059652; margin-top: 4px; font-style: italic; }
.work-meta { font-size: 0.70rem; color: var(--text-3); white-space: nowrap; flex-shrink: 0; text-align: right; }
.work-status {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 9px; border-radius: 99px; font-size: 0.70rem; font-weight: 700;
margin-bottom: 6px;
}
.work-status.new { background: rgba(6,214,224,0.1); color: #06aab3; }
.work-status.reviewed { background: rgba(5,150,82,0.1); color: #059652; }
.work-grade {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 9px; border-radius: 99px; font-size: 0.72rem; font-weight: 800;
background: rgba(155,93,229,0.1); color: var(--violet);
margin-left: 6px;
}
.work-grade.high { background: rgba(5,150,82,0.12); color: #059652; }
.work-grade.mid { background: rgba(255,193,7,0.14); color: #c07c00; }
.work-grade.low { background: rgba(241,91,181,0.12); color: #c0306a; }
.work-del-btn {
margin-top: 6px; border: none; background: transparent; color: #c0306a;
cursor: pointer; opacity: 0.4; transition: opacity 0.15s; padding: 4px;
border-radius: 6px; display: flex; align-items: center; justify-content: center;
margin-left: auto;
}
.work-del-btn:hover { opacity: 1; background: rgba(241,91,181,0.1); }
/* grade input row */
.grade-input-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.grade-input-row label { font-size: 0.8rem; font-weight: 600; color: #3D4F6B; white-space: nowrap; }
.grade-input {
width: 80px; padding: 6px 10px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 8px;
font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; color: #0F172A;
background: #fff; text-align: center;
}
.grade-input:focus { outline: none; border-color: var(--violet); }
.grade-letter {
font-size: 1.1rem; font-weight: 900;
width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.08); color: var(--violet); flex-shrink: 0;
}
/* review modal */
#review-modal .modal { max-width: 480px; }
.review-file-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 10px; font-size: 0.82rem; font-weight: 600;
background: rgba(155,93,229,0.07); color: var(--violet); text-decoration: none;
border: 1.5px solid rgba(155,93,229,0.2); transition: background 0.15s;
}
.review-file-link:hover { background: rgba(155,93,229,0.14); }
/* ── Icon picker ── */
.icon-picker-label { font-size: 0.78rem; font-weight: 600; color: var(--text-3); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
.icon-picker {
display: grid; grid-template-columns: repeat(auto-fill, 42px); gap: 4px;
max-height: 180px; overflow-y: auto; padding: 8px;
background: rgba(15,23,42,0.02); border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 12px;
}
.icon-picker::-webkit-scrollbar { width: 5px; }
.icon-picker::-webkit-scrollbar-thumb { background: rgba(15,23,42,0.12); border-radius: 99px; }
.ip-btn {
width: 42px; height: 42px; border: 2px solid transparent; border-radius: 10px;
background: #fff; cursor: pointer; display: flex;
align-items: center; justify-content: center; transition: all 0.12s;
color: #3D4F6B;
}
.ip-btn svg { width: 20px; height: 20px; }
.ip-btn:hover { border-color: rgba(155,93,229,0.3); transform: scale(1.1); color: var(--violet); }
.ip-btn.selected { border-color: var(--violet); background: rgba(155,93,229,0.1); color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.15); }
.ip-preview {
width: 52px; height: 52px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.08); border: 2px solid rgba(155,93,229,0.15);
flex-shrink: 0; color: var(--violet);
}
.ip-preview svg { width: 28px; height: 28px; }
.ip-row { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.color-picker { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.cp-btn {
width: 26px; height: 26px; border-radius: 50%; border: 2.5px solid transparent;
cursor: pointer; transition: all 0.12s; flex-shrink: 0;
}
.cp-btn:hover { transform: scale(1.2); }
.cp-btn.selected { border-color: #0F172A; box-shadow: 0 0 0 2px #fff, 0 0 0 4px currentColor; }
/* ── Personal assignments ── */
.cl-personal-sep { height: 1px; background: var(--border); margin: 6px 10px 8px; }
.cl-item-personal .cl-avatar {
background: linear-gradient(135deg, #F15BB5, #FF9F1C);
display: flex; align-items: center; justify-content: center;
}
.pa-item {
display: flex; align-items: flex-start; gap: 14px;
padding: 14px 16px; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 12px; margin-bottom: 8px; background: #fff;
transition: border-color var(--tr), box-shadow var(--tr);
}
.pa-item:hover { border-color: rgba(241,91,181,0.25); box-shadow: 0 2px 12px rgba(15,23,42,0.05); }
.pa-student {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 10px; border-radius: 999px;
background: rgba(241,91,181,0.10); color: #9b3c7e;
font-size: 0.72rem; font-weight: 700;
}
.pa-status-done {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 10px; border-radius: 999px;
background: rgba(6,214,100,0.10); color: #059669;
font-size: 0.72rem; font-weight: 700;
}
.pa-status-pending {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 10px; border-radius: 999px;
background: rgba(15,23,42,0.06); color: var(--text-3);
font-size: 0.72rem; font-weight: 700;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content classes-split">
<!-- Left: class list sidebar -->
<div class="cl-side">
<div class="cl-side-header">
<span class="cl-side-title">Классы</span>
<a href="/gradebook" class="btn-new-cl" style="background:rgba(155,93,229,0.15);color:var(--violet)" title="Журнал оценок">
<i data-lucide="table"></i>
</a>
<button class="btn-new-cl" onclick="openCreateClass()" title="Создать класс">
<i data-lucide="plus"></i>
</button>
</div>
<div class="cl-item cl-item-personal" onclick="openPersonalAssignments()" id="cc-personal">
<div class="cl-avatar">
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;stroke:#fff;stroke-width:2;fill:none"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="cl-info">
<div class="cl-name">Личные задания</div>
<div class="cl-meta" id="pa-sidebar-count"></div>
</div>
<i data-lucide="chevron-right" class="cl-chevron" style="width:15px;height:15px"></i>
</div>
<div class="cl-personal-sep"></div>
<div class="cl-list" id="classes-grid"><div class="spinner" style="margin:20px auto;width:24px;height:24px;border-width:2px"></div></div>
</div>
<!-- Right: class content -->
<div class="cl-main">
<!-- Empty state -->
<div class="cl-placeholder" id="cl-placeholder">
<div class="cl-placeholder-icon"><i data-lucide="graduation-cap"></i></div>
<div class="cl-placeholder-title">Выберите класс</div>
<div class="cl-placeholder-sub">Нажмите на класс слева или создайте новый</div>
<button class="btn-primary" onclick="openCreateClass()" style="margin-top:4px">+ Создать класс</button>
</div>
<!-- Personal Assignments Panel -->
<div id="personal-panel" style="display:none">
<div class="cl-detail-wrap">
<div class="detail-panel">
<div class="detail-header">
<div style="flex:1;min-width:0">
<div class="detail-title">Личные задания</div>
<div class="detail-sub">Задания, выданные напрямую конкретным ученикам</div>
</div>
</div>
<div id="personal-list" style="margin-top:20px"></div>
</div>
</div>
</div>
<!-- Detail panel -->
<div id="detail-panel" style="display:none">
<div class="cl-detail-wrap">
<div class="detail-panel">
<div class="detail-header">
<div style="flex:1;min-width:0">
<div class="detail-title" id="d-name"></div>
<div class="detail-sub" id="d-sub"></div>
</div>
<div class="detail-actions">
<button class="btn-ghost" onclick="copyInvite()"><i data-lucide="link-2" style="width:13px;height:13px;vertical-align:-2px"></i> Ссылка</button>
<button class="btn-primary" onclick="openCreateAssignment()">+ Задание</button>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
<button class="tab-btn" data-tab="theory" onclick="switchDetailTab(this)"><i data-lucide="brain" style="width:13px;height:13px;vertical-align:-2px"></i> Теория</button>
<button class="tab-btn" data-tab="settings" onclick="switchDetailTab(this)"><i data-lucide="settings" style="width:13px;height:13px;vertical-align:-2px"></i> Настройки</button>
</div>
<!-- Dashboard -->
<div class="tab-pane active" id="dtab-dash">
<div id="dash-content"><div class="spinner"></div></div>
</div>
<!-- Members -->
<div class="tab-pane" id="dtab-members">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
<div class="student-search-wrap">
<input class="form-input" id="add-member-search" placeholder="Поиск ученика по имени или email…" autocomplete="off"
oninput="filterStudents(this.value)" onfocus="openStudentDrop()" onblur="setTimeout(closeStudentDrop,180)" />
<div class="student-dropdown" id="student-drop"></div>
</div>
<button class="btn-ghost" onclick="doAddMember()" id="btn-add-member" disabled>+ Добавить</button>
<span id="add-member-err" style="font-size:0.82rem;color:var(--pink)"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Имя</th><th>Email</th><th>Тестов</th><th>Средний %</th><th>Вступил</th><th></th></tr></thead>
<tbody id="d-members"></tbody>
</table>
</div>
</div>
<!-- Assignments -->
<div class="tab-pane" id="dtab-assign">
<div class="assign-list" id="d-assignments"></div>
</div>
<!-- Journal -->
<div class="tab-pane" id="dtab-journal">
<div id="journal-content"><div class="spinner"></div></div>
</div>
<!-- Works (student submissions) -->
<div class="tab-pane" id="dtab-works">
<div id="works-content"><div class="spinner"></div></div>
</div>
<!-- Theory -->
<div class="tab-pane" id="dtab-theory">
<div id="theory-content"><div class="spinner"></div></div>
</div>
<!-- Settings -->
<div class="tab-pane" id="dtab-settings">
<div class="settings-section">
<div class="settings-section-title">Основная информация</div>
<div class="ip-row">
<div class="ip-preview" id="set-icon-preview"><i data-lucide="book-open"></i></div>
<div style="flex:1">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Название класса</label>
<input class="form-input" id="set-name" placeholder="11А · Биология" />
</div>
</div>
</div>
<div class="form-group">
<div class="icon-picker-label">Иконка класса</div>
<div class="icon-picker" id="set-icon-picker"></div>
<div class="icon-picker-label" style="margin-top:10px">Цвет</div>
<div class="color-picker" id="set-color-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea class="form-textarea" id="set-desc" rows="2" placeholder="Подготовка к ЦТ 2026"></textarea>
</div>
<button class="btn-primary" onclick="saveClassSettings()">Сохранить изменения</button>
</div>
<div class="settings-section">
<div class="settings-section-title">Код приглашения</div>
<div class="invite-regen-row">
<div class="invite-code-display" id="set-invite-code"></div>
<button class="btn-ghost" onclick="copyInvite()"><i data-lucide="link-2" style="width:13px;height:13px;vertical-align:-2px"></i> Скопировать ссылку</button>
<button class="btn-ghost" onclick="doRegenerateCode()"><i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:-2px"></i> Обновить код</button>
</div>
<div style="margin-top:10px;font-size:0.77rem;color:var(--text-3);line-height:1.5">
После обновления старый код перестанет работать. Ученики, которые уже вступили, останутся в классе.
</div>
</div>
<div class="settings-section" id="set-modules">
<div class="settings-section-title">Модули класса</div>
<p style="font-size:0.82rem;color:var(--text-2);margin-bottom:14px;line-height:1.5">Отключённые модули скроются у учеников этого класса.</p>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:16px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-gamification" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg> Геймификация (XP, уровни, рейтинг)
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-collection" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
🃏 Коллекция
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-hangman" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> Виселица
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-crossword" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Кроссворд
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-red_book" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg> Красная книга
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
<input type="checkbox" id="feat-pet" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg> Питомец
</label>
</div>
<button class="btn-primary" onclick="saveModules()">Сохранить модули</button>
</div>
<div class="settings-section settings-danger">
<div class="settings-section-title">Опасная зона</div>
<p style="font-size:0.83rem;color:var(--text-2);margin-bottom:14px;line-height:1.5">Удаление класса необратимо. Все задания, участники и объявления будут удалены.</p>
<button class="btn-delete-class" onclick="confirmDeleteClass()"><i data-lucide="triangle-alert" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить класс</button>
</div>
</div>
<!-- Announcements -->
<div class="tab-pane" id="dtab-announce">
<div style="display:flex;gap:10px;margin-bottom:20px;align-items:flex-end">
<textarea class="form-textarea" id="announce-text" rows="2" placeholder="Текст объявления…" style="flex:1;resize:vertical"></textarea>
<button class="btn-save" id="announce-send-btn" onclick="sendAnnouncement()" style="white-space:nowrap">Отправить</button>
</div>
<div id="announce-list"><div class="spinner"></div></div>
</div>
</div><!-- /detail-panel (.detail-panel card) -->
</div><!-- /cl-detail-wrap -->
</div><!-- /#detail-panel -->
</div><!-- /cl-main -->
</div><!-- /sb-content -->
<!-- Modal: Create class -->
<!-- Modal: Create assignment -->
<div class="modal-overlay" id="modal-assign" onclick="closeOnOverlay(event,'modal-assign')">
<div class="modal">
<div class="modal-title" id="modal-assign-title">Новое задание</div>
<!-- Шаблоны -->
<div style="display:flex;gap:8px;align-items:center;margin-bottom:18px">
<select class="form-select" id="tpl-select" style="flex:1;font-size:0.82rem">
<option value="">— Загрузить шаблон —</option>
</select>
<button class="btn-ghost" onclick="loadTemplateIntoForm()" style="padding:7px 14px;white-space:nowrap">Загрузить</button>
</div>
<!-- Тип контента: случайные / готовый тест / файл -->
<div class="atype-tabs">
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
</div>
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
Вопросы подбираются случайно из базы по выбранному предмету
</div>
<!-- Кому: класс / ученик / несколько классов + ДЗ-переключатель -->
<div style="display:flex;gap:10px;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:180px;margin-bottom:0">
<label class="form-label">Кому</label>
<select class="form-select" id="a-target" onchange="onTargetChange()">
<option value="class">Всему классу</option>
<option value="multi">Нескольким классам…</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:4px;padding-top:22px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.86rem;font-weight:600;color:var(--text-2)">
<input type="checkbox" id="a-is-hw" onchange="onHwChange()" style="width:16px;height:16px;accent-color:var(--violet)">
<i data-lucide="book-open" style="width:15px;height:15px;vertical-align:-3px;color:var(--violet)"></i> Домашнее задание
</label>
<div id="a-hw-hint" style="display:none;font-size:0.72rem;color:var(--text-3);line-height:1.45;max-width:200px">
Доступно в любое время, без ограничений режима
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Название</label>
<input class="form-input" id="a-title" placeholder="Домашнее задание №3" />
</div>
<!-- Поля для случайных вопросов -->
<div id="a-test-fields">
<div class="form-group">
<label class="form-label">Предмет</label>
<select class="form-select" id="a-subject">
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
<div class="form-group" id="a-mode-group">
<label class="form-label">Режим</label>
<select class="form-select" id="a-mode">
<option value="exam">Экзамен</option>
<option value="practice">Тренировка</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Количество вопросов</label>
<select class="form-select" id="a-count">
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="40">40</option>
<option value="60">60</option>
</select>
</div>
</div>
<!-- Поля для готового теста -->
<div id="a-fixtest-fields" style="display:none">
<div class="form-group">
<label class="form-label">Выбери тест из библиотеки</label>
<input class="form-input file-picker-search" id="a-test-search" placeholder="Поиск теста…" oninput="filterTestList(this.value)" />
<div class="file-picker-list" id="a-test-list"><div class="student-opt-empty">Загрузка…</div></div>
<div id="a-test-selected" style="display:none;margin-top:8px;font-size:0.84rem;color:var(--violet);font-weight:600"></div>
</div>
</div>
<!-- Поля для файла -->
<div id="a-file-fields" style="display:none">
<div class="form-group">
<label class="form-label">Файл из библиотеки</label>
<input class="form-input file-picker-search" id="a-file-search" placeholder="Поиск файла…" oninput="filterFileList(this.value)" />
<div class="file-picker-list" id="a-file-list"><div class="student-opt-empty">Загрузка…</div></div>
<div id="a-file-selected" style="display:none;margin-top:8px;font-size:0.84rem;color:var(--violet);font-weight:600"></div>
</div>
</div>
<div class="form-group">
<label class="form-label">Дедлайн <span style="font-weight:400;text-transform:none;font-size:0.72rem;color:var(--text-3)">(необязательно — если не задан, задание открыто бессрочно)</span></label>
<input class="form-input" id="a-deadline" type="datetime-local" />
</div>
<div class="form-group">
<label class="form-label">Попытки <span style="font-weight:400;text-transform:none;font-size:0.72rem;color:var(--text-3)">(сколько раз ученик может пройти задание)</span></label>
<select class="form-select" id="a-max-attempts">
<option value="0">Без ограничений</option>
<option value="1">1 попытка</option>
<option value="2">2 попытки</option>
<option value="3">3 попытки</option>
<option value="5">5 попыток</option>
</select>
</div>
<!-- Multi-class selector (shown when target=multi) -->
<div id="a-multi-classes" style="display:none">
<div class="form-label" style="margin-bottom:8px">Выберите классы</div>
<div id="a-multi-class-list" style="display:flex;flex-direction:column;gap:6px;max-height:160px;overflow-y:auto;border:1.5px solid var(--border);border-radius:12px;padding:10px 12px"></div>
</div>
<div class="modal-footer">
<button class="btn-ghost" onclick="promptSaveTemplate()" style="margin-right:auto;font-size:0.8rem"><i data-lucide="save" style="width:13px;height:13px;vertical-align:-2px"></i> Сохранить шаблон</button>
<button class="btn-cancel" onclick="closeModal('modal-assign')">Отмена</button>
<button class="btn-save" id="btn-save-assign" onclick="saveAssignment()">Создать</button>
</div>
</div>
</div>
<!-- Results overlay -->
<div class="results-overlay" id="results-overlay" onclick="closeOnOverlay(event,'results-overlay')">
<div class="results-box">
<!-- ── Header ── -->
<div class="res-header">
<div class="res-header-title" id="res-title">Результаты</div>
<button class="res-csv-btn" onclick="exportResultsCSV()" id="res-csv-btn" style="display:none">
<i data-lucide="download" style="width:13px;height:13px"></i> CSV
</button>
<button class="res-close-btn" onclick="closeModal('results-overlay')">Закрыть <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<!-- ── Tabs ── -->
<div class="res-tabs" id="res-tabs" style="display:none">
<button class="res-tab-btn active" data-restab="students" onclick="switchResTab(this)">Ученики</button>
<button class="res-tab-btn" data-restab="questions" onclick="switchResTab(this)">По вопросам</button>
</div>
<!-- ── Tab: Students ── -->
<div id="restab-students">
<!-- stat chips -->
<div class="res-stat-row" id="res-stats" style="display:none">
<div class="res-stat-chip"><div class="res-stat-val" id="res-done-n">0</div><div class="res-stat-lbl">Сдали</div></div>
<div class="res-stat-chip"><div class="res-stat-val" id="res-total-n">0</div><div class="res-stat-lbl">Всего</div></div>
<div class="res-stat-chip"><div class="res-stat-val" id="res-avg-n"></div><div class="res-stat-lbl">Средний %</div></div>
<div class="res-stat-chip"><div class="res-stat-val" id="res-best-n"></div><div class="res-stat-lbl">Лучший %</div></div>
</div>
<!-- completion bar -->
<div class="res-prog-wrap" id="res-prog-wrap" style="display:none">
<div class="res-prog-fill" id="res-prog-bar" style="width:0%"></div>
</div>
<!-- score distribution -->
<div class="res-distrib" id="res-distrib" style="display:none">
<div class="res-distrib-title">Распределение баллов</div>
<div class="res-distrib-bars" id="res-distrib-bars"></div>
</div>
<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:10px;padding:0 2px" id="res-click-hint" style="display:none">Нажмите на ученика, чтобы посмотреть его ответы</div>
<div id="res-list"></div>
</div>
<!-- ── Tab: Questions ── -->
<div id="restab-questions" style="display:none">
<div id="res-q-list"><div class="spinner"></div></div>
</div>
<!-- ── Drill-down: individual student review ── -->
<div class="res-drill" id="res-drill">
<div class="res-drill-header">
<button class="res-back-btn" onclick="resDrillBack()"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Назад</button>
<div class="res-drill-name" id="res-drill-name"></div>
<div class="res-drill-pct" id="res-drill-pct"></div>
</div>
<div id="res-drill-list"></div>
</div>
</div>
</div>
<!-- Review submission modal -->
<div class="modal-overlay" id="review-modal" onclick="if(event.target===this)closeReviewModal()">
<div class="modal">
<div class="modal-title">Работа ученика</div>
<div id="rv-student" style="font-size:0.88rem;color:#3D4F6B;font-weight:600;margin-bottom:4px"></div>
<div id="rv-assign" style="font-size:0.78rem;color:var(--violet);margin-bottom:14px"></div>
<a id="rv-file-link" class="review-file-link" href="#" target="_blank" style="margin-bottom:14px;display:inline-flex">
<i data-lucide="download" style="width:14px;height:14px"></i>
<span id="rv-file-name"></span>
</a>
<div id="rv-message" style="background:#f8f9fc;border-radius:12px;padding:12px 14px;font-size:0.82rem;color:#3D4F6B;line-height:1.5;margin-bottom:14px;display:none"></div>
<div class="grade-input-row">
<label>Оценка (0100):</label>
<input class="grade-input" id="rv-grade" type="number" min="0" max="100" placeholder="—" oninput="updateGradeLetter()">
<div class="grade-letter" id="rv-grade-letter"></div>
</div>
<div class="form-group">
<label class="form-label">Комментарий учителя</label>
<textarea class="form-input" id="rv-note" rows="2" placeholder="Молодец! Или: нужно доработать раздел 2…" style="resize:vertical"></textarea>
</div>
<div class="modal-footer" style="justify-content:space-between">
<button class="btn-cancel" onclick="closeReviewModal()">Закрыть</button>
<button class="btn-save" id="btn-mark-reviewed" onclick="doMarkReviewed()">Отметить проверенным</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
if (!isTeacher) { window.location.href='/dashboard'; throw new Error(); }
const SUBJECTS = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const MODES = { exam:'Экзамен', practice:'Тренировка' };
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; }
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2200);
}
function deadlineBadge(d) {
if (!d) return '';
const dt = new Date(d), now = new Date(), diff = (dt - now) / 3600000;
if (dt < now) return `<span class="deadline-badge deadline-over">просрочено ${fmtDate(d)}</span>`;
if (diff < 48) return `<span class="deadline-badge deadline-soon">до ${fmtDate(d)}</span>`;
return `<span class="deadline-badge deadline-ok">до ${fmtDate(d)}</span>`;
}
/* ══ Classes list ══ */
let classes = [], currentClass = null, _classAssignments = [];
async function loadClasses() {
try {
classes = await LS.getClasses();
renderClasses();
} catch (e) {
document.getElementById('classes-grid').innerHTML = `<div class="error">${esc(e.message)}</div>`;
}
// Update personal assignments sidebar count in background
LS.teacherAssignments().then(all => {
updatePaSidebarCount(all.filter(a => a.class_id === 0).length);
}).catch(() => {});
}
const CC_GRADIENTS = [
'linear-gradient(135deg,#06D6E0,#9B5DE5)',
'linear-gradient(135deg,#F15BB5,#9B5DE5)',
'linear-gradient(135deg,#06D664,#06B6D4)',
'linear-gradient(135deg,#FF9F1C,#F15BB5)',
'linear-gradient(135deg,#3B82F6,#9B5DE5)',
'linear-gradient(135deg,#06D6E0,#06D664)',
];
function classGradient(id) { return CC_GRADIENTS[id % CC_GRADIENTS.length]; }
function classInitials(name) {
return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
}
function renderClasses() {
const grid = document.getElementById('classes-grid');
if (!classes.length) {
grid.innerHTML = `<div class="cl-empty-side">Классов нет.<br>Нажмите <strong>+</strong>, чтобы создать первый.</div>`;
return;
}
grid.innerHTML = classes.map(c => {
const grad = classGradient(c.id);
const initials = classInitials(c.name);
const isActive = currentClass?.id === c.id;
let avatar;
if (c.cover_emoji) {
const p = parseIconValue(c.cover_emoji);
avatar = `<div class="cl-avatar" style="background:${p.color}18;color:${p.color}"><i data-lucide="${p.icon}" style="width:20px;height:20px"></i></div>`;
} else {
avatar = `<div class="cl-avatar" style="background:${grad}">${initials}</div>`;
}
return `<div class="cl-item${isActive ? ' active' : ''}" onclick="openClass(${c.id})" id="cc-${c.id}">
${avatar}
<div class="cl-info">
<div class="cl-name">${esc(c.name)}</div>
<div class="cl-meta">${c.member_count} уч · ${c.assignment_count} зад</div>
</div>
<i data-lucide="chevron-right" class="cl-chevron" style="width:15px;height:15px"></i>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
async function openClass(id) {
document.querySelectorAll('.cl-item').forEach(c => c.classList.remove('active'));
document.getElementById('cc-' + id)?.classList.add('active');
document.getElementById('cl-placeholder').style.display = 'none';
document.getElementById('personal-panel').style.display = 'none';
const panel = document.getElementById('detail-panel');
panel.classList.remove('panel-visible');
panel.style.display = 'block';
void panel.offsetWidth; // reflow to restart animation
panel.classList.add('panel-visible');
// Reset to dashboard tab
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'dash'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === 'dtab-dash'));
document.getElementById('dash-content').innerHTML = '<div class="spinner"></div>';
document.getElementById('theory-content').innerHTML = '<div class="spinner"></div>';
_theoryLoaded = null;
try {
const d = await LS.getClassDetail(id);
currentClass = d;
document.getElementById('d-name').textContent = d.name;
document.getElementById('d-sub').innerHTML = esc(d.description || '') + ' · Код: <strong style="color:var(--violet);letter-spacing:0.05em;user-select:all">' + esc(d.invite_code) + '</strong> <button onclick="navigator.clipboard.writeText(\'' + esc(d.invite_code) + '\');LS.toast(\'Код скопирован\',\'success\')" style="background:none;border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:0.72rem;cursor:pointer;color:var(--text-2);margin-left:4px" title="Копировать код"><svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
renderMembers(d.members);
renderAssignments(d.assignments, d.members.length);
renderDashboard(d);
} catch (e) {
document.getElementById('dash-content').innerHTML = `<div class="error">${esc(e.message)}</div>`;
}
}
function renderMembers(members) {
const tbody = document.getElementById('d-members');
if (!members.length) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty">Нет учеников. Поделитесь кодом приглашения.</div></td></tr>';
return;
}
tbody.innerHTML = members.map(m => {
const pc = pctCls(m.avg_pct);
return `<tr>
<td><strong>${esc(m.name)}</strong></td>
<td style="color:var(--text-3)">${esc(m.email)}</td>
<td>${m.tests_count}</td>
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td>
</tr>`;
}).join('');
}
function renderAssignments(assignments, totalMembers) {
_classAssignments = assignments;
const el = document.getElementById('d-assignments');
if (!assignments.length) {
el.innerHTML = '<div class="empty">Заданий нет. Нажмите «+ Задание» чтобы создать.</div>';
return;
}
el.innerHTML = assignments.map(a => {
const isFile = !!a.file_id;
const meta = isFile
? `<i data-lucide="paperclip" style="width:12px;height:12px;vertical-align:-1px"></i> ${esc(a.file_title || 'Файл')} · ${SUBJECTS[a.subject_slug]||a.subject_slug} ${deadlineBadge(a.deadline)}`
: `${SUBJECTS[a.subject_slug]||a.subject_slug} · ${MODES[a.mode]||a.mode} · ${a.count} вопросов ${deadlineBadge(a.deadline)}`;
return `<div class="assign-item">
<div style="flex:1">
<div class="assign-title">${esc(a.title)}</div>
<div class="assign-meta">${meta}</div>
</div>
<div class="assign-progress">
<div class="assign-pct">${a.completed_count} / ${a.total_members}</div>
<div class="assign-sub">${isFile ? 'скачали' : 'выполнили'}</div>
</div>
<div style="display:flex;gap:8px">
${!isFile ? `<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="showResults(${a.id},'${esc(a.title)}')">Результаты</button>` : ''}
<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="openEditAssignment(${a.id})">Изменить</button>
<button class="btn-danger" onclick="removeAssignment(${a.id})">Удалить</button>
</div>
</div>`;
}).join('');
}
/* ══ Detail tabs ══ */
function switchDetailTab(btn) {
const name = btn.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('dtab-' + name).classList.add('active');
if (name === 'announce') loadAnnouncements();
if (name === 'dash') loadClassDashboard();
if (name === 'journal') loadJournal();
if (name === 'settings') loadSettings();
if (name === 'works') loadClassWorks();
if (name === 'theory') loadClassTheory();
}
/* ══ Invite ══ */
function copyCode(code) {
const url = `${location.origin}/dashboard?join=${code}`;
navigator.clipboard.writeText(url).then(() => toast('Ссылка скопирована!'));
}
function copyInvite() {
if (!currentClass) return;
copyCode(currentClass.invite_code);
}
/* ══ Create class ══ */
function openCreateClass() {
_selectedIcon = 'book-open';
_selectedColor = '#9B5DE5';
const body = `
<div class="ip-row">
<div class="ip-preview" id="c-icon-preview"><i data-lucide="book-open"></i></div>
<div style="flex:1">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Название класса</label>
<input class="form-input" id="c-name" placeholder="11А · Биология" />
</div>
</div>
</div>
<div class="form-group">
<div class="icon-picker-label">Иконка класса</div>
<div class="icon-picker" id="c-icon-picker"></div>
<div class="icon-picker-label" style="margin-top:10px">Цвет</div>
<div class="color-picker" id="c-color-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Описание (необязательно)</label>
<textarea class="form-textarea" id="c-desc" rows="2" placeholder="Подготовка к ЦТ 2026"></textarea>
</div>`;
_classModal = LS.modal({
title: 'Создать класс',
content: body,
size: 'md',
actions: [
{ label: 'Отмена', onClick: () => _classModal.close() },
{ label: 'Создать', primary: true, id: 'btn-save-class', onClick: saveClass },
],
});
renderIconPreview('c-icon-preview');
renderIconPicker('c-icon-picker', 'c-icon-preview');
renderColorPicker('c-color-picker', 'c-icon-preview');
}
let _classModal = null;
/* ══ Icon picker (Lucide SVG) ══════════════════════════════════════ */
const CLASS_ICONS = [
// Биология / Химия / Наука
'flask-conical','flask-round','microscope','atom','dna','test-tubes','pill','syringe',
'scan-eye','heart-pulse','activity','thermometer','biohazard','bug','leaf','trees',
'flower-2','sprout','apple','carrot','egg','fish','bird','cat','dog','rabbit','turtle','squirrel',
// Математика / Физика
'calculator','sigma','pi','percent','binary','triangle','hexagon','circle-dot',
'magnet','zap','lightbulb','sun','moon','cloud-lightning','orbit','satellite',
// География / История
'globe','map','compass','mountain','waves','landmark','castle','flag',
// Учёба / Школа
'book-open','book-marked','notebook-pen','notebook-text','library','scroll-text',
'graduation-cap','school','backpack','pencil','pen-tool','highlighter','clipboard-list',
'file-text','file-check','brain','sparkles','puzzle','blocks',
// IT / Технологии
'laptop','monitor','keyboard','cpu','code','terminal','database','wifi','bot','circuit-board',
// Искусство / Музыка
'palette','brush','paintbrush','camera','clapperboard','music','music-2','mic','headphones','drama',
// Спорт
'trophy','medal','target','flame','timer','bike','dumbbell','footprints',
// Общие / Абстрактные
'rocket','gem','crown','star','heart','shield','key','lock','award','badge-check',
'square','circle','triangle','diamond','hexagon','octagon','pentagon',
];
let _selectedIcon = 'book-open';
let _selectedColor = '#9B5DE5';
const ICON_COLORS = [
{ color: '#9B5DE5', label: 'Фиолетовый' },
{ color: '#3B82F6', label: 'Синий' },
{ color: '#06B6D4', label: 'Голубой' },
{ color: '#06D6A0', label: 'Зелёный' },
{ color: '#059652', label: 'Тёмно-зелёный' },
{ color: '#F59E0B', label: 'Жёлтый' },
{ color: '#F97316', label: 'Оранжевый' },
{ color: '#E83A1E', label: 'Красный' },
{ color: '#F15BB5', label: 'Розовый' },
{ color: '#8B5CF6', label: 'Индиго' },
{ color: '#64748B', label: 'Серый' },
{ color: '#0F172A', label: 'Чёрный' },
];
function parseIconValue(val) {
if (!val) return { icon: 'book-open', color: '#9B5DE5' };
const parts = val.split(':');
return { icon: parts[0] || 'book-open', color: parts[1] || '#9B5DE5' };
}
function encodeIconValue(icon, color) {
return color === '#9B5DE5' ? icon : icon + ':' + color;
}
function renderIconPreview(previewId) {
const el = document.getElementById(previewId);
if (!el) return;
el.style.color = _selectedColor;
el.style.background = _selectedColor + '18';
el.style.borderColor = _selectedColor + '30';
el.innerHTML = `<i data-lucide="${_selectedIcon}"></i>`;
if (window.lucide) lucide.createIcons({ nodes: [el] });
}
function renderIconPicker(containerId, previewId) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = CLASS_ICONS.map(ic =>
`<button type="button" class="ip-btn${ic === _selectedIcon ? ' selected' : ''}" data-icon="${ic}"><i data-lucide="${ic}"></i></button>`
).join('');
if (window.lucide) lucide.createIcons({ nodes: [el] });
el.onclick = (e) => {
const btn = e.target.closest('.ip-btn');
if (!btn) return;
_selectedIcon = btn.dataset.icon;
renderIconPreview(previewId);
el.querySelectorAll('.ip-btn').forEach(b => b.classList.toggle('selected', b.dataset.icon === _selectedIcon));
};
}
function renderColorPicker(containerId, previewId) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = ICON_COLORS.map(c =>
`<button type="button" class="cp-btn${c.color === _selectedColor ? ' selected' : ''}" data-color="${c.color}" title="${c.label}" style="background:${c.color}"></button>`
).join('');
el.onclick = (e) => {
const btn = e.target.closest('.cp-btn');
if (!btn) return;
_selectedColor = btn.dataset.color;
renderIconPreview(previewId);
el.querySelectorAll('.cp-btn').forEach(b => b.classList.toggle('selected', b.dataset.color === _selectedColor));
};
}
async function saveClass() {
const name = document.getElementById('c-name').value.trim();
const description = document.getElementById('c-desc').value.trim();
if (!name) return LS.toast('Введите название', 'warn');
const btn = document.getElementById('btn-save-class');
btn.disabled = true;
try {
const c = await LS.createClass({ name, description, cover_emoji: encodeIconValue(_selectedIcon, _selectedColor) });
_classModal?.close();
toast('Класс создан! Код: ' + c.invite_code);
await loadClasses();
openClass(c.id);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
/* ══ Delete class ══ */
async function confirmDeleteClass() {
if (!currentClass) return;
if (!await LS.confirm(`Удалить класс «${currentClass.name}»?\nВсе данные будут удалены.`, { title: 'Удалить класс', confirmText: 'Удалить' })) return;
try {
await LS.deleteClass(currentClass.id);
currentClass = null;
document.getElementById('detail-panel').style.display = 'none';
document.getElementById('cl-placeholder').style.display = '';
toast('Класс удалён');
await loadClasses();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Student search ══ */
let allStudents = [], selectedStudent = null;
async function loadStudents() {
if (allStudents.length) return;
try { allStudents = await LS.getStudentsList(); } catch {}
}
function filterStudents(q) {
const drop = document.getElementById('student-drop');
const lq = q.toLowerCase();
const matches = q.length < 1
? allStudents.slice(0, 60)
: allStudents.filter(s => s.name.toLowerCase().includes(lq) || s.email.toLowerCase().includes(lq)).slice(0, 40);
if (!matches.length) {
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
} else {
drop.innerHTML = matches.map(s => `
<div class="student-opt" onmousedown="selectStudent(${s.id},'${esc(s.name)}','${esc(s.email)}')">
<div class="student-opt-name">${esc(s.name)}</div>
<div class="student-opt-email">${esc(s.email)}</div>
</div>`).join('');
}
drop.classList.add('open');
if (selectedStudent) { selectedStudent = null; document.getElementById('btn-add-member').disabled = true; }
}
function selectStudent(id, name, email) {
selectedStudent = { id, name, email };
document.getElementById('add-member-search').value = `${name} (${email})`;
document.getElementById('student-drop').classList.remove('open');
document.getElementById('btn-add-member').disabled = false;
}
async function openStudentDrop() {
await loadStudents();
filterStudents(document.getElementById('add-member-search').value);
}
function closeStudentDrop() { document.getElementById('student-drop').classList.remove('open'); }
/* ══ Add member ══ */
async function doAddMember() {
if (!currentClass || !selectedStudent) return;
const errEl = document.getElementById('add-member-err');
errEl.textContent = '';
try {
const r = await LS.addClassMember(currentClass.id, null, selectedStudent.id);
document.getElementById('add-member-search').value = '';
selectedStudent = null;
document.getElementById('btn-add-member').disabled = true;
toast(`${r.name} добавлен в класс`);
await openClass(currentClass.id);
} catch (e) { errEl.textContent = e.message; }
}
/* ══ Kick member ══ */
async function kickMember(uid, name) {
if (!await LS.confirm(`Удалить ${name} из класса?`, { title: 'Удалить ученика', confirmText: 'Удалить' })) return;
try {
await LS.kickMember(currentClass.id, uid);
await openClass(currentClass.id);
toast('Ученик удалён');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Assignment type ══ */
let _assignType = 'random';
let _allFiles = null, _selectedFileId = null;
let _allTests = null, _selectedTestId = null;
const TYPE_HINTS = {
random: 'Вопросы подбираются случайно из базы по выбранному предмету',
fixed_test: 'Все ученики получат одинаковый тест из библиотеки в фиксированном порядке',
file: 'Ученикам будет доступна ссылка для скачивания файла из библиотеки',
upload: 'Ученик должен загрузить файл (реферат, лабораторную и т.д.)',
};
function setAssignType(type) {
_assignType = type;
document.getElementById('atype-random-btn') .classList.toggle('active', type === 'random');
document.getElementById('atype-fixtest-btn').classList.toggle('active', type === 'fixed_test');
document.getElementById('atype-file-btn') .classList.toggle('active', type === 'file');
document.getElementById('atype-upload-btn') .classList.toggle('active', type === 'upload');
document.getElementById('a-test-fields') .style.display = type === 'random' ? '' : 'none';
document.getElementById('a-fixtest-fields') .style.display = type === 'fixed_test' ? '' : 'none';
document.getElementById('a-file-fields') .style.display = type === 'file' ? '' : 'none';
document.getElementById('a-type-hint').textContent = TYPE_HINTS[type] || '';
if (type === 'file' && !_allFiles) loadFilePicker();
if (type === 'fixed_test' && !_allTests) loadTestPicker();
// Upload type: auto-check homework, hide test/file fields
if (type === 'upload') {
document.getElementById('a-is-hw').checked = true;
onHwChange();
}
}
function onHwChange() {
const isHw = document.getElementById('a-is-hw').checked;
document.getElementById('a-mode-group').style.display = isHw ? 'none' : '';
document.getElementById('a-hw-hint').style.display = isHw ? '' : 'none';
}
async function loadFilePicker() {
try {
_allFiles = await LS.getFiles();
renderFileList('');
} catch { _allFiles = []; }
}
async function loadTestPicker() {
try {
_allTests = await LS.getTests();
renderTestList('');
} catch { _allTests = []; }
}
function renderTestList(q) {
const el = document.getElementById('a-test-list');
if (!_allTests) { el.innerHTML = '<div class="student-opt-empty">Загрузка…</div>'; return; }
const lq = q.toLowerCase();
const items = q ? _allTests.filter(t => (t.title||'').toLowerCase().includes(lq)) : _allTests;
if (!items.length) { el.innerHTML = '<div class="student-opt-empty">Тестов нет</div>'; return; }
el.innerHTML = items.map(t => `
<div class="file-pick-item${_selectedTestId === t.id ? ' selected' : ''}" onclick="selectTest(${t.id},'${esc(t.title||'Тест')}')">
<div style="flex:1">
<div class="file-pick-name">${esc(t.title||'Тест')}</div>
<div class="file-pick-subj">${SUBJECTS[t.subject_slug]||t.subject_slug||''} · ${t.question_count||'?'} вопросов</div>
</div>
${_selectedTestId === t.id ? '<div class="file-pick-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></div>' : ''}
</div>`).join('');
}
function filterTestList(q) { renderTestList(q); }
function selectTest(id, title) {
_selectedTestId = id;
renderTestList(document.getElementById('a-test-search').value);
const sel = document.getElementById('a-test-selected');
sel.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выбран: ' + title;
sel.style.display = '';
}
function renderFileList(q) {
const el = document.getElementById('a-file-list');
if (!_allFiles) { el.innerHTML = '<div class="student-opt-empty">Загрузка…</div>'; return; }
const lq = q.toLowerCase();
const items = q ? _allFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _allFiles;
if (!items.length) { el.innerHTML = '<div class="student-opt-empty">Нет файлов</div>'; return; }
el.innerHTML = items.map(f => `
<div class="file-pick-item${_selectedFileId === f.id ? ' selected' : ''}" onclick="selectFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')">
<div style="flex:1">
<div class="file-pick-name">${esc(f.title||'Файл')}</div>
<div class="file-pick-subj">${SUBJECTS[f.subject_slug]||f.subject_slug||''}</div>
</div>
${_selectedFileId === f.id ? '<div class="file-pick-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></div>' : ''}
</div>`).join('');
}
function filterFileList(q) { renderFileList(q); }
function selectFile(id, title, subject_slug) {
_selectedFileId = id;
renderFileList(document.getElementById('a-file-search').value);
const sel = document.getElementById('a-file-selected');
sel.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выбран: ' + title;
sel.style.display = '';
}
/* ══ Target selector ══ */
function buildTargetSelect(members) {
const sel = document.getElementById('a-target');
sel.innerHTML =
`<option value="class">Всему классу</option>` +
`<option value="multi">Нескольким классам…</option>` +
members.map(m => `<option value="${m.id}">${esc(m.name)} (лично)</option>`).join('');
}
function onTargetChange() {
const val = document.getElementById('a-target').value;
const isHw = document.getElementById('a-is-hw');
if (val !== 'class' && val !== 'multi') { isHw.checked = true; }
// Show/hide multi-class selector
const multiDiv = document.getElementById('a-multi-classes');
if (val === 'multi') {
multiDiv.style.display = '';
buildMultiClassList();
} else {
multiDiv.style.display = 'none';
}
}
function buildMultiClassList() {
const el = document.getElementById('a-multi-class-list');
const others = (classes || []).filter(c => c.id !== currentClass?.id);
// Always include current class (pre-checked)
const all = currentClass ? [currentClass, ...others] : others;
el.innerHTML = all.map(c => `
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-2);padding:2px 0">
<input type="checkbox" value="${c.id}"${c.id === currentClass?.id ? ' checked' : ''} style="width:15px;height:15px;accent-color:var(--violet)">
${esc(c.name)}
</label>`).join('');
}
/* ══ Templates ══ */
let _templates = [];
async function refreshTemplates() {
try {
_templates = await LS.listTemplates();
const sel = document.getElementById('tpl-select');
sel.innerHTML = '<option value="">— Загрузить шаблон —</option>' +
_templates.map(t => `<option value="${t.id}">${esc(t.label)}</option>`).join('');
} catch { _templates = []; }
}
function loadTemplateIntoForm() {
const id = Number(document.getElementById('tpl-select').value);
if (!id) return LS.toast('Выберите шаблон', 'warn');
const tpl = _templates.find(t => t.id === id);
if (!tpl) return;
if (tpl.file_id) {
setAssignType('file');
_selectedFileId = tpl.file_id;
document.getElementById('a-file-selected').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Файл из шаблона';
document.getElementById('a-file-selected').style.display = '';
} else if (tpl.test_id) {
setAssignType('fixed_test');
_selectedTestId = tpl.test_id;
document.getElementById('a-test-selected').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Тест из шаблона';
document.getElementById('a-test-selected').style.display = '';
} else {
setAssignType('random');
const subj = document.getElementById('a-subject');
if (subj) subj.value = tpl.subject_slug || 'bio';
const mode = document.getElementById('a-mode');
if (mode) mode.value = tpl.mode || 'exam';
const count = document.getElementById('a-count');
if (count) count.value = tpl.count || 25;
}
document.getElementById('a-is-hw').checked = !!tpl.is_homework;
onHwChange();
LS.toast('Шаблон загружен', 'success');
}
async function promptSaveTemplate() {
const label = window.prompt('Название шаблона:',
document.getElementById('a-title').value.trim() || 'Мой шаблон');
if (!label) return;
const isHw = document.getElementById('a-is-hw').checked;
let payload = { label, is_homework: isHw ? 1 : 0 };
if (_assignType === 'file') {
if (!_selectedFileId) return LS.toast('Выберите файл перед сохранением', 'warn');
const f = (_allFiles||[]).find(x => x.id === _selectedFileId);
payload = { ...payload, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1, file_id: _selectedFileId };
} else if (_assignType === 'fixed_test') {
if (!_selectedTestId) return LS.toast('Выберите тест перед сохранением', 'warn');
const t = (_allTests||[]).find(x => x.id === _selectedTestId);
payload = { ...payload, subject_slug: t?.subject_slug || 'bio', mode: 'exam', count: t?.question_count || 25, test_id: _selectedTestId };
} else {
payload = {
...payload,
subject_slug: document.getElementById('a-subject').value,
mode: isHw ? 'exam' : document.getElementById('a-mode').value,
count: Number(document.getElementById('a-count').value),
};
}
try {
await LS.saveTemplate(payload);
await refreshTemplates();
LS.toast('Шаблон сохранён', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Create assignment ══ */
function openCreateAssignment() {
if (!currentClass) return;
document.getElementById('a-title').value = '';
document.getElementById('a-deadline').value = '';
document.getElementById('a-is-hw').checked = false;
const ma = document.getElementById('a-max-attempts');
if (ma) ma.value = '0';
document.getElementById('a-mode-group').style.display = '';
document.getElementById('a-multi-classes').style.display = 'none';
_selectedFileId = null;
document.getElementById('a-file-selected').style.display = 'none';
document.getElementById('a-file-search').value = '';
_selectedTestId = null;
document.getElementById('a-test-selected').style.display = 'none';
document.getElementById('a-test-search').value = '';
setAssignType('random');
buildTargetSelect(currentClass.members || []);
refreshTemplates();
openModal('modal-assign');
}
async function saveAssignment() {
if (!currentClass) return;
const title = document.getElementById('a-title').value.trim();
const deadline = document.getElementById('a-deadline').value || null;
const isHw = document.getElementById('a-is-hw').checked;
const target = document.getElementById('a-target').value;
const maxAttempts = Number(document.getElementById('a-max-attempts')?.value || 0);
if (!title) return LS.toast('Введите название задания', 'warn');
let payload;
if (_assignType === 'upload') {
payload = { title, mode: 'exam', count: 1, deadline, is_homework: 1, max_attempts: 0 };
} else if (_assignType === 'file') {
if (!_selectedFileId) return LS.toast('Выберите файл из библиотеки', 'warn');
const f = _allFiles.find(x => x.id === _selectedFileId);
payload = { title, file_id: _selectedFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1, deadline, is_homework: isHw ? 1 : 0, max_attempts: maxAttempts };
} else if (_assignType === 'fixed_test') {
if (!_selectedTestId) return LS.toast('Выберите тест из списка', 'warn');
const t = _allTests.find(x => x.id === _selectedTestId);
payload = { title, test_id: _selectedTestId, subject_slug: t?.subject_slug || 'bio', mode: 'exam', count: t?.question_count || 25, deadline, is_homework: isHw ? 1 : 0, max_attempts: maxAttempts };
} else {
payload = {
title,
subject_slug: document.getElementById('a-subject').value,
mode: isHw ? 'exam' : document.getElementById('a-mode').value,
count: Number(document.getElementById('a-count').value),
deadline,
is_homework: isHw ? 1 : 0,
max_attempts: maxAttempts,
};
}
const btn = document.getElementById('btn-save-assign');
btn.disabled = true;
try {
if (target === 'multi') {
const checked = [...document.querySelectorAll('#a-multi-class-list input[type=checkbox]:checked')];
const class_ids = checked.map(cb => Number(cb.value));
if (!class_ids.length) { LS.toast('Выберите хотя бы один класс', 'warn'); return; }
const result = await LS.bulkCreateAssignment({ ...payload, class_ids });
closeModal('modal-assign');
LS.toast(`Задание создано для ${result.count} класс(ов)`, 'success');
await openClass(currentClass.id);
document.querySelector('[data-tab="assign"]').click();
} else if (target === 'class') {
await LS.createAssignment(currentClass.id, payload);
closeModal('modal-assign');
toast(isHw ? 'ДЗ выдано' : 'Задание создано');
await openClass(currentClass.id);
document.querySelector('[data-tab="assign"]').click();
} else {
// Конкретному ученику
await LS.createDirectAssignment({ ...payload, student_id: Number(target) });
closeModal('modal-assign');
toast(isHw ? 'ДЗ выдано' : 'Задание выдано');
await openClass(currentClass.id);
document.querySelector('[data-tab="assign"]').click();
}
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
/* ══ Delete assignment ══ */
async function removeAssignment(id) {
if (!await LS.confirm('Удалить задание?', { title: 'Удалить задание', confirmText: 'Удалить' })) return;
try {
await LS.deleteAssignment(id);
toast('Задание удалено');
await openClass(currentClass.id);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Edit assignment ══ */
let _editingAssign = null;
let _editModal = null;
function openEditAssignment(id) {
_editingAssign = _classAssignments.find(x => x.id === id);
if (!_editingAssign) return;
const dl = _editingAssign.deadline;
const dlVal = dl ? dl.replace(' ', 'T').slice(0, 16) : '';
const body = `
<div class="form-group">
<label class="form-label">Название</label>
<input class="form-input" id="ea-title" value="${LS.esc(_editingAssign.title)}" />
</div>
<div class="form-group">
<label class="form-label">Дедлайн</label>
<input class="form-input" id="ea-deadline" type="datetime-local" value="${dlVal}" />
</div>`;
_editModal = LS.modal({
title: 'Редактировать задание',
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _editModal.close() },
{ label: 'Сохранить', primary: true, id: 'btn-save-edit-assign', onClick: saveEditAssignment },
],
});
}
async function saveEditAssignment() {
if (!_editingAssign) return;
const title = document.getElementById('ea-title').value.trim();
if (!title) return LS.toast('Введите название', 'warn');
const deadline = document.getElementById('ea-deadline').value || null;
const btn = document.getElementById('btn-save-edit-assign');
btn.disabled = true;
try {
await LS.updateAssignment(_editingAssign.id, {
title,
deadline,
subject_slug: _editingAssign.subject_slug || 'bio',
mode: _editingAssign.mode || 'exam',
count: _editingAssign.count || 25,
test_id: _editingAssign.test_id || null,
});
_editModal?.close();
toast('Изменения сохранены');
await openClass(currentClass.id);
document.querySelector('[data-tab="assign"]').click();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
/* ══ Results overlay ══════════════════════════════════════════════════ */
let _currentResultsAssignId = null;
let _currentResultsData = null; // cache for CSV
let _qStatsLoaded = false;
function switchResTab(btn) {
document.querySelectorAll('.res-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.restab;
document.getElementById('restab-students').style.display = tab === 'students' ? '' : 'none';
document.getElementById('restab-questions').style.display = tab === 'questions' ? '' : 'none';
document.getElementById('res-csv-btn').style.display = tab === 'students' ? '' : 'none';
if (tab === 'questions') loadResQuestionStats();
}
async function showResults(id, title) {
_currentResultsAssignId = id;
_currentResultsData = null;
_qStatsLoaded = false;
document.getElementById('res-title').textContent = title;
document.getElementById('res-list').innerHTML = '<div class="spinner"></div>';
document.getElementById('res-drill').classList.remove('open');
document.getElementById('restab-students').style.display = '';
document.getElementById('restab-questions').style.display = 'none';
document.getElementById('res-tabs').style.display = 'none';
document.getElementById('res-stats').style.display = 'none';
document.getElementById('res-distrib').style.display = 'none';
document.getElementById('res-prog-wrap').style.display = 'none';
document.getElementById('res-csv-btn').style.display = 'none';
// reset tabs
document.querySelectorAll('.res-tab-btn').forEach((b,i) => b.classList.toggle('active', i===0));
openModal('results-overlay');
try {
const { results } = await LS.assignmentResults(id);
_currentResultsData = { results, title };
const done = results.filter(r => r.session_status === 'completed');
const pending = results.filter(r => r.session_status !== 'completed');
if (done.length > 0) {
// Stats chips
const avg = Math.round(done.reduce((s,r) => s + (r.percent||0), 0) / done.length);
const best = Math.max(...done.map(r => r.percent||0));
document.getElementById('res-done-n').textContent = done.length;
document.getElementById('res-total-n').textContent = results.length;
document.getElementById('res-avg-n').textContent = avg + '%';
document.getElementById('res-best-n').textContent = best + '%';
document.getElementById('res-stats').style.display = '';
// Completion bar
document.getElementById('res-prog-bar').style.width = Math.round(done.length / results.length * 100) + '%';
document.getElementById('res-prog-wrap').style.display = '';
// Score distribution
const buckets = [
{ label: '025%', min: 0, max: 25, color: '#F15BB5' },
{ label: '2650%', min: 26, max: 50, color: '#FFB347' },
{ label: '5175%', min: 51, max: 75, color: '#06B6D4' },
{ label: '76100%',min: 76, max: 100, color: '#06D664' },
];
const maxCnt = Math.max(...buckets.map(b => done.filter(r => r.percent >= b.min && r.percent <= b.max).length), 1);
document.getElementById('res-distrib-bars').innerHTML = buckets.map(b => {
const cnt = done.filter(r => r.percent >= b.min && r.percent <= b.max).length;
const h = Math.round((cnt / maxCnt) * 56);
return `<div class="res-bar-col">
<div class="res-bar-cnt">${cnt || ''}</div>
<div class="res-bar-fill" style="height:${h}px;background:${b.color};opacity:0.85"></div>
<div class="res-bar-lbl">${b.label}</div>
</div>`;
}).join('');
document.getElementById('res-distrib').style.display = '';
document.getElementById('res-csv-btn').style.display = '';
document.getElementById('res-tabs').style.display = '';
}
// Student list
const rank = (r, i) => {
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
const pc = pctCls(r.percent);
const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${esc(r.name)}',${r.percent??'null'})"` : '';
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
<div class="res-rank">${medal}</div>
<div class="res-name">
<strong>${esc(r.name)}</strong>
<div style="font-size:0.75rem;color:var(--text-3)">${esc(r.email)}</div>
</div>
<div style="text-align:right">
<div class="res-pct ${pc}">${r.percent!==null?r.percent+'%':'—'}</div>
<div class="res-score">${r.score??'—'} / ${r.total??'—'}</div>
</div>
${r.session_id ? `<div style="color:var(--text-3);opacity:.4"><i data-lucide="chevron-right" style="width:15px;height:15px"></i></div>` : ''}
</div>`;
};
document.getElementById('res-list').innerHTML =
(done.length ? `<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:8px;padding:0 2px">Нажмите на ученика — увидите его ответы</div>` + done.map(rank).join('') : '') +
(pending.length ? `<div style="margin-top:16px;font-family:'Unbounded',sans-serif;font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">Не выполнили</div>` +
pending.map(r => `<div class="res-row"><div class="res-rank" style="color:var(--text-3)">—</div><div class="res-name"><strong>${esc(r.name)}</strong><div style="font-size:0.75rem;color:var(--text-3)">${esc(r.email)}</div></div><div class="res-pending">Не сдано</div></div>`).join('') : '');
if (!results.length) document.getElementById('res-list').innerHTML = '<div class="empty">Нет данных</div>';
if (window.lucide) lucide.createIcons();
} catch (e) {
document.getElementById('res-list').innerHTML = `<div class="error">${esc(e.message)}</div>`;
}
}
/* ── Question stats tab ── */
async function loadResQuestionStats() {
if (_qStatsLoaded) return;
const el = document.getElementById('res-q-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const { stats, session_count } = await LS.assignmentQuestionStats(_currentResultsAssignId);
_qStatsLoaded = true;
if (!stats.length) { el.innerHTML = '<div class="empty">Нет данных — никто не сдал это задание</div>'; return; }
el.innerHTML = `<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:12px">На основе ${session_count} сданных работ. Сортировка: от самых сложных вопросов.</div>` +
stats.map((q, idx) => {
const pct = q.error_pct || 0;
const color = pct >= 70 ? '#F15BB5' : pct >= 40 ? '#FFB347' : '#06D664';
return `<div class="qs-item">
<div class="qs-num">${idx+1}</div>
<div class="qs-text">${esc(q.question_text)}</div>
<div class="qs-bar-wrap"><div class="qs-bar-fill" style="width:${pct}%;background:${color}"></div></div>
<div class="qs-pct" style="color:${color}">${pct}%</div>
<div class="qs-cnt">${q.wrong}/${q.total} ошибок</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
}
/* ── CSV export ── */
function exportResultsCSV() {
if (!_currentResultsData) return;
const { results, title } = _currentResultsData;
const rows = [['Имя', 'Email', 'Баллы', 'Всего', '%', 'Статус', 'Дата сдачи']];
results.forEach(r => {
const dt = r.finished_at ? new Date(r.finished_at.includes('T') ? r.finished_at : r.finished_at.replace(' ','T')+'Z').toLocaleString('ru') : '';
rows.push([r.name, r.email, r.score??'', r.total??'', r.percent!=null?r.percent+'%':'', r.session_status==='completed'?'Сдано':'Не сдано', dt]);
});
const csv = rows.map(row => row.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
const a = document.createElement('a');
a.href = 'data:text/csv;charset=utf-8,\uFEFF' + encodeURIComponent(csv);
a.download = `results_${title.replace(/[^а-яёa-z0-9]/gi,'_')}.csv`;
a.click();
}
/* ── Drill-down: individual student review ── */
async function openResDrill(session_id, name, pct) {
document.getElementById('restab-students').style.display = 'none';
document.getElementById('restab-questions').style.display = 'none';
document.getElementById('res-tabs').style.display = 'none';
document.getElementById('res-csv-btn').style.display = 'none';
document.getElementById('res-drill').classList.add('open');
document.getElementById('res-drill-name').textContent = name;
const pctEl = document.getElementById('res-drill-pct');
pctEl.textContent = pct !== null ? pct + '%' : '—';
pctEl.className = 'res-drill-pct ' + pctCls(pct);
const listEl = document.getElementById('res-drill-list');
listEl.innerHTML = '<div class="spinner"></div>';
document.querySelector('.results-box').scrollTop = 0;
try {
const data = await LS.assignmentSessionReview(_currentResultsAssignId, session_id);
const review = data.review || [];
if (!review.length) { listEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
listEl.innerHTML = review.map((q, idx) => {
const status = q.is_correct === null ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
const statusIcon = q.is_correct === null ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>' : q.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const statusColor = q.is_correct === null ? 'var(--text-3)' : q.is_correct ? 'var(--green)' : 'var(--pink)';
let answerHtml = '';
if (q.type === 'short_answer') {
answerHtml = `<div class="rq-opts">
<div class="rq-opt ${q.is_correct ? 'both' : 'chosen'}"><span class="rq-icon">${q.is_correct?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'}</span> Ответ: ${esc(q.answer_text||'—')}</div>
${!q.is_correct ? `<div class="rq-opt answer"><span class="rq-icon"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span> Правильно: ${esc(q.correct_text||'')}</div>` : ''}
</div>`;
} else if (q.type === 'matching') {
let pairs = {}; try { pairs = JSON.parse(q.answer_text||'{}'); } catch {}
answerHtml = '<div class="rq-opts">' + q.options.map(o => {
const given = pairs[String(o.id)], ok = given === o.match_pair;
return `<div class="rq-opt ${ok?'answer':given?'chosen':''}">
<span class="rq-icon">${ok?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':given?'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>
${esc(o.text)} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> <strong>${esc(given||'—')}</strong>${!ok&&o.match_pair?` <span style="color:var(--green)">(${esc(o.match_pair)})</span>`:''}
</div>`;
}).join('') + '</div>';
} else {
let chosen = q.type === 'multi'
? (()=>{try{return JSON.parse(q.answer_text||'[]');}catch{return [];}})()
: (q.chosen_option_id ? [q.chosen_option_id] : []);
answerHtml = '<div class="rq-opts">' + q.options.map(o => {
const ic = chosen.includes(o.id), ir = o.is_correct;
const cls = ic&&ir?'both':ic?'chosen':ir?'answer':'';
const icon = ic&&ir?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':ic?'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>':ir?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
return cls
? `<div class="rq-opt ${cls}"><span class="rq-icon">${icon}</span>${esc(o.text)}</div>`
: `<div class="rq-opt" style="color:var(--text-3)">${esc(o.text)}</div>`;
}).join('') + '</div>';
}
return `<div class="rq-item ${status}">
<div class="rq-num" style="color:${statusColor}">${statusIcon} Вопрос ${idx+1}</div>
<div class="rq-text">${esc(q.text)}</div>
${answerHtml}
${q.explanation?`<div class="rq-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`:''}
</div>`;
}).join('');
} catch (e) { listEl.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
}
function resDrillBack() {
document.getElementById('res-drill').classList.remove('open');
document.getElementById('restab-students').style.display = '';
document.getElementById('restab-questions').style.display = 'none';
const hasTabs = _currentResultsData?.results?.some(r => r.session_status === 'completed');
document.getElementById('res-tabs').style.display = hasTabs ? '' : 'none';
document.getElementById('res-csv-btn').style.display = hasTabs ? '' : 'none';
document.querySelector('.results-box').scrollTop = 0;
if (window.lucide) lucide.createIcons();
}
/* ══ Объявления ══════════════════════════════════════════════════════ */
async function loadAnnouncements() {
if (!currentClass) return;
const el = document.getElementById('announce-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const items = await LS.getAnnouncements(currentClass.id);
if (!items.length) { el.innerHTML = '<div class="empty">Объявлений нет</div>'; return; }
el.innerHTML = items.map(a => `
<div class="ann-item">
<div class="ann-body">
<div class="ann-author">${esc(a.author_name)}</div>
<div class="ann-text">${esc(a.text)}</div>
<div class="ann-date">${parseDate(a.created_at).toLocaleString('ru',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}</div>
</div>
<button class="ann-del" onclick="delAnnouncement(${a.id})" title="Удалить"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>`).join('');
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
}
async function sendAnnouncement() {
const text = document.getElementById('announce-text').value.trim();
if (!text) return;
const btn = document.getElementById('announce-send-btn');
btn.disabled = true;
try {
await LS.createAnnouncement(currentClass.id, text);
document.getElementById('announce-text').value = '';
await loadAnnouncements();
LS.toast('Объявление отправлено', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
async function delAnnouncement(id) {
if (!await LS.confirm('Объявление будет удалено для всех студентов.', { title: 'Удалить объявление?', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAnnouncement(currentClass.id, id);
await loadAnnouncements();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Работы учеников (submissions) ══════════════════════════════════ */
let _worksFilter = 'all', _worksData = [];
async function loadClassWorks() {
if (!currentClass) return;
const el = document.getElementById('works-content');
el.innerHTML = '<div class="spinner"></div>';
try {
_worksData = await LS.getClassSubmissions(currentClass.id);
renderWorks();
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
}
function renderWorks() {
const el = document.getElementById('works-content');
let list = _worksData;
if (_worksFilter === 'new') list = list.filter(w => w.status === 'new');
if (_worksFilter === 'reviewed') list = list.filter(w => w.status === 'reviewed');
const newCount = _worksData.filter(w => w.status === 'new').length;
const reviewedCount = _worksData.filter(w => w.status === 'reviewed').length;
if (!_worksData.length) {
el.innerHTML = '<div class="empty">Работ нет. Ученики ещё не прикрепляли файлы.</div>';
return;
}
el.innerHTML = `
<div class="works-filters">
<button class="works-chip${_worksFilter==='all'?' active':''}" onclick="setWorksFilter('all')">Все (${_worksData.length})</button>
<button class="works-chip${_worksFilter==='new'?' active':''}" onclick="setWorksFilter('new')">На проверке (${newCount})</button>
<button class="works-chip${_worksFilter==='reviewed'?' active':''}" onclick="setWorksFilter('reviewed')">Проверено (${reviewedCount})</button>
</div>
<div id="works-list">
${!list.length ? '<div class="empty">Нет работ в этой категории.</div>' : list.map(w => `
<div class="work-card ${w.status}" onclick="openReviewModal(${w.id})">
<div class="work-icon"><i data-lucide="file-text" style="width:18px;height:18px"></i></div>
<div class="work-body">
<div class="work-student">${esc(w.student_name)}</div>
${w.assignment_title ? `<div class="work-assign">${esc(w.assignment_title)}</div>` : ''}
<div class="work-file"><i data-lucide="paperclip" style="width:11px;height:11px;vertical-align:-1px;margin-right:3px"></i>${esc(w.original_name)}</div>
${w.message ? `<div class="work-msg">"${esc(w.message)}"</div>` : ''}
${w.teacher_note ? `<div class="work-note"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${esc(w.teacher_note)}</div>` : ''}
</div>
<div class="work-meta">
<div style="display:flex;align-items:center;gap:6px;justify-content:flex-end;margin-bottom:4px">
<div class="work-status ${w.status}" style="margin-bottom:0">${w.status === 'reviewed' ? LS.icon('check-circle',14)+' Проверено' : LS.icon('clock',14)+' На проверке'}</div>
${w.grade != null ? `<span class="work-grade ${w.grade>=80?'high':w.grade>=50?'mid':'low'}">${w.grade}</span>` : ''}
</div>
<div>${parseDate(w.submitted_at).toLocaleDateString('ru',{day:'numeric',month:'short'})}</div>
<button class="work-del-btn" onclick="event.stopPropagation();deleteClassWork(${w.id})" title="Удалить работу"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>
</div>
</div>`).join('')}
</div>`;
if (window.lucide) lucide.createIcons();
}
function setWorksFilter(f) {
_worksFilter = f;
renderWorks();
}
async function deleteClassWork(id) {
if (!await LS.confirm('Удалить работу ученика? Действие записывается в журнал.', { title: 'Удаление работы', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteSubmission(id);
_worksData = _worksData.filter(w => w.id !== id);
renderWorks();
LS.toast('Работа удалена', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
let _reviewSubId = null;
function gradeLetter(g) {
if (g == null) return '—';
if (g >= 90) return 'A';
if (g >= 75) return 'B';
if (g >= 60) return 'C';
if (g >= 40) return 'D';
return 'F';
}
function updateGradeLetter() {
const val = document.getElementById('rv-grade').value;
const g = val === '' ? null : Number(val);
const el = document.getElementById('rv-grade-letter');
const letter = gradeLetter(g);
el.textContent = letter;
el.style.background = g == null ? 'rgba(155,93,229,0.08)' :
g >= 80 ? 'rgba(5,150,82,0.12)' : g >= 50 ? 'rgba(255,193,7,0.14)' : 'rgba(241,91,181,0.12)';
el.style.color = g == null ? 'var(--violet)' :
g >= 80 ? '#059652' : g >= 50 ? '#c07c00' : '#c0306a';
}
function openReviewModal(subId) {
const w = _worksData.find(x => x.id === subId);
if (!w) return;
_reviewSubId = subId;
document.getElementById('rv-student').textContent = w.student_name + (w.student_email ? ` · ${w.student_email}` : '');
document.getElementById('rv-assign').textContent = w.assignment_title ? `Задание: ${w.assignment_title}` : '';
document.getElementById('rv-file-name').textContent = w.original_name;
document.getElementById('rv-file-link').href = LS.submissionDownloadUrl(subId);
const msgEl = document.getElementById('rv-message');
if (w.message) { msgEl.textContent = `"${w.message}"`; msgEl.style.display = ''; }
else msgEl.style.display = 'none';
document.getElementById('rv-note').value = w.teacher_note || '';
document.getElementById('rv-grade').value = w.grade != null ? w.grade : '';
updateGradeLetter();
const btn = document.getElementById('btn-mark-reviewed');
btn.textContent = w.status === 'reviewed' ? 'Обновить' : 'Отметить проверенным';
document.getElementById('review-modal').classList.add('open');
if (window.lucide) lucide.createIcons();
}
function closeReviewModal() {
document.getElementById('review-modal').classList.remove('open');
}
async function doMarkReviewed() {
if (!_reviewSubId) return;
const note = document.getElementById('rv-note').value.trim();
const gradeRaw = document.getElementById('rv-grade').value;
const grade = gradeRaw === '' ? null : Number(gradeRaw);
const btn = document.getElementById('btn-mark-reviewed');
btn.disabled = true;
try {
await LS.reviewSubmission(_reviewSubId, { status: 'reviewed', teacher_note: note || null, grade });
const w = _worksData.find(x => x.id === _reviewSubId);
if (w) { w.status = 'reviewed'; w.teacher_note = note || null; w.grade = grade; }
closeReviewModal();
LS.toast('Работа проверена' + (grade != null ? ` · ${grade}/100 (${gradeLetter(grade)})` : ''), 'success');
renderWorks();
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
/* ══ Уведомления — handled by notifications.js ══ */
/* ══ Teacher Dashboard ══════════════════════════════════════════════ */
function renderDashboard(d) {
const { members, assignments } = d;
const totalMembers = members.length;
const totalAssigns = assignments.length;
const avgPct = members.length
? Math.round(members.filter(m => m.avg_pct !== null).reduce((s, m) => s + (m.avg_pct || 0), 0) / (members.filter(m => m.avg_pct !== null).length || 1))
: null;
// Find lagging students: those with 0 completions across all assignments
const lagging = members.filter(m => m.tests_count === 0);
let html = `<div class="dash-stat-row">
<div class="dash-stat-card"><div class="dash-stat-val">${totalMembers}</div><div class="dash-stat-lbl">Учеников</div></div>
<div class="dash-stat-card"><div class="dash-stat-val">${totalAssigns}</div><div class="dash-stat-lbl">Заданий</div></div>
<div class="dash-stat-card"><div class="dash-stat-val">${avgPct !== null ? avgPct + '%' : '—'}</div><div class="dash-stat-lbl">Средний балл</div></div>
</div>`;
if (assignments.length) {
html += `<div style="font-family:'Unbounded',sans-serif;font-size:0.75rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px">Выполнение заданий</div>
<div style="border:1.5px solid var(--border);border-radius:14px;overflow:hidden;margin-bottom:20px">`;
assignments.forEach(a => {
const pct = totalMembers ? Math.round(a.completed_count / totalMembers * 100) : 0;
html += `<div class="dash-assign-row" style="padding:10px 16px">
<div style="flex:1;min-width:0">
<div style="font-size:0.85rem;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</div>
<div style="font-size:0.72rem;color:var(--text-3);margin-top:2px">${a.completed_count} из ${totalMembers} сдали</div>
</div>
<div class="dash-assign-bar-wrap" style="max-width:120px">
<div class="dash-assign-bar" style="width:${pct}%"></div>
</div>
<div style="min-width:36px;text-align:right;font-family:'Unbounded',sans-serif;font-size:0.78rem;font-weight:800;color:${pct>=75?'var(--green)':pct>=40?'var(--amber)':'var(--pink)'}">${pct}%</div>
</div>`;
});
html += '</div>';
}
if (lagging.length) {
html += `<div style="font-family:'Unbounded',sans-serif;font-size:0.75rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px">Не приступали к тестам</div>
<div style="display:flex;flex-wrap:wrap;gap:8px">`;
lagging.forEach(m => {
html += `<span class="dash-lagging">${esc(m.name)}</span>`;
});
html += '</div>';
} else if (totalMembers > 0) {
html += `<div style="color:var(--green);font-size:0.84rem;font-weight:600"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Все ученики уже проходили тесты</div>`;
}
if (!totalMembers) {
html = `<div class="empty">В классе нет учеников. Поделитесь кодом приглашения.</div>`;
}
document.getElementById('dash-content').innerHTML = html;
}
async function loadClassDashboard() {
if (!currentClass) return;
renderDashboard(currentClass);
}
/* ══ Journal ════════════════════════════════════════════════════════ */
async function loadJournal() {
if (!currentClass) return;
const el = document.getElementById('journal-content');
el.innerHTML = '<div class="spinner"></div>';
try {
const { members, assignments, results } = await LS.classJournal(currentClass.id);
if (!assignments.length || !members.length) {
el.innerHTML = '<div class="empty">Нет данных для журнала. Добавьте учеников и задания.</div>';
return;
}
// Build lookup map: [userId][assignmentId] = percent
const map = {};
results.forEach(r => {
if (!map[r.user_id]) map[r.user_id] = {};
map[r.user_id][r.assignment_id] = r.percent;
});
const avgRow = {};
assignments.forEach(a => {
const vals = members.map(m => map[m.id]?.[a.id]).filter(v => v != null);
avgRow[a.id] = vals.length ? Math.round(vals.reduce((s,v)=>s+v,0)/vals.length) : null;
});
let th = '<th>Ученик</th>';
assignments.forEach(a => { th += `<th title="${esc(a.title)}">${esc(a.title.length > 16 ? a.title.slice(0,14)+'…' : a.title)}</th>`; });
th += '<th>Средний</th>';
const rows = members.map(m => {
let cells = `<td>${esc(m.name)}</td>`;
let sum = 0, cnt = 0;
assignments.forEach(a => {
const v = map[m.id]?.[a.id];
if (v != null) { sum += v; cnt++; }
cells += `<td>${v != null ? `<span class="${v>=75?'j-cell-hi':v>=50?'j-cell-mid':'j-cell-lo'}">${v}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
});
const avg = cnt ? Math.round(sum/cnt) : null;
cells += `<td>${avg != null ? `<span class="${avg>=75?'j-cell-hi':avg>=50?'j-cell-mid':'j-cell-lo'}">${avg}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
return `<tr>${cells}</tr>`;
}).join('');
const avgCells = assignments.map(a => {
const v = avgRow[a.id];
return `<td>${v != null ? `<span class="${v>=75?'j-cell-hi':v>=50?'j-cell-mid':'j-cell-lo'}">${v}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
}).join('');
el.innerHTML = `<div class="journal-wrap">
<table class="journal-table">
<thead><tr>${th}</tr></thead>
<tbody>${rows}
<tr style="font-weight:700;background:rgba(238,242,255,0.5)">
<td style="font-size:0.75rem;color:var(--text-3);font-family:'Unbounded',sans-serif;font-weight:700">Средний</td>${avgCells}<td></td>
</tr>
</tbody>
</table>
</div>`;
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
}
/* ══ Settings ═══════════════════════════════════════════════════════ */
function loadSettings() {
if (!currentClass) return;
document.getElementById('set-name').value = currentClass.name || '';
document.getElementById('set-desc').value = currentClass.description || '';
const parsed = parseIconValue(currentClass.cover_emoji);
_selectedIcon = parsed.icon;
_selectedColor = parsed.color;
renderIconPreview('set-icon-preview');
renderIconPicker('set-icon-picker', 'set-icon-preview');
renderColorPicker('set-color-picker', 'set-icon-preview');
document.getElementById('set-invite-code').textContent = currentClass.invite_code || '—';
// Populate module toggles (all enabled by default if no features set)
const f = currentClass.features || {};
const FEATS = ['gamification', 'collection', 'hangman', 'crossword', 'red_book', 'pet'];
for (const key of FEATS) {
const el = document.getElementById('feat-' + key);
if (el) el.checked = f[key] !== false; // default enabled
}
}
async function saveModules() {
if (!currentClass) return;
const FEATS = ['gamification', 'collection', 'hangman', 'crossword', 'red_book', 'pet'];
const features = {};
for (const key of FEATS) {
const el = document.getElementById('feat-' + key);
if (el) features[key] = el.checked;
}
try {
const updated = await LS.updateClass(currentClass.id, { features });
currentClass = { ...currentClass, ...updated };
LS.clearFeaturesCache?.(); // force re-check on next navigation
LS.toast('Модули сохранены', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function saveClassSettings() {
if (!currentClass) return;
const name = document.getElementById('set-name').value.trim();
const description = document.getElementById('set-desc').value.trim();
if (!name) return LS.toast('Введите название', 'warn');
try {
const updated = await LS.updateClass(currentClass.id, { name, description, cover_emoji: encodeIconValue(_selectedIcon, _selectedColor) });
currentClass = { ...currentClass, ...updated };
document.getElementById('d-name').textContent = updated.name;
document.getElementById('d-sub').textContent = (updated.description || '') + ' · Код: ' + updated.invite_code;
// Update in classes list
const idx = classes.findIndex(c => c.id === currentClass.id);
if (idx >= 0) classes[idx] = { ...classes[idx], ...updated };
renderClasses();
LS.toast('Настройки сохранены', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function doRegenerateCode() {
if (!currentClass) return;
if (!await LS.confirm('Старый код приглашения перестанет работать. Продолжить?', { title: 'Обновить код', confirmText: 'Обновить' })) return;
try {
const { invite_code } = await LS.regenerateInviteCode(currentClass.id);
currentClass.invite_code = invite_code;
document.getElementById('set-invite-code').textContent = invite_code;
document.getElementById('d-sub').textContent = (currentClass.description || '') + ' · Код: ' + invite_code;
LS.toast('Код обновлён: ' + invite_code, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ══ Theory tab ══════════════════════════════════════════════════════ */
let _theoryLoaded = false;
async function loadClassTheory() {
if (!currentClass) return;
if (_theoryLoaded === currentClass.id) return;
_theoryLoaded = currentClass.id;
const el = document.getElementById('theory-content');
el.innerHTML = '<div class="spinner"></div>';
try {
const courses = await LS.api('/api/courses/class/' + currentClass.id);
const allCourses = await LS.api('/api/courses');
const BANNER_CLASS = { bio:'cc-banner-bio', chem:'cc-banner-chem', math:'cc-banner-math', phys:'cc-banner-phys' };
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const assignedIds = new Set((courses || []).map(c => c.id));
const available = (allCourses || []).filter(c => !assignedIds.has(c.id));
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div style="font-family:'Unbounded',sans-serif;font-size:0.82rem;font-weight:800;color:#0F172A">
Курсы класса <span style="color:var(--text-3);font-weight:600">(${(courses||[]).length})</span>
</div>
${available.length ? `<button class="btn-ghost" onclick="openAssignCourseModal()" style="font-size:0.8rem">
<i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Добавить курс
</button>` : ''}
</div>`;
if (!(courses||[]).length) {
html += `<div style="text-align:center;padding:40px;color:var(--text-3);font-size:0.85rem;background:#fff;border-radius:16px;border:1.5px dashed rgba(155,93,229,0.15)">
Нет назначенных курсов. Нажмите «Добавить курс», чтобы назначить курс классу.
</div>`;
} else {
html += `<div style="display:flex;flex-direction:column;gap:10px">` +
(courses || []).map(c => {
const bannerClass = BANNER_CLASS[c.subjectSlug] || 'cc-banner-other';
const pct = c.lessonCount > 0 ? Math.round(c.doneCount / c.lessonCount * 100) : 0;
return `<div style="background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:16px;padding:14px 18px;display:flex;align-items:center;gap:14px">
<a href="/course?id=${c.id}" style="flex:1;display:flex;align-items:center;gap:12px;text-decoration:none;color:inherit;min-width:0">
<span style="font-size:1.6rem">${c.coverEmoji || LS.icon('book-open',20)}</span>
<div style="flex:1;min-width:0">
<div style="font-size:0.9rem;font-weight:700;color:#0F172A;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.title)}</div>
<div style="font-size:0.74rem;color:var(--text-3);margin-top:2px">${esc(SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '')} · ${c.lessonCount} уроков</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<div style="width:80px;height:5px;border-radius:99px;background:rgba(15,23,42,0.07)">
<div style="height:100%;border-radius:99px;background:var(--violet);width:${pct}%"></div>
</div>
<span style="font-size:0.72rem;font-weight:700;color:var(--violet)">${pct}%</span>
</div>
</a>
<button onclick="unassignCourse(${c.id})" title="Убрать из класса"
style="width:28px;height:28px;border-radius:8px;border:none;background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<i data-lucide="x" style="width:13px;height:13px"></i>
</button>
</div>`;
}).join('') + `</div>`;
}
el.innerHTML = html;
if (window.lucide) lucide.createIcons();
// Store available for modal
el._available = available;
} catch (e) {
el.innerHTML = `<div class="empty">Ошибка загрузки: ${esc(e.message)}</div>`;
}
}
async function unassignCourse(courseId) {
if (!currentClass) return;
if (!await LS.confirm('Убрать курс из класса?', { title: 'Убрать курс', confirmText: 'Убрать', danger: true })) return;
try {
await LS.api('/api/courses/class/' + currentClass.id + '/' + courseId, { method: 'DELETE' });
_theoryLoaded = null;
loadClassTheory();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function openAssignCourseModal() {
const el = document.getElementById('theory-content');
const available = el._available || [];
if (!available.length) { LS.toast('Нет доступных курсов для назначения', 'warn'); return; }
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const sel = document.createElement('select');
sel.id = 'assign-course-select';
sel.style.cssText = 'width:100%;padding:10px 12px;border:1.5px solid rgba(15,23,42,0.15);border-radius:12px;font-family:Manrope,sans-serif;font-size:0.92rem;margin-bottom:16px';
available.forEach(c => {
const o = document.createElement('option');
o.value = c.id;
o.textContent = (SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '') + ' · ' + c.title;
sel.appendChild(o);
});
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.4);backdrop-filter:blur(6px);z-index:400;display:flex;align-items:center;justify-content:center;padding:20px';
overlay.innerHTML = `<div style="background:#fff;border-radius:24px;padding:32px;width:100%;max-width:440px;box-shadow:0 32px 80px rgba(15,23,42,0.22)">
<div style="font-family:'Unbounded',sans-serif;font-size:0.92rem;font-weight:800;margin-bottom:20px">Назначить курс классу</div>
<div id="assign-course-sel-wrap"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:4px">
<button id="assign-cancel-btn" style="padding:10px 22px;border:1.5px solid rgba(15,23,42,0.15);border-radius:999px;background:transparent;font-family:Manrope,sans-serif;font-size:0.88rem;font-weight:600;color:var(--text-3);cursor:pointer">Отмена</button>
<button id="assign-confirm-btn" style="padding:10px 28px;border:none;border-radius:999px;background:var(--violet);color:#fff;font-family:Manrope,sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer">Назначить</button>
</div>
</div>`;
overlay.querySelector('#assign-course-sel-wrap').appendChild(sel);
document.body.appendChild(overlay);
overlay.querySelector('#assign-cancel-btn').onclick = () => overlay.remove();
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#assign-confirm-btn').onclick = async () => {
const courseId = parseInt(sel.value);
if (!courseId) return;
try {
await LS.api('/api/courses/class/' + currentClass.id + '/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ courseId }),
});
overlay.remove();
_theoryLoaded = null;
loadClassTheory();
} catch (e) { alert(e.message || 'Ошибка назначения'); }
};
}
/* ══ Modal helpers ══ */
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function closeOnOverlay(e, id) { if (e.target === document.getElementById(id)) closeModal(id); }
/* ══ Personal assignments panel ══ */
async function openPersonalAssignments() {
document.querySelectorAll('.cl-item').forEach(c => c.classList.remove('active'));
document.getElementById('cc-personal').classList.add('active');
currentClass = null;
document.getElementById('cl-placeholder').style.display = 'none';
document.getElementById('detail-panel').style.display = 'none';
const panel = document.getElementById('personal-panel');
panel.classList.remove('panel-visible');
panel.style.display = 'block';
void panel.offsetWidth;
panel.classList.add('panel-visible');
document.getElementById('personal-list').innerHTML = '<div class="spinner"></div>';
try {
const all = await LS.teacherAssignments();
const list = all.filter(a => a.class_id === 0);
renderPersonalList(list);
updatePaSidebarCount(list.length);
} catch (e) {
document.getElementById('personal-list').innerHTML = `<div class="error">${esc(e.message)}</div>`;
}
}
function updatePaSidebarCount(n) {
const s = n === 0 ? 'нет заданий'
: n % 10 === 1 && n % 100 !== 11 ? n + ' задание'
: n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? n + ' задания'
: n + ' заданий';
document.getElementById('pa-sidebar-count').textContent = s;
}
function renderPersonalList(list) {
const el = document.getElementById('personal-list');
if (!list.length) {
el.innerHTML = `<div class="empty" style="padding:30px 0">
Личных заданий пока нет.<br>
<span style="font-size:0.82rem;color:var(--text-3)">
Назначьте задание конкретному ученику: выберите класс → «+ Задание» → в поле «Кому» выберите ученика.
</span>
</div>`;
return;
}
el.innerHTML = list.map(a => {
const subject = SUBJECTS[a.subject_slug] || a.subject_slug;
const done = (a.completed_count || 0) >= 1;
const statusHtml = done
? `<span class="pa-status-done"><svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px;stroke-width:2.5"><polyline points="20 6 9 17 4 12"/></svg> Выполнено</span>`
: `<span class="pa-status-pending"><svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px;stroke-width:2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Ожидает</span>`;
const meta = [subject, a.mode ? (MODES[a.mode] || a.mode) : null, a.count ? a.count + ' вопр.' : null].filter(Boolean).join(' · ');
const safeTitle = esc(a.title).replace(/'/g, "\\'");
return `<div class="pa-item">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px">
<span class="pa-student">
<svg class="ic" viewBox="0 0 24 24" style="width:10px;height:10px;stroke-width:2;fill:none"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
${esc(a.target_user_name || '—')}
</span>
${statusHtml}
${deadlineBadge(a.deadline)}
</div>
<div class="assign-title" style="font-size:0.9rem">${esc(a.title)}</div>
<div class="assign-meta">${meta}</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;margin-top:2px">
${!a.file_id ? `<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="showResults(${a.id},'${safeTitle}')">Результаты</button>` : ''}
<button class="btn-danger" onclick="removePersonalAssignment(${a.id})">Удалить</button>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
async function removePersonalAssignment(id) {
if (!await LS.confirm('Удалить задание?', { title: 'Удалить задание', confirmText: 'Удалить' })) return;
try {
await LS.deleteAssignment(id);
toast('Задание удалено');
openPersonalAssignments();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
loadClasses();
loadStudents(); // preload for student search
LS.notif.init();
// Real-time SSE for page-specific events
LS.connectSSE(ev => {
if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (currentClass) openClass(currentClass.id); // refresh assignment progress
} else if (ev.type === 'join') {
LS.toast(ev.message, 'info');
loadClasses();
}
});
if (window.lucide) lucide.createIcons();
</script>
</div>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>